← back to Costa Rica
costa-rica: per-event isolation for the WhatsApp inbound auto-reply loop (Cody cycle-13 deferred item) — TK-10346
85789262110dab7deeab50b0fb2cbe1be1a4e93c · 2026-09-24 02:44:04 -0700 · Steve
routes/webhooks.js POST /whatsapp wrapped handleInbound() AND the whole per-event
auto-reply for-loop in ONE try/catch. So a multi-message payload where the auto-reply
sendButtons for message N threw (a Graph API error, or a timeout now bounded to
~15s by a prior cycle's fetchT wiring) jumped straight to the outer catch — messages
N+1.. in that same batch got NO auto-reply. Deferred from cycle 13 as a real,
non-blocking follow-up (the old unbounded-hang version of this bug was worse: an
entire request hang; fetchT already reduced the blast radius to "skip the rest of
this batch").
Fix: handleInbound() keeps its own try/catch (a throw there means no events at all,
route still 200s). Each event's sendButtons call now has its OWN try/catch — a
failure on one event is logged and does not skip its siblings. The route always
res.sendStatus(200) regardless (never triggers a Meta retry storm).
Cody gate — clean pass, ship it, verified with a NEGATIVE test (not just a positive
one): stashed only the route file, reran the new test against the OLD code -> it
FAILED (1/13) as expected (proving the test is a real regression guard, not
theater); against the NEW code -> 13/13. Also verified: the argument-evaluation
concern (does ev.contact.wa_id throwing outside the try break the 200-to-Meta
guarantee?) is a false alarm — argument evaluation is part of the call expression,
which sits inside the per-event try; and handleInbound's own return shape can never
produce an event with a missing .contact (if contactByWaId throws, handleInbound
throws first, caught by the outer catch, before the loop ever runs). No new
double-processing (firstTime dedup runs before handleInbound); same-wa_id double-send
in one batch is pre-existing, unrelated to this diff; log-and-swallow matches the
existing confirmBooking WA-notify pattern.
Tests (+1, suite 197 -> 198): stub handleInbound with 2 greeting events, make the
first sendButtons throw, assert the second is still attempted and the route still 200s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
Files touched
M routes/webhooks.jsM test/webhooks-route.test.js
Diff
commit 85789262110dab7deeab50b0fb2cbe1be1a4e93c
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Sep 24 02:44:04 2026 -0700
costa-rica: per-event isolation for the WhatsApp inbound auto-reply loop (Cody cycle-13 deferred item) — TK-10346
routes/webhooks.js POST /whatsapp wrapped handleInbound() AND the whole per-event
auto-reply for-loop in ONE try/catch. So a multi-message payload where the auto-reply
sendButtons for message N threw (a Graph API error, or a timeout now bounded to
~15s by a prior cycle's fetchT wiring) jumped straight to the outer catch — messages
N+1.. in that same batch got NO auto-reply. Deferred from cycle 13 as a real,
non-blocking follow-up (the old unbounded-hang version of this bug was worse: an
entire request hang; fetchT already reduced the blast radius to "skip the rest of
this batch").
Fix: handleInbound() keeps its own try/catch (a throw there means no events at all,
route still 200s). Each event's sendButtons call now has its OWN try/catch — a
failure on one event is logged and does not skip its siblings. The route always
res.sendStatus(200) regardless (never triggers a Meta retry storm).
Cody gate — clean pass, ship it, verified with a NEGATIVE test (not just a positive
one): stashed only the route file, reran the new test against the OLD code -> it
FAILED (1/13) as expected (proving the test is a real regression guard, not
theater); against the NEW code -> 13/13. Also verified: the argument-evaluation
concern (does ev.contact.wa_id throwing outside the try break the 200-to-Meta
guarantee?) is a false alarm — argument evaluation is part of the call expression,
which sits inside the per-event try; and handleInbound's own return shape can never
produce an event with a missing .contact (if contactByWaId throws, handleInbound
throws first, caught by the outer catch, before the loop ever runs). No new
double-processing (firstTime dedup runs before handleInbound); same-wa_id double-send
in one batch is pre-existing, unrelated to this diff; log-and-swallow matches the
existing confirmBooking WA-notify pattern.
Tests (+1, suite 197 -> 198): stub handleInbound with 2 greeting events, make the
first sendButtons throw, assert the second is still attempted and the route still 200s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
---
routes/webhooks.js | 24 ++++++++++++++++--------
test/webhooks-route.test.js | 29 +++++++++++++++++++++++++++++
2 files changed, 45 insertions(+), 8 deletions(-)
diff --git a/routes/webhooks.js b/routes/webhooks.js
index 841a74d..7b0ce64 100644
--- a/routes/webhooks.js
+++ b/routes/webhooks.js
@@ -86,19 +86,27 @@ router.post('/whatsapp', raw, async (req, res) => {
let body; try { body = JSON.parse(req.body.toString()); } catch { return res.sendStatus(400); }
const evId = body.entry?.[0]?.id + ':' + (body.entry?.[0]?.changes?.[0]?.value?.messages?.[0]?.id || Date.now());
if (!(await firstTime('whatsapp', evId, 'inbound', null))) return res.sendStatus(200);
- try {
- const events = await wa.handleInbound(body);
- // Auto-reply hook: a simple keyword router (extend as needed).
- for (const ev of events) {
- const t = (ev.text || '').toLowerCase();
- if (/^(hola|hi|hello|menu|ayuda|help)/.test(t)) {
+ let events = [];
+ try { events = await wa.handleInbound(body); }
+ catch (e) { console.warn('[wa webhook] handleInbound', e.message); }
+ // Auto-reply hook: a simple keyword router (extend as needed). PER-EVENT isolation
+ // (Cody cycle-13 finding): a payload can carry multiple inbound messages: the old
+ // code wrapped the WHOLE loop in one try/catch, so a slow/failing sendButtons for
+ // message N (now bounded to ~15s by fetchT, but still real) threw straight to the
+ // outer catch and messages N+1.. got NO auto-reply in that batch. Each event's send
+ // is now independently try/caught — a failure on one is logged and does not skip
+ // its siblings. The route always 200s to Meta either way (no retry storm).
+ for (const ev of events) {
+ const t = (ev.text || '').toLowerCase();
+ if (/^(hola|hi|hello|menu|ayuda|help)/.test(t)) {
+ try {
await wa.sendButtons(ev.contact.wa_id,
'¡Hola! ¿En qué te ayudamos? / How can we help?',
[{ id: 'browse', title: 'Ver listados' }, { id: 'mybookings', title: 'Mis reservas' }, { id: 'support', title: 'Soporte' }],
{ header: 'Costa Rica' });
- }
+ } catch (e) { console.warn('[wa webhook] auto-reply', ev.contact?.wa_id, e.message); }
}
- } catch (e) { console.warn('[wa webhook]', e.message); }
+ }
res.sendStatus(200);
});
diff --git a/test/webhooks-route.test.js b/test/webhooks-route.test.js
index d0c2a7e..623f4f5 100644
--- a/test/webhooks-route.test.js
+++ b/test/webhooks-route.test.js
@@ -77,6 +77,35 @@ test('SECURITY: unsigned WhatsApp webhook (no header) → 401 and NO DB write',
assert.equal(sqls.length, 0);
});
+// PER-EVENT ISOLATION (Cody cycle-13 finding, fixed cycle 22): a payload with
+// multiple inbound messages must not let a failing auto-reply for message N starve
+// the auto-reply for message N+1. Stub handleInbound to return 2 greeting events;
+// the first sendButtons throws; assert the second is STILL attempted, and the route
+// still 200s to Meta regardless (no retry storm).
+test('WhatsApp webhook: a failing auto-reply for one inbound event does NOT skip the next event\'s auto-reply (200 either way)', async () => {
+ const sendCalls = [];
+ const origSendButtons = wa.sendButtons;
+ wa.handleInbound = async () => [
+ { contact: { wa_id: '50611111111' }, text: 'hola' },
+ { contact: { wa_id: '50622222222' }, text: 'hi there' },
+ ];
+ wa.sendButtons = async (waId, ...rest) => {
+ sendCalls.push(waId);
+ if (waId === '50611111111') throw new Error('simulated send failure for the first event');
+ return { ok: true };
+ };
+ try {
+ const body = JSON.stringify({ entry: [{ id: 'multi', changes: [{ value: { messages: [{ id: 'm-multi' }] } }] }] });
+ const r = await post('/webhooks/whatsapp', body, { 'x-hub-signature-256': waSign(body) });
+ assert.equal(r.status, 200, 'the webhook still 200s to Meta even though one auto-reply failed');
+ assert.deepEqual(sendCalls, ['50611111111', '50622222222'],
+ 'BOTH events were attempted — the first\'s failure did not skip the second');
+ } finally {
+ wa.handleInbound = async () => []; // restore the suite's default stub
+ wa.sendButtons = origSendButtons;
+ }
+});
+
test('a correctly-signed WhatsApp webhook passes the gate (200) and does the dedup INSERT', async () => {
sqls = [];
const body = JSON.stringify({ entry: [{ id: 'abc', changes: [{ value: { messages: [{ id: 'm1' }] } }] }] });
← ab1e4be cycle 21 docs: YOLO_NOTES ledger — deterministic (serial) te
·
back to Costa Rica
·
cycle 22 docs: YOLO_NOTES ledger — per-event WhatsApp auto-r 1ccb0ed →