From 2a37745b5db78bed66e058221b77877d769e493f Mon Sep 17 00:00:00 2001 From: Fabiana Severin Date: Wed, 16 Jul 2025 15:55:12 +0100 Subject: [PATCH 1/2] Adding warning for callback handlers --- src/UserFunction.js | 6 ++ src/WarningForCallbackHandlers.js | 24 ++++++++ src/index.mjs | 2 + test/unit/WarningForCallbackHandlersTest.js | 68 +++++++++++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 src/WarningForCallbackHandlers.js create mode 100644 test/unit/WarningForCallbackHandlersTest.js diff --git a/src/UserFunction.js b/src/UserFunction.js index 1114fb40..93a67d7f 100644 --- a/src/UserFunction.js +++ b/src/UserFunction.js @@ -311,10 +311,16 @@ module.exports.isHandlerFunction = function (value) { return typeof value === 'function'; }; +function _isAsync(handlerFunc) { + return handlerFunc.constructor.name === 'AsyncFunction'; +} + 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 00000000..7c41cf6e --- /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 9267866a..dd1d193e 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/unit/WarningForCallbackHandlersTest.js b/test/unit/WarningForCallbackHandlersTest.js new file mode 100644 index 00000000..82398390 --- /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 From 1ab99907ea58774ad03ac09ad6233b5c0fd7a861 Mon Sep 17 00:00:00 2001 From: Fabiana Severin Date: Wed, 16 Jul 2025 16:30:35 +0100 Subject: [PATCH 2/2] add async handler detection tests --- src/Errors.js | 13 ++++++++--- src/Runtime.js | 22 ++++++++++++++----- src/UserFunction.js | 13 +++++++++-- test/handlers/isAsync.mjs | 17 +++++++++++++++ test/handlers/isAsyncCallback.js | 12 +++++++++++ test/unit/ErrorsTest.js | 19 +++++++++++----- test/unit/IsAsyncTest.js | 37 ++++++++++++++++++++++++++++++++ 7 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 test/handlers/isAsync.mjs create mode 100644 test/handlers/isAsyncCallback.js create mode 100644 test/unit/IsAsyncTest.js diff --git a/src/Errors.js b/src/Errors.js index 6f92075c..c3028991 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 8ec0baa2..77d6eaac 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 93a67d7f..a6e16313 100644 --- a/src/UserFunction.js +++ b/src/UserFunction.js @@ -311,8 +311,17 @@ module.exports.isHandlerFunction = function (value) { return typeof value === 'function'; }; -function _isAsync(handlerFunc) { - return handlerFunc.constructor.name === 'AsyncFunction'; +function _isAsync(handler) { + try { + return ( + handler && + typeof handler === 'function' && + handler.constructor && + handler.constructor.name === 'AsyncFunction' + ); + } catch (error) { + return false; + } } module.exports.getHandlerMetadata = function (handlerFunc) { diff --git a/test/handlers/isAsync.mjs b/test/handlers/isAsync.mjs new file mode 100644 index 00000000..d3be3a36 --- /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 00000000..2c4f99c6 --- /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 8f88ae62..ea98fd19 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 00000000..a4df41ae --- /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