[object Object]

← back to Costa Rica

costa-rica: bound live provider fetch with a timeout (PRE-FLIGHT #4/#5) — TK-10346

a3d5084d74e7eae78b109c5818d4c32241e30b81 · 2026-09-23 16:15:25 -0700 · Steve

Add lib/payments/http.js fetchT(): AbortController timeout (env
PROVIDER_HTTP_TIMEOUT_MS, default 15s) so a hung provider TCP rejects
fail-closed instead of stranding a payment/payout in 'processing' forever.
Wired into every live fetch in tilopay.js + onvo.js. 4 new tests (117/117).

Cody gate: kept the wrapper (sound; fully covers getCharge/refund/payout,
better-than-hang for createCharge) and CORRECTED the diff's safety comment,
which falsely claimed a createCharge timeout is reconcilable — it is not,
because routes/app.js writes the payments row (provider_ref) only AFTER
createCharge resolves. Cody #1 (createCharge reconcilability + idempotency)
and #3 (bound the body read too) tracked as PRE-FLIGHT #6/#7 in YOLO_NOTES.

Reversible, sandbox-inert (no live path exercised without creds).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic

Files touched

Diff

commit a3d5084d74e7eae78b109c5818d4c32241e30b81
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Sep 23 16:15:25 2026 -0700

    costa-rica: bound live provider fetch with a timeout (PRE-FLIGHT #4/#5) — TK-10346
    
    Add lib/payments/http.js fetchT(): AbortController timeout (env
    PROVIDER_HTTP_TIMEOUT_MS, default 15s) so a hung provider TCP rejects
    fail-closed instead of stranding a payment/payout in 'processing' forever.
    Wired into every live fetch in tilopay.js + onvo.js. 4 new tests (117/117).
    
    Cody gate: kept the wrapper (sound; fully covers getCharge/refund/payout,
    better-than-hang for createCharge) and CORRECTED the diff's safety comment,
    which falsely claimed a createCharge timeout is reconcilable — it is not,
    because routes/app.js writes the payments row (provider_ref) only AFTER
    createCharge resolves. Cody #1 (createCharge reconcilability + idempotency)
    and #3 (bound the body read too) tracked as PRE-FLIGHT #6/#7 in YOLO_NOTES.
    
    Reversible, sandbox-inert (no live path exercised without creds).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
---
 YOLO_NOTES.md                 | 22 ++++++++++++
 lib/payments/http.js          | 63 ++++++++++++++++++++++++++++++++
 lib/payments/onvo.js          |  7 ++--
 lib/payments/tilopay.js       | 11 +++---
 test/payments-timeout.test.js | 84 +++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 179 insertions(+), 8 deletions(-)

diff --git a/YOLO_NOTES.md b/YOLO_NOTES.md
index b378907..b175c9f 100644
--- a/YOLO_NOTES.md
+++ b/YOLO_NOTES.md
@@ -114,3 +114,25 @@ Canonical apply order now: `004_marketplace → 005_apple → 006_contacts → 0
 **FINAL DTD:** UNANIMOUS SHIP. Suite 113/113. Inert in sandbox (safe for current prod). NOT yet deployed (prod deploy gated — rides the go-live deploy when keys are added).
 **Cost:** ~$0.12.
 **Backlog:** safe local work nearly exhausted. Remaining: docs/GO-LIVE.md runbook (minor), then only Steve-credential-gated items (Tilopay/Meta/Plaid/ASC) + gated prod deploy of the CYCLE-1/2 hardening.
+
+## /yoloforever CYCLE 4 (2026-09-23, yf-costa) — DTD-scoped, Cody-gated
+
+**Decision:** close GO-LIVE PRE-FLIGHT #4/#5 (live provider `fetch` had NO timeout → a hung TCP strands a payment/payout in `processing` forever, host never paid). Provider-AGNOSTIC, so it does NOT need the Tilopay/ONVO creds Steve is KYC-blocked on.
+
+**Landed (local/reversible, sandbox-inert):**
+- `lib/payments/http.js` — `fetchT()` AbortController timeout wrapper (env `PROVIDER_HTTP_TIMEOUT_MS`, default 15s, clamped [1,120000], bad→default; on abort throws labelled `Error` code `PROVIDER_TIMEOUT`; timer cleared in `finally`).
+- Wired into **every live fetch** in `tilopay.js` (token/createCharge/getCharge/refund/payout) + `onvo.js` (createCharge/getCharge/refund).
+- `test/payments-timeout.test.js` — 4 tests (hung-fetch→reject, fast-passthrough, non-abort error re-thrown as-is, `timeoutMs()` clamping). Full suite **117/117** (was 113).
+
+**Cody gate — 3 REAL findings, all VERIFIED against the code (not phantoms):**
+- **#1 [CRITICAL] createCharge timeout is NOT reconcilable.** `routes/app.js:252` calls `createCharge()`; `:258` returns 502 on error; the `payments` row (holding `provider_ref`) is INSERTed only at `:261` AFTER it resolves. So a timeout returns 502 before any row exists → the webhook `UPDATE payments … WHERE provider_ref=$4` (`webhooks.js:47-48`) matches nothing → `confirmBooking` never fires → booking stuck `pending`, traveler possibly charged, host never paid, no DB trace. The diff's original comment claiming "reconciliation rides the webhook + poll" was FALSE for createCharge — **comment corrected this cycle** to state the truth + point at the real fix.
+- **#2 [HIGH] No provider-honored idempotency key.** Tilopay `orderNumber`/ONVO `description` = booking.code are metadata, not dedupe keys. A fast clean 502 (now that we fail fast) invites a mobile-client retry → double-charge risk, since no row blocks the retry.
+- **#3 [MEDIUM] Wrapper bounds connect+headers only, not the body.** `res.json()` after `fetchT` returns is unbounded — a header-fast/body-stalled provider reproduces the hang a few lines later. **Comment now scopes this honestly.**
+
+**FINAL DTD:** SHIP the honest partial (timeout wrapper + truthful comment); it is a strict improvement (getCharge/refund/payout fully covered; createCharge better-than-hang) with ZERO false claims. Cody #1/#2/#3 are the REAL go-live correctness items → tracked below; #1/#2 gated to Steve (money-path design + provider-specific idempotency).
+
+**NEW GO-LIVE PRE-FLIGHT (from Cody C4, ranked):**
+6. **[CRITICAL] Make createCharge timeout reconcilable.** In `routes/app.js`, INSERT the `payments` row `status='processing'` with a locally-generated idempotency token BEFORE `provider.createCharge()`; on retry, detect an existing in-flight payment for the booking and refuse/reuse instead of firing a 2nd real charge; pass a provider-HONORED idempotency key to the provider (Stripe-like ONVO → `Idempotency-Key` header; Tilopay → confirm the real mechanism from the live account). The provider-honored half is LIVE-ONLY (needs the real account); the pre-charge-row + local dedupe half is provider-agnostic and can land first.
+7. **[MEDIUM] Bound the response BODY read, not just headers.** Give `res.json()` its own deadline (or an `AbortSignal.timeout(ms)` that spans connect→body) so a header-fast/body-stalled provider can't hang the money path.
+
+**Cost:** $0 (local PG/tests; Cody = Anthropic subagent tokens only; no paid API).
diff --git a/lib/payments/http.js b/lib/payments/http.js
new file mode 100644
index 0000000..9f7926d
--- /dev/null
+++ b/lib/payments/http.js
@@ -0,0 +1,63 @@
+'use strict';
+// Shared timeout wrapper for LIVE payment-provider HTTP calls (Tilopay/ONVO).
+//
+// WHY: every live provider fetch (login/token, createCharge, getCharge, refund,
+// payout) had no timeout. A hung TCP connection to the provider would leave the
+// fetch pending forever, stranding a payment/payout row in 'processing' — the
+// host is never paid and the booking never resolves, with no error surfaced.
+// (GO-LIVE PRE-FLIGHT items #4/#5, TK-10346.)
+//
+// FAIL-CLOSED: on timeout we ABORT and REJECT with a labelled error rather than
+// hanging. A reject is strictly safer than an infinite hang, but what it BUYS
+// differs per call site (verified against routes/app.js + routes/webhooks.js):
+//   - getCharge / refund / payout act on an ALREADY-PERSISTED provider_ref, so a
+//     timeout just fails the current attempt and is safely retryable (payouts.js
+//     already try/catches -> marks the payout 'failed' and re-throws).
+//   - createCharge is NOT yet reconcilable on timeout. routes/app.js INSERTs the
+//     payments row (which holds provider_ref) only AFTER createCharge resolves,
+//     so a timeout returns 502 before any row exists -> the provider webhook
+//     (UPDATE ... WHERE provider_ref=...) matches nothing and the booking stays
+//     'pending' with no trace. Bounding the hang is still better than an infinite
+//     one, but the REAL fix is to write a 'processing' payments row + a
+//     provider-honored idempotency key BEFORE the charge. Tracked as GO-LIVE
+//     PRE-FLIGHT #6; see YOLO_NOTES.md. (Do NOT trust this wrapper alone to make
+//     a timed-out charge recoverable — it does not.)
+//
+// SCOPE (PRE-FLIGHT #7): this bounds the CONNECT + HEADERS phase only. The timer
+// is cleared once fetch() resolves, so a subsequent stalled `res.json()` body
+// read is NOT bounded. A body-phase deadline is the follow-up.
+//
+// Provider-AGNOSTIC: this makes no assumption about either provider's payload,
+// status vocabulary, or signature encoding — it only bounds how long we wait.
+
+// Read per-call so a test (or ops) can override PROVIDER_HTTP_TIMEOUT_MS without
+// re-requiring the module. Clamp to a sane floor/ceiling; bad values -> default.
+function timeoutMs() {
+  const n = Number(process.env.PROVIDER_HTTP_TIMEOUT_MS);
+  if (!Number.isFinite(n) || n <= 0) return 15000;
+  return Math.min(Math.max(n, 1), 120000);
+}
+
+// Drop-in for fetch() that aborts after timeoutMs(). Rejects with a clear
+// 'provider fetch timeout' Error on timeout; otherwise transparently returns
+// the Response (or re-throws the original network error).
+async function fetchT(url, opts = {}) {
+  const ms = timeoutMs();
+  const ctl = new AbortController();
+  const timer = setTimeout(() => ctl.abort(), ms);
+  // Do not swallow a signal the caller already passed; ours is the only one here.
+  try {
+    return await fetch(url, { ...opts, signal: ctl.signal });
+  } catch (e) {
+    if (e && (e.name === 'AbortError' || ctl.signal.aborted)) {
+      const err = new Error(`provider fetch timeout after ${ms}ms: ${url}`);
+      err.code = 'PROVIDER_TIMEOUT';
+      throw err;
+    }
+    throw e;
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+module.exports = { fetchT, timeoutMs };
diff --git a/lib/payments/onvo.js b/lib/payments/onvo.js
index 73beb93..c15b8aa 100644
--- a/lib/payments/onvo.js
+++ b/lib/payments/onvo.js
@@ -5,6 +5,7 @@
 // Full method bodies mirror Tilopay; live wiring pinned in the go-live memo.
 
 const crypto = require('crypto');
+const { fetchT } = require('./http'); // live fetches get a timeout (no infinite hang)
 const BASE = process.env.ONVO_BASE || 'https://api.onvopay.com/v1';
 const SECRET = process.env.ONVO_SECRET_KEY || '';
 const WEBHOOK_SECRET = process.env.ONVO_WEBHOOK_SECRET || '';
@@ -19,7 +20,7 @@ async function createCharge({ amount, currency, method = 'card', booking, return
         ? { type: 'sinpe_instructions', sinpe_phone: '8888-0000', note: `SANDBOX ${booking?.code}` }
         : { type: 'redirect', url: `${returnUrl}?ref=${ref}&sandbox=1&result=success` } };
   }
-  const res = await fetch(`${BASE}/payment-intents`, {
+  const res = await fetchT(`${BASE}/payment-intents`, {
     method: 'POST', headers: { Authorization: `Bearer ${SECRET}`, 'Content-Type': 'application/json' },
     body: JSON.stringify({ amount, currency, description: booking?.code, redirectUrl: returnUrl }),
   });
@@ -38,13 +39,13 @@ function mapStatus(s) { return STATUS_MAP[s] || 'processing'; }
 
 async function getCharge(ref) {
   if (!LIVE) return { status: /_sbx_|success/.test(String(ref)) ? 'succeeded' : 'processing', raw: { sandbox: true } };
-  const res = await fetch(`${BASE}/payment-intents/${ref}`, { headers: { Authorization: `Bearer ${SECRET}` } });
+  const res = await fetchT(`${BASE}/payment-intents/${ref}`, { headers: { Authorization: `Bearer ${SECRET}` } });
   const j = await res.json();
   return { status: mapStatus(j.status), raw: j };
 }
 async function refund(ref, amount) {
   if (!LIVE) return { status: 'refunded', raw: { sandbox: true } };
-  const res = await fetch(`${BASE}/refunds`, { method: 'POST',
+  const res = await fetchT(`${BASE}/refunds`, { method: 'POST',
     headers: { Authorization: `Bearer ${SECRET}`, 'Content-Type': 'application/json' },
     body: JSON.stringify({ paymentIntentId: ref, amount }) });
   return { status: res.ok ? 'refunded' : 'failed', raw: await res.json().catch(() => ({})) };
diff --git a/lib/payments/tilopay.js b/lib/payments/tilopay.js
index 4b70954..f6e6c5f 100644
--- a/lib/payments/tilopay.js
+++ b/lib/payments/tilopay.js
@@ -14,6 +14,7 @@
 // structured so wiring real creds is a config change, not a code rewrite.
 
 const crypto = require('crypto');
+const { fetchT } = require('./http'); // live fetches get a timeout (no infinite hang)
 
 const BASE = process.env.TILOPAY_BASE || 'https://app.tilopay.com/api/v1';
 const API_USER = process.env.TILOPAY_API_USER || '';
@@ -26,7 +27,7 @@ let _token = null, _tokenExp = 0;
 async function token() {
   if (!LIVE) return 'sandbox';
   if (_token && Date.now() < _tokenExp) return _token;
-  const res = await fetch(`${BASE}/login`, {
+  const res = await fetchT(`${BASE}/login`, {
     method: 'POST', headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({ apiuser: API_USER, password: API_PASS }),
   });
@@ -54,7 +55,7 @@ async function createCharge({ amount, currency, method = 'card', booking, custom
       clientAction: { type: 'redirect', url: `${returnUrl}?ref=${ref}&sandbox=1&result=success` } };
   }
   const t = await token();
-  const res = await fetch(`${BASE}/processPayment`, {
+  const res = await fetchT(`${BASE}/processPayment`, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}`, 'X-Api-Key': API_KEY },
     body: JSON.stringify({
@@ -75,7 +76,7 @@ async function getCharge(providerRef) {
     return { status: succeeded ? 'succeeded' : 'processing', raw: { sandbox: true } };
   }
   const t = await token();
-  const res = await fetch(`${BASE}/payment/${providerRef}`, { headers: { Authorization: `Bearer ${t}`, 'X-Api-Key': API_KEY } });
+  const res = await fetchT(`${BASE}/payment/${providerRef}`, { headers: { Authorization: `Bearer ${t}`, 'X-Api-Key': API_KEY } });
   const j = await res.json();
   const map = { success: 'succeeded', pending: 'processing', declined: 'failed', reversed: 'refunded' };
   return { status: map[j.status] || 'processing', raw: j };
@@ -84,7 +85,7 @@ async function getCharge(providerRef) {
 async function refund(providerRef, amount) {
   if (!LIVE) return { status: 'refunded', raw: { sandbox: true } };
   const t = await token();
-  const res = await fetch(`${BASE}/refund`, {
+  const res = await fetchT(`${BASE}/refund`, {
     method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}`, 'X-Api-Key': API_KEY },
     body: JSON.stringify({ paymentId: providerRef, amount: amount != null ? (amount / 100).toFixed(2) : undefined }),
   });
@@ -95,7 +96,7 @@ async function refund(providerRef, amount) {
 async function payout({ method, amount, currency = 'CRC', reference }) {
   if (!LIVE) return { providerRef: fakeRef('pyt'), status: 'processing', raw: { sandbox: true } };
   const t = await token();
-  const res = await fetch(`${BASE}/sinpe/transfer`, {
+  const res = await fetchT(`${BASE}/sinpe/transfer`, {
     method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}`, 'X-Api-Key': API_KEY },
     body: JSON.stringify({ phone: method.sinpe_phone, amount: (amount / 100).toFixed(2), currency, description: reference }),
   });
diff --git a/test/payments-timeout.test.js b/test/payments-timeout.test.js
new file mode 100644
index 0000000..3758174
--- /dev/null
+++ b/test/payments-timeout.test.js
@@ -0,0 +1,84 @@
+'use strict';
+// Timeout wrapper for LIVE provider HTTP (lib/payments/http.js). Proves a hung
+// provider connection REJECTS (fail-closed) instead of hanging forever, and
+// that a normal response passes through with the timer cleared. Zero network,
+// zero live creds: global.fetch is faked per test (node runs this file in its
+// own process, so the fake does not leak into other test files).
+// GO-LIVE PRE-FLIGHT #4/#5, TK-10346.
+
+const { test } = require('node:test');
+const assert = require('node:assert');
+
+const { fetchT, timeoutMs } = require('../lib/payments/http');
+
+const realFetch = global.fetch;
+function restore() { global.fetch = realFetch; }
+
+test('fetchT: a hung fetch that honours abort -> rejects with a timeout error (does not hang)', async () => {
+  process.env.PROVIDER_HTTP_TIMEOUT_MS = '30';
+  // Simulate a hung TCP: never resolves on its own, only settles when aborted.
+  global.fetch = (url, opts) => new Promise((_resolve, reject) => {
+    opts.signal.addEventListener('abort', () => {
+      const e = new Error('The operation was aborted'); e.name = 'AbortError'; reject(e);
+    });
+  });
+  try {
+    const started = Date.now();
+    await assert.rejects(
+      () => fetchT('https://provider.test/hang'),
+      (err) => {
+        assert.equal(err.code, 'PROVIDER_TIMEOUT');
+        assert.match(err.message, /timeout after 30ms/);
+        assert.match(err.message, /provider\.test\/hang/);
+        return true;
+      },
+    );
+    // Sanity: it resolved via the timeout, not by waiting on the (never-ending) fetch.
+    assert.ok(Date.now() - started < 2000, 'rejected promptly, not hung');
+  } finally {
+    restore();
+    delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+  }
+});
+
+test('fetchT: a fast response passes straight through, unmodified', async () => {
+  process.env.PROVIDER_HTTP_TIMEOUT_MS = '5000';
+  const sentinel = { ok: true, marker: 'passthrough' };
+  let sawSignal = false;
+  global.fetch = (url, opts) => { sawSignal = !!(opts && opts.signal); return Promise.resolve(sentinel); };
+  try {
+    const res = await fetchT('https://provider.test/ok', { method: 'POST' });
+    assert.strictEqual(res, sentinel, 'returns the real Response object unchanged');
+    assert.ok(sawSignal, 'passes an abort signal down to fetch');
+  } finally {
+    restore();
+    delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+  }
+});
+
+test('fetchT: a non-abort network error is re-thrown as-is (not masked as a timeout)', async () => {
+  process.env.PROVIDER_HTTP_TIMEOUT_MS = '5000';
+  global.fetch = () => Promise.reject(new Error('ECONNREFUSED'));
+  try {
+    await assert.rejects(
+      () => fetchT('https://provider.test/down'),
+      (err) => { assert.match(err.message, /ECONNREFUSED/); assert.notEqual(err.code, 'PROVIDER_TIMEOUT'); return true; },
+    );
+  } finally {
+    restore();
+    delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+  }
+});
+
+test('timeoutMs: bad/zero/absent values fall back to the 15s default; sane values pass; clamped to ceiling', () => {
+  const cases = [
+    [undefined, 15000], ['', 15000], ['0', 15000], ['-5', 15000], ['abc', 15000],
+    ['3000', 3000], ['999999', 120000],
+  ];
+  for (const [val, expect] of cases) {
+    if (val === undefined) delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+    else process.env.PROVIDER_HTTP_TIMEOUT_MS = val;
+    assert.equal(timeoutMs(), expect, `timeoutMs for ${JSON.stringify(val)}`);
+  }
+  delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+});

← 26bd9e3 app API: expose region_image_url so listings can fall back (  ·  back to Costa Rica  ·  costa-rica: implement payment reorder (PRE-FLIGHT #6) — pre- 62a3add →