fix(batch): throw when a synchronous processor gets a promise-returning handler - #5650
Conversation
…ng handler `BatchProcessorSync.processRecordSync()` passed the handler's return value straight to `successHandler()`. When the handler is `async`, that value is a pending promise, so every record was recorded as a success, `batchItemFailures` came back empty, and the event source deleted messages that were never really processed. Any rejection surfaced later as an unhandled rejection, after the response had already been returned. The record handler is typed as `CallableFunction`, so TypeScript does not reject an `async` function here, and there was no runtime guard. `SqsFifoPartialProcessor` delegates to `processRecordSync()`, so the non-deprecated FIFO class was affected as well. Add a thenable guard after invoking the handler and throw a `BatchProcessingError` pointing at `BatchProcessor` / `SqsFifoPartialProcessorAsync`. The throw happens outside the try/catch so it fails the whole invocation rather than being recorded as a per-record failure, which is the right outcome for a programming error and mirrors how `BatchProcessor.processRecordSync()` already rejects misuse. `BatchProcessorSync` had no unit tests, which is why this went unnoticed; this adds coverage for the sync processor and the FIFO subclass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018W34ieE3NNGaRQBxj5CKkH
svozza
left a comment
There was a problem hiding this comment.
Thanks for picking this up, and for adding the first unit tests for BatchProcessorSync. The guard is in the right place and the throw sitting outside the try/catch is exactly what we want.
A few things need to change before we can merge. Some of these should have been spelled out more clearly in the issue, so apologies for that.
1. Silence the abandoned promise before throwing
The issue mentioned the unhandled rejections but did not say the fix needs to deal with them. Right now the guard throws and leaves the handler's promise dangling. With a rejecting handler you still get an unhandled rejection after the BatchProcessingError, and in the Lambda Node runtime that shows up as Runtime.UnhandledPromiseRejection and hides the useful message. Attaching a no-op rejection handler before throwing fixes it:
if (isThenable(result)) {
result.then(undefined, () => undefined);
throw new AsyncHandlerNotSupportedError();
}2. Add a test with a rejecting handler
All three tests use a handler that resolves. The rejecting case is the one users actually hit, and Vitest fails the file on an unhandled rejection, so that test also guards point 1. The second test can fold into the first as an extra assertion on processor.successMessages rather than swallowing the throw in an empty catch. Please also add the // Prepare / // Act / // Assess phase comments to match the rest of the suite.
3. Introduce a dedicated error class
Please add AsyncHandlerNotSupportedError extends BatchProcessingError in errors.ts, export it from index.ts, and throw that instead of a bare BatchProcessingError. It gives users something to match on and gives the docs something to name. Keep the message plain text without backticks, since it ends up in CloudWatch.
4. Update the JSDoc examples that this change breaks
Several examples currently pair an async record handler with a synchronous processor, and after this PR they throw at runtime:
packages/batch/src/processPartialResponseSync.ts(three examples)packages/batch/src/SqsFifoPartialProcessor.ts(one example, which also constructs aBatchProcessorinstead ofSqsFifoPartialProcessor)
Please switch those handlers to synchronous functions.
5. Docs
Yes please, fold the docs note into this PR. Two places in docs/features/batch.md:
- Extend the existing migration note in the SQS section to say the synchronous processors throw
AsyncHandlerNotSupportedErrorif the handler returns a Promise. - Add a note in the FIFO section stating that
SqsFifoPartialProcessorrequires a synchronous handler and pointing toSqsFifoPartialProcessorAsyncwithprocessPartialResponse.
Once those are in I am happy to approve. Thanks again.
Introduce AsyncHandlerNotSupportedError so users have something to match on, and take ownership of the abandoned promise's rejection before throwing — otherwise a rejecting handler also produces an unhandled rejection, which the Lambda runtime reports in place of the useful error. Cover the rejecting-handler case in the tests, fold the successMessages check into the first test, fix the four JSDoc examples that paired an async record handler with a synchronous processor, and document the new error in the SQS and FIFO sections of the batch docs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSkXPgm2uonSJnja7ZHdwM
|
All five are in, pushed as 8c97878. 1. Silencing the abandoned promise. Confirmed the behaviour first: with the 2. Rejecting-handler test. Added 3.
4. JSDoc examples. All four handlers are synchronous now. One thing beyond 5. Docs. Extended the existing migration note in the SQS section, and added a Verification: 145 unit tests pass with 100% coverage on |
| // The promise is abandoned here, so take ownership of its rejection first: | ||
| // otherwise a rejecting handler also surfaces as an unhandled rejection, which | ||
| // the Lambda runtime reports instead of the error thrown below. | ||
| result.then(undefined, () => undefined); |
There was a problem hiding this comment.
The guard itself makes sense, and I agree the rejection has to be claimed before the throw or the Lambda runtime reports Runtime.UnhandledPromiseRejection instead of this error. Two things about how it's claimed though.
The no-op handler discards the customer's actual failure. By this point the async handler has already been invoked for the first record, so if it rejects, that rejection is the only evidence of what went wrong inside their code. With this line they see "wrong processor" and nothing else. I'd rather log the reason so the guard doesn't hide a second bug:
| // The promise is abandoned here, so take ownership of its rejection first: | |
| // otherwise a rejecting handler also surfaces as an unhandled rejection, which | |
| // the Lambda runtime reports instead of the error thrown below. | |
| result.then(undefined, () => undefined); | |
| // The promise cannot be awaited here. Claim its rejection so a failing handler | |
| // doesn't also surface as Runtime.UnhandledPromiseRejection and mask the error | |
| // thrown below; log the reason so the handler's own failure is not lost. | |
| result.then(undefined, (reason) => { | |
| console.error( | |
| 'Record handler returned a promise to a synchronous batch processor and later rejected', | |
| reason | |
| ); | |
| }); |
Separately, the comment says the code "takes ownership" of the rejection, which reads as if it's handled. It's suppressed, and the comment is clearer if it says that. The suggestion above rewords it.
One more nit, not on this line: since the handler has already run once when this throws, it'd help if AsyncHandlerNotSupportedError said so. Something like "The record handler was invoked for the first record and returned a promise, but this batch processor is synchronous and cannot await it..." tells the reader that side effects may already be in flight.
Summary
BatchProcessorSyncandSqsFifoPartialProcessoronly support synchronous recordhandlers, but nothing enforced that at runtime.
processRecordSync()passed thehandler's return value straight to
successHandler(), so when the handler wasasyncthat value was a pending promise and every record was recorded as asuccess —
batchItemFailurescame back empty and the event source deletedmessages that were never actually processed. Rejections surfaced afterwards as
unhandled rejections, once the response had already been returned.
The handler is typed as
CallableFunction, so TypeScript does not reject anasyncfunction here either.Changes
BatchProcessorSync.processRecordSync(). If thehandler returns a promise-like value, it throws the new
AsyncHandlerNotSupportedError, which points atBatchProcessor/SqsFifoPartialProcessorAsyncwithprocessPartialResponse().promise. Without it a rejecting handler also produces an unhandled rejection,
which the Lambda Node runtime reports as
Runtime.UnhandledPromiseRejectionin place of the useful error.
try/catchso it fails thewhole invocation rather than being captured as a per-record failure. Misusing a
synchronous processor is a programming error, not a record-level one, and this
mirrors how
BatchProcessor.processRecordSync()already rejects misuse.toBatchType()errors still go throughfailureHandler()exactly as before.SqsFifoPartialProcessordelegates toprocessRecordSync(), so thenon-deprecated FIFO class is covered by the same guard.
AsyncHandlerNotSupportedError extends BatchProcessingErrorinerrors.ts, exported from the package entry point.packages/batch/tests/unit/BatchProcessorSync.test.ts.BatchProcessorSyncpreviously had no unit tests, which is why this went unnoticed; the new tests
cover the sync processor, the FIFO subclass, and the rejecting-handler case.
asyncrecord handler with asynchronous processor, in
processPartialResponseSync.ts(three) andSqsFifoPartialProcessor.ts(one), since they would now throw at runtime.docs/features/batch.md, in the SQS migration noteand in the FIFO section.
Verified locally: all 145 unit tests in
packages/batchpass with 100% coverage,npm run lintandnpm run build:testsare clean. E2E tests were not run as theyrequire live AWS credentials.
Issue number: closes #5627
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.