[object Object]

← 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

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 →