← 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
M lib/whatsapp.jsA test/whatsapp-timeout.test.js
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 →