fix(wrappers/cron): don't leave an unhandled rejection when a bot promise rejects - #225
Conversation
…mise rejects
When a bot handler returns a promise, the wrapper attached both
`promise.then(...)` and `promise.catch(...)` to the *same* promise.
Those are siblings, not a chain, so a rejection produced two outcomes:
- the `.catch()` handled it correctly and reported the bot complete
with status "error";
- the `.then()`-derived chain also rejected, with no handler.
Node's default unhandled-rejection mode is `throw`, and the wrapper
removes Lambda's own `uncaughtException` listener without registering an
`unhandledRejection` one, so that orphaned chain terminated the process
*after* the bot had already been reported complete. On Lambda this
surfaces as Runtime.UnhandledPromiseRejection / Runtime.ExitError.
Pass the rejection handler as the second argument to `then()` instead.
That gives a rejection exactly one handler and leaves no orphaned chain,
while preserving existing behaviour for both branches:
- `botHandler.length < 3` keeps the success path plus error reporting;
- 3-arg (callback-style) handlers still only get the rejection path,
since they report their own success through the callback.
Using `then(onFulfilled, onRejected)` rather than `.then().catch()` is
deliberate: it keeps `onRejected` scoped to a rejection of the handler's
promise, so a throw inside the success path is not newly rerouted into
the error branch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
|
Seeing the same signature on item-prod-item-aux-update-catalog (20:23 UTC), same stack through index.mjs:1448:17. Also getting two runs that time out at the 10-min hard limit instead of rejecting — @smithy/node-http-handler warnings about a 20s request hanging, then killed by Lambda with no unhandled-rejection logged. Not sure if this covers that hang case. |
|
I independently re-verified this against the repo and CloudWatch before review. The diagnosis and the fix both hold up — the sibling I edited the description to correct two details the logs contradicted (@MrCooper42, please yell if you'd rather I hadn't):
@emely-rithum — confirmed your On the monitoring note: neither affected function currently has a CloudWatch alarm on its |
Problem
When a bot handler returns a promise,
wrappers/cron.jsattaches bothpromise.then(...)andpromise.catch(...)to the same promise:Those two are siblings, not a chain. When the handler's promise rejects, there are two outcomes:
.catch()chain handles it correctly — logs[LEOCRON]:completeand reports status"error"..then()-derived chain also rejects, and nothing handles it.Node's default unhandled-rejection mode is
throw, and in that mode the rejection is first delivered to anyunhandledRejectionlisteners before being raised as an exception. The Lambda runtime registers one —/var/runtime/index.mjs:1448, visible in the crash stack — which reportsRuntime.UnhandledPromiseRejectionand exits the process. The SDK never registers anunhandledRejectionlistener of its own, so the orphaned chain is fatal. (The wrapper's removal ofuncaughtExceptionlisteners at lines 61-63 plays no role here; that path is never reached.)On Lambda this surfaces as
Runtime.UnhandledPromiseRejection→Runtime.ExitError, killing the sandbox mid-write — see below for what that costs.Observed in production
A bot whose write stream exhausted its retries (leo-sdk's
toLeocalls back with the bare string'failed') produced this sequence — note the ordering, which shows leo's error branch starting and then the process being killed 5ms later:The error report never actually lands. The
[LEOCRON]:completeline is only the log statement; thereportCompleteDynamoDB write behind it is killed in flight when the process exits 5ms later, so the error status is never recorded and the cron lock is never released. CloudWatch shows the consequence: this run acquired its lock at 20:27:21.9 with a 300s Lambda timeout, and the next invocation arrived at 20:32:22.2 — exactly at lock expiry — where the normal cadence is back-to-back re-invocation within a second. (The preceding crash shows the same pattern to the second: lock at 20:22:20.5 → next run 20:27:21.9.) So each rejection today costs the real error report, ~4 minutes of the bot stalled on a dead lock, and a destroyed sandbox / cold start. This bot has been hitting it 1–6 times per day since at least July 26.This is not specific to that
'failed'value. ThebotHandler.length < 3gate means anybotWrapper(async (settings, context) => …)bot gets the orphaned.then(), so any rejection from any async bot handler becomes a fatalRuntime.ExitErroron top of the SDK's own correct error reporting.Present on
masterand unchanged across at least 7.1.8 → 7.1.21.Fix
Pass the rejection handler as the second argument to
then()instead of attaching a separate.catch(). A rejection then has exactly one handler and no orphaned chain, while both existing branches keep their current behaviour:botHandler.length < 3→ success path plus error reporting.Using
then(onFulfilled, onRejected)rather than.then().catch()is deliberate: it keepsonRejectedscoped to a rejection of the handler's promise, so a throw inside the success path isn't newly rerouted into the error branch. That keeps this change behaviour-preserving apart from removing the crash.Behavioural note worth flagging for reviewers
Today a rejection attempts
reportComplete("error")but the write is killed in flight (see above) — so in practice the failure is visible only in the LambdaErrorsmetric, while the LeoCron error record is lost and the lock stalls the bot until expiry. After this change the Lambda no longer fails — it callscallback(null, err), exactly as the existing callback-style path at line 176-181 already does, and the LeoCron error record reliably persists.That is consistent with the SDK's design (bot errors are reported through the cron record, not through Lambda failure), but it does mean these failures stop appearing in the Lambda
Errorsmetric and show up only via the LeoCronerrorCount/ botmon. Anyone alerting on Lambda errors for async bots would see those alarms go quiet — though as of 2026-08-08 neither affected function found so far (media-service-prod-media-switcher,item-prod-item-aux-update-catalog) has a CloudWatch alarm on it. I think consistency with the callback path is the right call, but it's a real monitoring change and I'd rather surface it than have it discovered later.Testing
npm run compile && mocha "test/*.utest.js"→ 297 passing, 0 failuresnode --check wrappers/cron.jsclean;npx eslint wrappers/cron.jscleanI did not add a unit test for this.
wrappers/cron.jscurrently has no test coverage, andlib/mock-sdk.mdstates the position explicitly — "Don't test the wrapper — it's framework plumbing" — noting that the wrapper "creates its own internal SDK instance which cannot be swapped out." Testing it would needrequire-level interception of../index.js, which isn't a pattern used anywhere in the suite today. Happy to add one if you'd like, and #212 looks like the natural home for that kind of harness.Notes
ESproject — feel free to retitle if you want one attached.errreferenced inside thethen()success handler resolves to the enclosingcron.checkLockcallback'serr(line 161), not the handler's error. It's always falsy on that path, so the current behaviour is correct, but it reads as though it were the handler's error. Left alone to keep this diff surgical.Note
Medium Risk
Touches core cron/Lambda completion plumbing for all async bots; behavior change stops Lambda invocation failures on handler promise rejections, which can affect error-based alerting even though cron error reporting is unchanged.
Overview
Fixes a fatal
Runtime.ExitErroron async cron bots when the handler’s promise rejects.Previously
wrappers/cron.jsattached separatepromise.then()andpromise.catch()on the same promise. On rejection,.catch()reported cron complete with"error", but the.then()-derived chain stayed unhandled and Node’s default unhandled-rejection behavior could kill the Lambda after the bot was already marked complete.The change wires rejections through
promise.then(onFulfilled, onRejected)(sharedonRejectedhelper) for handlers withbotHandler.length < 3, and uses only.catch(onRejected)for three-argument callback-style handlers. That preserves the existing success vs error reporting paths without an orphaned rejection chain.After the fix, rejected async bots finish via
callback(null, err)like the callback path instead of also failing the invocation—so LambdaErrorsmetrics may drop for these cases while cron/botmon error reporting remains.Reviewed by Cursor Bugbot for commit b623277. Bugbot is set up for automated code reviews on this repo. Configure here.