[object Object]

← back to Costa Rica

costa-rica: bound WhatsApp Graph API fetch with fetchT (Cody-cleared, cycle 13) — TK-10346

3089df86bbd510afa8b2b13cfde4b7e24eb3cc4a · 2026-09-23 22:06:59 -0700 · Steve

Cycle-13 audit of lib/whatsapp.js. _send() (the Meta Graph API sender) used raw
fetch() with no timeout — the same unbounded-hang class already closed for
tilopay/onvo/plaid. Swapped to the shared fetchT.

WHY IT MATTERS despite sends being best-effort: callers wrap wa.send*() in
try/catch, but that catches an ERROR, not a HANG. A stalled Graph connection on
raw fetch() never rejects, so `await wa.sendText(...)` would hang FOREVER,
stalling confirmBooking and its money-path callers (/pay, the payment webhook,
GET /payments/:id). fetchT turns the hang into a catchable PROVIDER_TIMEOUT throw.

Cody gate (focused — mechanically identical to 3 prior precedented swaps, so
scoped to caller-side handling of the new throw rather than re-auditing fetchT
internals): SHIP IT, clean. Exhaustive grep found exactly 2 real call sites
(confirmBooking's notify + the webhooks inbound auto-reply), both already
try/catch-wrapped as best-effort; the throw happens in _send BEFORE any
DB persistence (contactByWaId/logMessage never run); no .catch() anywhere
silently eats it; markRead/sendTemplate/sendList/sendImage/sendDocument/
sendLocation have ZERO callers in the repo (not a landmine, just unused).

Noted, not a regression (pre-existing, made LESS bad not worse — follow-up
ticket, not a blocker): routes/webhooks.js's inbound auto-reply wraps the WHOLE
per-payload for-loop in one try/catch, not per-event — a stalled sendButtons on
message N would skip auto-replies for messages N+1.. in the same batch. Before
this diff the same batch would have hung the ENTIRE webhook request forever;
now it's bounded to one timeout window. Move the try/catch inside the loop in a
future cycle for per-event isolation.

Tests (+3, suite 175 -> 178): stalled body -> PROVIDER_TIMEOUT; Graph HTTP error
fails closed (throws before any DB write); sandbox never hits the network.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic

Files touched

Diff

commit 3089df86bbd510afa8b2b13cfde4b7e24eb3cc4a
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Sep 23 22:06:59 2026 -0700

    costa-rica: bound WhatsApp Graph API fetch with fetchT (Cody-cleared, cycle 13) — TK-10346
    
    Cycle-13 audit of lib/whatsapp.js. _send() (the Meta Graph API sender) used raw
    fetch() with no timeout — the same unbounded-hang class already closed for
    tilopay/onvo/plaid. Swapped to the shared fetchT.
    
    WHY IT MATTERS despite sends being best-effort: callers wrap wa.send*() in
    try/catch, but that catches an ERROR, not a HANG. A stalled Graph connection on
    raw fetch() never rejects, so `await wa.sendText(...)` would hang FOREVER,
    stalling confirmBooking and its money-path callers (/pay, the payment webhook,
    GET /payments/:id). fetchT turns the hang into a catchable PROVIDER_TIMEOUT throw.
    
    Cody gate (focused — mechanically identical to 3 prior precedented swaps, so
    scoped to caller-side handling of the new throw rather than re-auditing fetchT
    internals): SHIP IT, clean. Exhaustive grep found exactly 2 real call sites
    (confirmBooking's notify + the webhooks inbound auto-reply), both already
    try/catch-wrapped as best-effort; the throw happens in _send BEFORE any
    DB persistence (contactByWaId/logMessage never run); no .catch() anywhere
    silently eats it; markRead/sendTemplate/sendList/sendImage/sendDocument/
    sendLocation have ZERO callers in the repo (not a landmine, just unused).
    
    Noted, not a regression (pre-existing, made LESS bad not worse — follow-up
    ticket, not a blocker): routes/webhooks.js's inbound auto-reply wraps the WHOLE
    per-payload for-loop in one try/catch, not per-event — a stalled sendButtons on
    message N would skip auto-replies for messages N+1.. in the same batch. Before
    this diff the same batch would have hung the ENTIRE webhook request forever;
    now it's bounded to one timeout window. Move the try/catch inside the loop in a
    future cycle for per-event isolation.
    
    Tests (+3, suite 175 -> 178): stalled body -> PROVIDER_TIMEOUT; Graph HTTP error
    fails closed (throws before any DB write); sandbox never hits the network.
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
---
 lib/whatsapp.js               | 10 ++++-
 test/whatsapp-timeout.test.js | 90 +++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 99 insertions(+), 1 deletion(-)

diff --git a/lib/whatsapp.js b/lib/whatsapp.js
index 8c834e5..4f71388 100644
--- a/lib/whatsapp.js
+++ b/lib/whatsapp.js
@@ -17,6 +17,7 @@
 
 const crypto = require('crypto');
 const { pool } = require('./db');
+const { fetchT } = require('./payments/http'); // bound live Graph API calls (no infinite hang)
 
 const TOKEN = process.env.WHATSAPP_TOKEN || '';
 const PHONE_ID = process.env.WHATSAPP_PHONE_ID || '';
@@ -31,7 +32,14 @@ async function _send(payload) {
   if (!LIVE) {
     return { messages: [{ id: `wamid.SBX_${crypto.randomBytes(8).toString('hex')}` }], sandbox: true };
   }
-  const res = await fetch(GRAPH(`${PHONE_ID}/messages`), {
+  // fetchT bounds connect+headers AND the body read (shared with tilopay/onvo/plaid).
+  // WHY IT MATTERS HERE: sends are best-effort and callers (confirmBooking, the
+  // webhook auto-reply) wrap them in try/catch — but that catches an ERROR, not a
+  // HANG. A stalled Graph API connection on raw fetch() never rejects, so the
+  // caller's `await wa.sendText(...)` would hang FOREVER, stalling confirmBooking and
+  // its money-path callers (/pay, the payment webhook, GET /payments/:id). fetchT
+  // turns the hang into a PROVIDER_TIMEOUT throw the existing try/catch handles.
+  const res = await fetchT(GRAPH(`${PHONE_ID}/messages`), {
     method: 'POST',
     headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
     body: JSON.stringify({ messaging_product: 'whatsapp', ...payload }),
diff --git a/test/whatsapp-timeout.test.js b/test/whatsapp-timeout.test.js
new file mode 100644
index 0000000..06b4b62
--- /dev/null
+++ b/test/whatsapp-timeout.test.js
@@ -0,0 +1,90 @@
+'use strict';
+// lib/whatsapp.js now routes its live Graph API sends through fetchT (PRE-FLIGHT
+// #4/#5 class). WHY it matters: sends are best-effort and callers wrap them in
+// try/catch, but that catches an ERROR, not a HANG — a stalled Graph connection on
+// raw fetch() would hang confirmBooking forever (and its money-path callers). This
+// proves the wiring: a header-fast/body-stalled send rejects with PROVIDER_TIMEOUT
+// (so the caller's try/catch fires) instead of hanging, and a Graph HTTP error
+// still fails closed (throws). Forces LIVE via env + require-cache reset; faked
+// fetch, so no network and no DB (the throw happens in _send before any persistence).
+
+const { test } = require('node:test');
+const assert = require('node:assert');
+
+const WA = require.resolve('../lib/whatsapp');
+const realFetch = global.fetch;
+
+function loadLiveWa() {
+  process.env.WHATSAPP_TOKEN = 'tok';
+  process.env.WHATSAPP_PHONE_ID = '123456';
+  delete require.cache[WA];
+  return require(WA);
+}
+function unloadLiveWa() {
+  delete process.env.WHATSAPP_TOKEN;
+  delete process.env.WHATSAPP_PHONE_ID;
+  delete require.cache[WA];
+}
+
+test('wa sendText: a body-stalled Graph response rejects PROVIDER_TIMEOUT (not an infinite hang that would stall confirmBooking)', async () => {
+  process.env.PROVIDER_HTTP_TIMEOUT_MS = '40';
+  const wa = loadLiveWa();
+  global.fetch = (url, opts) => Promise.resolve({
+    ok: true, status: 200,
+    json: () => new Promise((_resolve, reject) => {
+      opts.signal.addEventListener('abort', () => { const e = new Error('aborted'); e.name = 'AbortError'; reject(e); });
+    }),
+  });
+  try {
+    assert.equal(wa.liveMode, true, 'live mode for this test');
+    await assert.rejects(
+      () => wa.sendText('50688887777', 'hola'),
+      (err) => { assert.equal(err.code, 'PROVIDER_TIMEOUT'); return true; },
+    );
+  } finally {
+    global.fetch = realFetch;
+    unloadLiveWa();
+    delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+  }
+});
+
+test('wa sendText: a Graph HTTP error fails closed (throws) before any DB persistence', async () => {
+  const wa = loadLiveWa();
+  let dbTouched = false;
+  // If _send throws on !res.ok, contactByWaId/logMessage (pool.query) are never reached.
+  const db = require('../lib/db');
+  const origQuery = db.pool.query;
+  db.pool.query = async () => { dbTouched = true; return { rows: [{ id: 1 }] }; };
+  global.fetch = () => Promise.resolve({ ok: false, status: 401, json: async () => ({ error: { message: 'invalid token' } }) });
+  try {
+    await assert.rejects(
+      () => wa.sendText('50688887777', 'hola'),
+      (err) => { assert.match(err.message, /wa send HTTP 401/); return true; },
+    );
+    assert.equal(dbTouched, false, 'a failed send never reaches persistence');
+  } finally {
+    db.pool.query = origQuery;
+    global.fetch = realFetch;
+    unloadLiveWa();
+  }
+});
+
+test('wa sendText: sandbox (no creds) simulates the send, no fetch', async () => {
+  delete require.cache[WA];
+  const wa = require(WA); // no token -> liveMode false
+  const db = require('../lib/db');
+  const origQuery = db.pool.query;
+  db.pool.query = async () => ({ rows: [{ id: 1 }] }); // contactByWaId + logMessage
+  let fetched = false;
+  global.fetch = () => { fetched = true; return Promise.reject(new Error('should not fetch in sandbox')); };
+  try {
+    const r = await wa.sendText('50688887777', 'hola');
+    assert.equal(wa.liveMode, false);
+    assert.match(r.messages[0].id, /^wamid\.SBX_/);
+    assert.equal(fetched, false, 'sandbox never hits the Graph API');
+  } finally {
+    db.pool.query = origQuery;
+    global.fetch = realFetch;
+    delete require.cache[WA];
+  }
+});

← 11478ab cycle 12 docs: YOLO_NOTES ledger — webhook idempotency-relea  ·  back to Costa Rica  ·  costa-rica: bound Apple JWKS fetch with fetchT (Cody-cleared 1d3aefd →