diff --git a/src/Errors.js b/src/Errors.js index 6f92075..c302899 100644 --- a/src/Errors.js +++ b/src/Errors.js @@ -38,9 +38,9 @@ function toRapidResponse(error) { try { if (util.types.isNativeError(error) || _isError(error)) { return { - errorType: error.name?.replace(/\x7F/g, '%7F'), - errorMessage: error.message?.replace(/\x7F/g, '%7F'), - trace: error.stack.replace(/\x7F/g, '%7F').split('\n'), + errorType: error.name?.replaceAll('\x7F', '%7F'), + errorMessage: error.message?.replaceAll('\x7F', '%7F'), + trace: error.stack.replaceAll('\x7F', '%7F').split('\n'), }; } else { return { @@ -106,6 +106,13 @@ const errorClasses = [ class UserCodeSyntaxError extends Error {}, class MalformedStreamingHandler extends Error {}, class InvalidStreamingOperation extends Error {}, + class NodeJsExit extends Error { + constructor() { + super( + 'The Lambda runtime client detected an unexpected Node.js exit code. This is most commonly caused by a Promise that was never settled. For more information, see https://nodejs.org/docs/latest/api/process.html#exit-codes', + ); + } + }, class UnhandledPromiseRejection extends Error { constructor(reason, promise) { super(reason); diff --git a/src/Runtime.js b/src/Runtime.js index 8ec0baa..77d6eaa 100644 --- a/src/Runtime.js +++ b/src/Runtime.js @@ -12,7 +12,9 @@ const CallbackContext = require('./CallbackContext.js'); const StreamingContext = require('./StreamingContext.js'); const BeforeExitListener = require('./BeforeExitListener.js'); const { STREAM_RESPONSE } = require('./UserFunction.js'); +const { NodeJsExit } = require('./Errors.js'); const { verbose, vverbose } = require('./VerboseLog.js').logger('RAPID'); +const { structuredConsole } = require('./LogPatch'); module.exports = class Runtime { constructor(client, handler, handlerMetadata, errorCallbacks) { @@ -69,7 +71,7 @@ module.exports = class Runtime { try { this._setErrorCallbacks(invokeContext.invokeId); - this._setDefaultExitListener(invokeContext.invokeId, markCompleted); + this._setDefaultExitListener(invokeContext.invokeId, markCompleted, this.handlerMetadata.isAsync); let result = this.handler( JSON.parse(bodyJson), @@ -178,12 +180,22 @@ module.exports = class Runtime { * called and the handler is not async. * CallbackContext replaces the listener if a callback is invoked. */ - _setDefaultExitListener(invokeId, markCompleted) { + _setDefaultExitListener(invokeId, markCompleted, isAsync) { BeforeExitListener.set(() => { markCompleted(); - this.client.postInvocationResponse(null, invokeId, () => - this.scheduleIteration(), - ); + // if the handle signature is async, we do want to fail the invocation + if (isAsync) { + const nodeJsExitError = new NodeJsExit(); + structuredConsole.logError('Invoke Error', nodeJsExitError); + this.client.postInvocationError(nodeJsExitError, invokeId, () => + this.scheduleIteration(), + ); + // if the handler signature is sync, or use callback, we do want to send a successful invocation with a null payload if the customer forgot to call the callback + } else { + this.client.postInvocationResponse(null, invokeId, () => + this.scheduleIteration(), + ); + } }); } diff --git a/src/UserFunction.js b/src/UserFunction.js index 1114fb4..a6e1631 100644 --- a/src/UserFunction.js +++ b/src/UserFunction.js @@ -311,10 +311,25 @@ module.exports.isHandlerFunction = function (value) { return typeof value === 'function'; }; +function _isAsync(handler) { + try { + return ( + handler && + typeof handler === 'function' && + handler.constructor && + handler.constructor.name === 'AsyncFunction' + ); + } catch (error) { + return false; + } +} + module.exports.getHandlerMetadata = function (handlerFunc) { return { streaming: _isHandlerStreaming(handlerFunc), highWaterMark: _highWaterMark(handlerFunc), + isAsync: _isAsync(handlerFunc), + argsNum: handlerFunc.length, }; }; diff --git a/src/WarningForCallbackHandlers.js b/src/WarningForCallbackHandlers.js new file mode 100644 index 0000000..7c41cf6 --- /dev/null +++ b/src/WarningForCallbackHandlers.js @@ -0,0 +1,24 @@ +/* +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: Apache-2.0 +*/ + +'use strict'; + +const shouldWarnOnCallbackFunctionUse = (metadata) => { + return ( + process.env.AWS_LAMBDA_NODEJS_DISABLE_CALLBACK_WARNING === undefined && + metadata !== undefined && + metadata.argsNum == 3 && + metadata.isAsync == false && + metadata.streaming == false + ); +}; + +module.exports.checkForDeprecatedCallback = function (metadata) { + if (shouldWarnOnCallbackFunctionUse(metadata)) { + console.warn( + `AWS Lambda plans to remove support for callback-based function handlers starting with Node.js 24. You will need to update this function to use an async handler to use Node.js 24 or later. For more information and to provide feedback on this change, see https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/issues/137. To disable this warning, set the AWS_LAMBDA_NODEJS_DISABLE_CALLBACK_WARNING environment variable.`, + ); + } +}; \ No newline at end of file diff --git a/src/index.mjs b/src/index.mjs index 9267866..dd1d193 100755 --- a/src/index.mjs +++ b/src/index.mjs @@ -11,6 +11,7 @@ const UserFunction = require('./UserFunction.js'); const Errors = require('./Errors.js'); const BeforeExitListener = require('./BeforeExitListener.js'); const LogPatch = require('./LogPatch'); +const { checkForDeprecatedCallback } = require('./WarningForCallbackHandlers'); export async function run(appRootOrHandler, handler = '') { LogPatch.patchConsole(); @@ -44,6 +45,7 @@ export async function run(appRootOrHandler, handler = '') { : await UserFunction.load(appRootOrHandler, handler); const metadata = UserFunction.getHandlerMetadata(handlerFunc); + checkForDeprecatedCallback(metadata); new Runtime( client, handlerFunc, diff --git a/test/handlers/isAsync.mjs b/test/handlers/isAsync.mjs new file mode 100644 index 0000000..d3be3a3 --- /dev/null +++ b/test/handlers/isAsync.mjs @@ -0,0 +1,17 @@ +/** Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. */ + +export const handlerAsync = async () => { + const response = { + statusCode: 200, + body: JSON.stringify('Hello from Lambda!'), + }; + return response; +}; + +export const handlerNotAsync = () => { + const response = { + statusCode: 200, + body: JSON.stringify('Hello from Lambda!'), + }; + return response; +}; \ No newline at end of file diff --git a/test/handlers/isAsyncCallback.js b/test/handlers/isAsyncCallback.js new file mode 100644 index 0000000..2c4f99c --- /dev/null +++ b/test/handlers/isAsyncCallback.js @@ -0,0 +1,12 @@ +/** Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. */ + +'use strict'; + +exports.handler = (_event, _context, callback) => { + callback(null, { + statusCode: 200, + body: JSON.stringify({ + message: 'hello world', + }), + }); +}; \ No newline at end of file diff --git a/test/unit/ErrorsTest.js b/test/unit/ErrorsTest.js index 8f88ae6..ea98fd1 100644 --- a/test/unit/ErrorsTest.js +++ b/test/unit/ErrorsTest.js @@ -1,6 +1,4 @@ -/** - * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. - */ +/** Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. */ 'use strict'; @@ -22,11 +20,22 @@ describe('Formatted Error Logging', () => { describe('Invalid chars in HTTP header', () => { it('should be replaced', () => { - let errorWithInvalidChar = new Error('\x7F \x7F'); + let errorWithInvalidChar = new Error('\x7F'); errorWithInvalidChar.name = 'ErrorWithInvalidChar'; let loggedError = Errors.toRapidResponse(errorWithInvalidChar); loggedError.should.have.property('errorType', 'ErrorWithInvalidChar'); - loggedError.should.have.property('errorMessage', '%7F %7F'); + loggedError.should.have.property('errorMessage', '%7F'); + }); +}); + +describe('NodeJsExit error ctor', () => { + it('should be have a fixed reason', () => { + let nodeJsExit = new Errors.NodeJsExit(); + let loggedError = Errors.toRapidResponse(nodeJsExit); + loggedError.should.have.property('errorType', 'Runtime.NodeJsExit'); + loggedError.errorMessage.should.containEql( + 'runtime client detected an unexpected Node.js', + ); }); }); diff --git a/test/unit/IsAsyncTest.js b/test/unit/IsAsyncTest.js new file mode 100644 index 0000000..a4df41a --- /dev/null +++ b/test/unit/IsAsyncTest.js @@ -0,0 +1,37 @@ +/** Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. */ + +'use strict'; + +require('should'); +const path = require('path'); +const UserFunction = require('lambda-runtime/UserFunction.js'); + +const TEST_ROOT = path.join(__dirname, '../'); +const HANDLERS_ROOT = path.join(TEST_ROOT, 'handlers'); + +describe('isAsync tests', () => { + it('is async should be true', async () => { + const handlerFunc = await UserFunction.load( + HANDLERS_ROOT, + 'isAsync.handlerAsync', + ); + const metadata = UserFunction.getHandlerMetadata(handlerFunc); + metadata.isAsync.should.be.true(); + }); + it('is async should be false', async () => { + const handlerFunc = await UserFunction.load( + HANDLERS_ROOT, + 'isAsync.handlerNotAsync', + ); + const metadata = UserFunction.getHandlerMetadata(handlerFunc); + metadata.isAsync.should.be.false(); + }); + it('is async should be false since it is a callback', async () => { + const handlerFunc = await UserFunction.load( + HANDLERS_ROOT, + 'isAsyncCallback.handler', + ); + const metadata = UserFunction.getHandlerMetadata(handlerFunc); + metadata.isAsync.should.be.false(); + }); +}); \ No newline at end of file diff --git a/test/unit/WarningForCallbackHandlersTest.js b/test/unit/WarningForCallbackHandlersTest.js new file mode 100644 index 0000000..8239839 --- /dev/null +++ b/test/unit/WarningForCallbackHandlersTest.js @@ -0,0 +1,68 @@ +/* +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: Apache-2.0 +*/ + +'use strict'; + +require('should'); + +let { captureStream, consoleSnapshot } = require('./LoggingGlobals'); + +let { + checkForDeprecatedCallback, +} = require('../../src/WarningForCallbackHandlers.js'); + +let LogPatch = require('lambda-runtime/LogPatch'); +const UserFunction = require('lambda-runtime/UserFunction.js'); + +const path = require('path'); +const TEST_ROOT = path.join(__dirname, '../'); +const HANDLERS_ROOT = path.join(TEST_ROOT, 'handlers'); + +describe('Formatted Error Logging', () => { + let restoreConsole = consoleSnapshot(); + let capturedStdout = captureStream(process.stdout); + + beforeEach( + 'delete env var', + () => delete process.env.AWS_LAMBDA_NODEJS_DISABLE_CALLBACK_WARNING, + ); + beforeEach('capture stdout', () => capturedStdout.hook()); + beforeEach('apply console patch', () => LogPatch.patchConsole()); + afterEach('remove console patch', () => restoreConsole()); + afterEach('unhook stdout', () => capturedStdout.unhook()); + + const expectedString = + 'AWS Lambda plans to remove support for callback-based function handlers'; + + const tests = [ + { args: [false, 'isAsyncCallback.handler'], expected: true }, + { args: [true, 'isAsyncCallback.handler'], expected: false }, + { args: [false, 'isAsync.handlerAsync'], expected: false }, + { args: [true, 'isAsync.handlerAsync'], expected: false }, + { args: [false, 'defaultHandler.default'], expected: false }, + { args: [true, 'defaultHandler.default'], expected: false }, + ]; + + tests.forEach(({ args, expected }) => { + const shouldDeclareEnv = args[0]; + const handler = args[1]; + it(`When AWS_LAMBDA_NODEJS_DISABLE_CALLBACK_WARNING=${shouldDeclareEnv} expecting ${ + expected ? 'no ' : '' + }warning logs for handler ${handler}`, async () => { + if (shouldDeclareEnv) { + process.env.AWS_LAMBDA_NODEJS_DISABLE_CALLBACK_WARNING = 1; + } + const handlerFunc = await UserFunction.load(HANDLERS_ROOT, handler); + const metadata = UserFunction.getHandlerMetadata(handlerFunc); + + checkForDeprecatedCallback(metadata); + if (expected) { + capturedStdout.captured().should.containEql(expectedString); + } else { + capturedStdout.captured().should.not.containEql(expectedString); + } + }); + }); +}); \ No newline at end of file