← back to Costa Rica
costa-rica: bound provider body-read + fix fail-OPEN on refund/payout timeout (PRE-FLIGHT #7) — TK-10346
a00cd464d8d7b65794412198e985bfd6843b2d83 · 2026-09-23 18:27:26 -0700 · Steve
GO-LIVE PRE-FLIGHT #7: the fetchT() deadline now spans the WHOLE request lifecycle
(connect + headers AND the response body read), not just connect+headers. fetch()
resolves once headers are in; the body (res.json()/res.text()) is read later in the
caller, so previously a header-fast / body-stalled provider could still hang the
money path inside res.json(). Now one AbortController stays armed across the body
read; the returned object delegates to the real Response, with json()/text() bounded
by the same deadline and relabelled PROVIDER_TIMEOUT on abort. timer.unref() so a
metadata-only caller (token() throws on !res.ok, never reads the body) can't leave
an active timer.
Cody gate — CRITICAL fail-OPEN found + fixed: once the body read is bounded,
res.json() throws PROVIDER_TIMEOUT on a stall. But refund()/payout() used
`res.json().catch(() => ({}))`, which SWALLOWED that timeout — and since res.ok was
already true (fast headers), they returned a FABRICATED success:
- tilopay payout(): {providerRef: undefined, status: 'processing', raw: {}} ->
payouts.js writes a stuck 'processing' payout row with a NULL provider_ref that
can never reconcile, and its catch/mark-failed never fires (nothing threw).
- tilopay/onvo refund(): {status: 'refunded', raw: {}} on a refund that never confirmed.
Fix: fail closed on a PROVIDER_TIMEOUT specifically — payout() THROWS (so payouts.js
marks the row 'failed' and surfaces it); refund() returns status:'failed'. A merely
empty/malformed but fully-received 200 body is still tolerated (raw:{}), since refund
success is HTTP-status-driven — so the fix does not over-correct legitimate responses.
Tests (+4, suite 122 -> 126):
- payments-timeout.test.js: body-stall -> json() rejects PROVIDER_TIMEOUT; fast body
clears the deadline; metadata+body read through the Proxy off a REAL undici Response
(guards Reflect.get(target,prop,target) vs a receiver refactor the plain-object
mocks would miss).
- payments-body-timeout-failclosed.test.js: tilopay payout() throws + refund() ->
'failed' on a body-read timeout; an empty/malformed fully-received body stays
tolerated (not over-corrected).
Provider-honored idempotency key (double-payout-on-retry guard) stays the live-only
follow-up; provider-agnostic hardening for #4/#5/#6/#7 now complete.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
Files touched
M lib/payments/http.jsM lib/payments/onvo.jsM lib/payments/tilopay.jsA test/payments-body-timeout-failclosed.test.jsM test/payments-timeout.test.js
Diff
commit a00cd464d8d7b65794412198e985bfd6843b2d83
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Sep 23 18:27:26 2026 -0700
costa-rica: bound provider body-read + fix fail-OPEN on refund/payout timeout (PRE-FLIGHT #7) — TK-10346
GO-LIVE PRE-FLIGHT #7: the fetchT() deadline now spans the WHOLE request lifecycle
(connect + headers AND the response body read), not just connect+headers. fetch()
resolves once headers are in; the body (res.json()/res.text()) is read later in the
caller, so previously a header-fast / body-stalled provider could still hang the
money path inside res.json(). Now one AbortController stays armed across the body
read; the returned object delegates to the real Response, with json()/text() bounded
by the same deadline and relabelled PROVIDER_TIMEOUT on abort. timer.unref() so a
metadata-only caller (token() throws on !res.ok, never reads the body) can't leave
an active timer.
Cody gate — CRITICAL fail-OPEN found + fixed: once the body read is bounded,
res.json() throws PROVIDER_TIMEOUT on a stall. But refund()/payout() used
`res.json().catch(() => ({}))`, which SWALLOWED that timeout — and since res.ok was
already true (fast headers), they returned a FABRICATED success:
- tilopay payout(): {providerRef: undefined, status: 'processing', raw: {}} ->
payouts.js writes a stuck 'processing' payout row with a NULL provider_ref that
can never reconcile, and its catch/mark-failed never fires (nothing threw).
- tilopay/onvo refund(): {status: 'refunded', raw: {}} on a refund that never confirmed.
Fix: fail closed on a PROVIDER_TIMEOUT specifically — payout() THROWS (so payouts.js
marks the row 'failed' and surfaces it); refund() returns status:'failed'. A merely
empty/malformed but fully-received 200 body is still tolerated (raw:{}), since refund
success is HTTP-status-driven — so the fix does not over-correct legitimate responses.
Tests (+4, suite 122 -> 126):
- payments-timeout.test.js: body-stall -> json() rejects PROVIDER_TIMEOUT; fast body
clears the deadline; metadata+body read through the Proxy off a REAL undici Response
(guards Reflect.get(target,prop,target) vs a receiver refactor the plain-object
mocks would miss).
- payments-body-timeout-failclosed.test.js: tilopay payout() throws + refund() ->
'failed' on a body-read timeout; an empty/malformed fully-received body stays
tolerated (not over-corrected).
Provider-honored idempotency key (double-payout-on-retry guard) stays the live-only
follow-up; provider-agnostic hardening for #4/#5/#6/#7 now complete.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
---
lib/payments/http.js | 90 ++++++++++++++------
lib/payments/onvo.js | 12 ++-
lib/payments/tilopay.js | 28 ++++++-
test/payments-body-timeout-failclosed.test.js | 113 ++++++++++++++++++++++++++
test/payments-timeout.test.js | 79 +++++++++++++++++-
5 files changed, 292 insertions(+), 30 deletions(-)
diff --git a/lib/payments/http.js b/lib/payments/http.js
index 9f7926d..b55751a 100644
--- a/lib/payments/http.js
+++ b/lib/payments/http.js
@@ -13,19 +13,22 @@
// - 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.)
+// - createCharge IS now reconcilable on timeout (PRE-FLIGHT #6, cycle 5):
+// routes/app.js writes the 'processing' payments row BEFORE createCharge and
+// marks it 'failed' on error, and a retry reuses an in-flight payment instead
+// of double-charging. The provider-HONORED idempotency key stays a live-only
+// follow-up (needs the real Tilopay/ONVO account); see YOLO_NOTES.md.
//
-// 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.
+// SCOPE (PRE-FLIGHT #7, cycle 6 — now CLOSED): the deadline spans the WHOLE
+// request lifecycle — connect + headers AND the response body read. fetch() only
+// resolves once headers are in; the body (`res.json()`/`res.text()`) is read
+// later, in the caller. If we cleared the timer the moment fetch() resolved, a
+// header-fast / body-stalled provider could still hang the money path inside
+// res.json(). So we keep the SAME AbortController armed across the body read and
+// clear it only when the body is fully consumed (success OR error). The returned
+// object delegates to the real Response; its json()/text() run under the armed
+// signal and relabel an abort as PROVIDER_TIMEOUT. Everything else (ok, status,
+// headers, …) reads straight off the underlying Response, unchanged.
//
// Provider-AGNOSTIC: this makes no assumption about either provider's payload,
// status vocabulary, or signature encoding — it only bounds how long we wait.
@@ -38,26 +41,65 @@ function timeoutMs() {
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).
+// Turn an abort (from either the connect/headers phase or the body-read phase)
+// into a clear, labelled error. Any non-abort error is passed through untouched.
+function asTimeout(e, ms, url, phase, ctl) {
+ if (e && (e.name === 'AbortError' || (ctl && ctl.signal.aborted))) {
+ const err = new Error(`provider ${phase} timeout after ${ms}ms: ${url}`);
+ err.code = 'PROVIDER_TIMEOUT';
+ return err;
+ }
+ return e;
+}
+
+// Drop-in for fetch() that aborts after timeoutMs() — across the ENTIRE request,
+// headers AND body. Rejects with a labelled 'provider ... timeout' Error
+// (code PROVIDER_TIMEOUT) on timeout; otherwise returns a Response-like object
+// whose json()/text() are also bounded by the same deadline.
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.
+ // A caller that reads only metadata (e.g. tilopay.js token() throws on !res.ok
+ // and never reads the body) would otherwise leave this timer active until it
+ // self-fires; unref so it can never keep the event loop / process alive. It
+ // still fires normally while a body read is in flight (the socket keeps the
+ // loop alive), so the body-phase deadline below is unaffected.
+ if (typeof timer.unref === 'function') timer.unref();
+
+ // Phase 1: connect + headers. Do NOT clear the timer on success — the deadline
+ // must keep running through the body read below.
+ let res;
try {
- return await fetch(url, { ...opts, signal: ctl.signal });
+ // Ours is the only signal here (call sites don't pass their own).
+ res = 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);
+ throw asTimeout(e, ms, url, 'fetch', ctl);
}
+
+ // Phase 2: bound the body read with the SAME still-armed deadline. Wrap only the
+ // body-consuming methods; clear the timer once the body settles (success/error).
+ let cleared = false;
+ const done = () => { if (!cleared) { cleared = true; clearTimeout(timer); } };
+ const wrapBody = (fnName) => async (...args) => {
+ try {
+ return await res[fnName](...args);
+ } catch (e) {
+ throw asTimeout(e, ms, url, 'body read', ctl);
+ } finally {
+ done();
+ }
+ };
+
+ // Delegate everything to the real Response; only json()/text() are wrapped.
+ return new Proxy(res, {
+ get(target, prop, receiver) {
+ if (prop === 'json' || prop === 'text') return wrapBody(prop);
+ const v = Reflect.get(target, prop, target);
+ return typeof v === 'function' ? v.bind(target) : v;
+ },
+ });
}
module.exports = { fetchT, timeoutMs };
diff --git a/lib/payments/onvo.js b/lib/payments/onvo.js
index c15b8aa..8fb9fc2 100644
--- a/lib/payments/onvo.js
+++ b/lib/payments/onvo.js
@@ -48,7 +48,17 @@ async function refund(ref, amount) {
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(() => ({})) };
+ // FAIL-CLOSED on a body-read timeout (see tilopay.js refund): a stalled body must
+ // not be swallowed into raw:{} and read as 'refunded'. A merely empty/malformed
+ // (fully-received) 200 body is still tolerated — refund success is HTTP-driven.
+ let raw;
+ try {
+ raw = await res.json();
+ } catch (e) {
+ if (e && e.code === 'PROVIDER_TIMEOUT') return { status: 'failed', raw: { error: e.message } };
+ raw = {};
+ }
+ return { status: res.ok ? 'refunded' : 'failed', raw };
}
async function payout({ method, amount, currency = 'CRC', reference }) {
if (!LIVE) return { providerRef: fakeRef('pyt'), status: 'processing', raw: { sandbox: true } };
diff --git a/lib/payments/tilopay.js b/lib/payments/tilopay.js
index f6e6c5f..2b3c51c 100644
--- a/lib/payments/tilopay.js
+++ b/lib/payments/tilopay.js
@@ -89,7 +89,19 @@ async function refund(providerRef, amount) {
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 }),
});
- return { status: res.ok ? 'refunded' : 'failed', raw: await res.json().catch(() => ({})) };
+ // FAIL-CLOSED on a body-read timeout: res.ok comes from the (fast) headers, but a
+ // stalled body means we never confirmed the refund. Do NOT let a PROVIDER_TIMEOUT
+ // get swallowed into raw:{} and read as 'refunded' — mark it failed so it isn't
+ // treated as a completed refund. A merely empty/malformed (but fully-received)
+ // 200 body is still tolerated, since refund success is HTTP-status-driven.
+ let raw;
+ try {
+ raw = await res.json();
+ } catch (e) {
+ if (e && e.code === 'PROVIDER_TIMEOUT') return { status: 'failed', raw: { error: e.message } };
+ raw = {};
+ }
+ return { status: res.ok ? 'refunded' : 'failed', raw };
}
// SINPE Movil payout to a host.
@@ -100,7 +112,19 @@ async function payout({ method, amount, currency = 'CRC', reference }) {
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 }),
});
- const j = await res.json().catch(() => ({}));
+ // FAIL-CLOSED on a body-read timeout: THROW so payouts.js's catch marks the
+ // payout row 'failed' and surfaces it. Swallowing a PROVIDER_TIMEOUT into raw:{}
+ // here returns {providerRef: undefined, status:'processing'} (res.ok is true from
+ // the fast headers) -> payouts.js writes a stuck 'processing' row with a NULL
+ // provider_ref that can NEVER reconcile, silently, on exactly the header-fast/
+ // body-stalled failure this timeout exists to catch. (Cody gate, cycle 6.)
+ let j;
+ try {
+ j = await res.json();
+ } catch (e) {
+ if (e && e.code === 'PROVIDER_TIMEOUT') throw e;
+ j = {}; // tolerate an empty/malformed but fully-received body
+ }
return { providerRef: j.id, status: res.ok ? 'processing' : 'failed', raw: j };
}
diff --git a/test/payments-body-timeout-failclosed.test.js b/test/payments-body-timeout-failclosed.test.js
new file mode 100644
index 0000000..51dffde
--- /dev/null
+++ b/test/payments-body-timeout-failclosed.test.js
@@ -0,0 +1,113 @@
+'use strict';
+// PRE-FLIGHT #7 fail-closed regression (Cody gate, cycle 6, TK-10346).
+//
+// The money-path bug: once fetchT bounds the BODY read, a header-fast /
+// body-stalled provider makes res.json() throw PROVIDER_TIMEOUT. The adapters'
+// refund()/payout() used `res.json().catch(() => ({}))`, which SWALLOWED that
+// timeout — and because res.ok was already true (from the fast headers), they
+// returned a FABRICATED success:
+// - payout(): {providerRef: undefined, status: 'processing', raw: {}} -> payouts.js
+// writes a stuck 'processing' payout row with a NULL provider_ref that can never
+// reconcile, and its catch/mark-failed logic never fires (no throw ever escaped).
+// - refund(): {status: 'refunded', raw: {}} -> a refund that never confirmed reads
+// as completed.
+// These tests prove the fail-CLOSED fix: payout() THROWS, refund() -> 'failed'.
+//
+// Forces LIVE mode via env + require-cache reset (the adapters read creds at load
+// time); global.fetch is faked, so there is zero network and zero real money.
+
+const { test } = require('node:test');
+const assert = require('node:assert');
+
+const TILOPAY = require.resolve('../lib/payments/tilopay');
+
+function loadLiveTilopay() {
+ process.env.TILOPAY_API_USER = 'u';
+ process.env.TILOPAY_API_PASSWORD = 'p';
+ process.env.TILOPAY_API_KEY = 'k';
+ delete require.cache[TILOPAY];
+ return require(TILOPAY);
+}
+function unloadLiveTilopay() {
+ delete process.env.TILOPAY_API_USER;
+ delete process.env.TILOPAY_API_PASSWORD;
+ delete process.env.TILOPAY_API_KEY;
+ delete require.cache[TILOPAY];
+}
+
+const realFetch = global.fetch;
+
+// Auth (/login) responds fast; the target endpoint's HEADERS arrive immediately but
+// its BODY read hangs until the shared abort signal fires — reproducing the exact
+// header-fast / body-stalled failure this PRE-FLIGHT exists to catch.
+function fakeFetchStallBody() {
+ return (url, opts) => {
+ if (String(url).includes('/login')) {
+ return Promise.resolve({ ok: true, status: 200, json: async () => ({ access_token: 'tok' }) });
+ }
+ return Promise.resolve({
+ ok: true,
+ status: 200,
+ json: () => new Promise((_resolve, reject) => {
+ opts.signal.addEventListener('abort', () => {
+ const e = new Error('The operation was aborted'); e.name = 'AbortError'; reject(e);
+ });
+ }),
+ });
+ };
+}
+
+test('tilopay payout(): a body-read timeout THROWS PROVIDER_TIMEOUT (no stuck processing row)', async () => {
+ process.env.PROVIDER_HTTP_TIMEOUT_MS = '40';
+ const tilopay = loadLiveTilopay();
+ global.fetch = fakeFetchStallBody();
+ try {
+ assert.equal(tilopay.liveMode, true, 'adapter is in LIVE mode for this test');
+ await assert.rejects(
+ () => tilopay.payout({ method: { sinpe_phone: '8888-0000' }, amount: 36000, currency: 'CRC', reference: 'CR-X' }),
+ (err) => {
+ assert.equal(err.code, 'PROVIDER_TIMEOUT', 'payout fails closed by throwing the timeout, not swallowing it');
+ return true;
+ },
+ );
+ } finally {
+ global.fetch = realFetch;
+ unloadLiveTilopay();
+ delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+ }
+});
+
+test('tilopay refund(): a body-read timeout returns status:failed (not a fabricated refunded)', async () => {
+ process.env.PROVIDER_HTTP_TIMEOUT_MS = '40';
+ const tilopay = loadLiveTilopay();
+ global.fetch = fakeFetchStallBody();
+ try {
+ const r = await tilopay.refund('pay_1', 12000);
+ assert.equal(r.status, 'failed', 'a stalled refund body must not read as refunded');
+ } finally {
+ global.fetch = realFetch;
+ unloadLiveTilopay();
+ delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+ }
+});
+
+test('tilopay payout(): an empty/malformed but fully-received 200 body is still tolerated (not a timeout)', async () => {
+ process.env.PROVIDER_HTTP_TIMEOUT_MS = '5000';
+ const tilopay = loadLiveTilopay();
+ // Body arrives, but is not valid JSON -> res.json() throws a SyntaxError (NOT a
+ // PROVIDER_TIMEOUT). This must be tolerated (raw:{}), not failed-closed — the
+ // fail-closed branch is strictly for the timeout code.
+ global.fetch = (url) => {
+ if (String(url).includes('/login')) return Promise.resolve({ ok: true, status: 200, json: async () => ({ access_token: 'tok' }) });
+ return Promise.resolve({ ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected end of JSON input'); } });
+ };
+ try {
+ const r = await tilopay.payout({ method: { sinpe_phone: '8888-0000' }, amount: 36000, currency: 'CRC', reference: 'CR-Y' });
+ assert.equal(r.status, 'processing', 'a fully-received 200 (empty body) stays processing — only a timeout fails closed');
+ assert.deepEqual(r.raw, {}, 'tolerated malformed body -> raw {}');
+ } finally {
+ global.fetch = realFetch;
+ unloadLiveTilopay();
+ delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+ }
+});
diff --git a/test/payments-timeout.test.js b/test/payments-timeout.test.js
index 3758174..1ed06ab 100644
--- a/test/payments-timeout.test.js
+++ b/test/payments-timeout.test.js
@@ -41,14 +41,19 @@ test('fetchT: a hung fetch that honours abort -> rejects with a timeout error (d
}
});
-test('fetchT: a fast response passes straight through, unmodified', async () => {
+test('fetchT: a fast response passes its metadata through (ok/status/headers readable off the wrapper)', async () => {
process.env.PROVIDER_HTTP_TIMEOUT_MS = '5000';
- const sentinel = { ok: true, marker: 'passthrough' };
+ // A Response-like with sync metadata + an async body. The wrapper delegates
+ // everything but json()/text() straight to this object.
+ const sentinel = { ok: true, status: 201, marker: 'passthrough', json: async () => ({ id: 'ch_1' }) };
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.equal(res.ok, true, 'res.ok reads through the wrapper');
+ assert.equal(res.status, 201, 'res.status reads through the wrapper');
+ assert.equal(res.marker, 'passthrough', 'arbitrary props read through the wrapper');
+ assert.deepEqual(await res.json(), { id: 'ch_1' }, 'body read returns the parsed JSON');
assert.ok(sawSignal, 'passes an abort signal down to fetch');
} finally {
restore();
@@ -56,6 +61,74 @@ test('fetchT: a fast response passes straight through, unmodified', async () =>
}
});
+test('fetchT: a header-fast / BODY-stalled response -> json() rejects with PROVIDER_TIMEOUT (PRE-FLIGHT #7)', async () => {
+ process.env.PROVIDER_HTTP_TIMEOUT_MS = '40';
+ // Headers arrive immediately; the body read hangs until the shared signal aborts.
+ global.fetch = (url, opts) => Promise.resolve({
+ ok: true,
+ status: 200,
+ json: () => new Promise((_resolve, reject) => {
+ opts.signal.addEventListener('abort', () => {
+ const e = new Error('The operation was aborted'); e.name = 'AbortError'; reject(e);
+ });
+ }),
+ });
+ try {
+ const res = await fetchT('https://provider.test/slowbody'); // resolves fast (headers only)
+ const started = Date.now();
+ await assert.rejects(
+ () => res.json(),
+ (err) => {
+ assert.equal(err.code, 'PROVIDER_TIMEOUT', 'body-read abort is labelled PROVIDER_TIMEOUT');
+ assert.match(err.message, /body read timeout after 40ms/);
+ assert.match(err.message, /provider\.test\/slowbody/);
+ return true;
+ },
+ );
+ assert.ok(Date.now() - started < 2000, 'body read aborted promptly, not hung');
+ } finally {
+ restore();
+ delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+ }
+});
+
+test('fetchT: a fast body read clears the deadline (a later slow op is NOT aborted by a stale timer)', async () => {
+ process.env.PROVIDER_HTTP_TIMEOUT_MS = '50';
+ let capturedSignal = null;
+ global.fetch = (url, opts) => { capturedSignal = opts.signal; return Promise.resolve({ ok: true, status: 200, json: async () => ({ done: true }) }); };
+ try {
+ const res = await fetchT('https://provider.test/fastbody');
+ assert.deepEqual(await res.json(), { done: true }, 'body parsed');
+ // The timer must have been cleared when json() resolved; wait past the old deadline
+ // and confirm the signal never fired (no stale abort strands a later operation).
+ await new Promise(r => setTimeout(r, 90));
+ assert.equal(capturedSignal.aborted, false, 'deadline cleared on body-read success — signal never aborted');
+ } finally {
+ restore();
+ delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+ }
+});
+
+test('fetchT: reads metadata + body through the Proxy off a REAL Response (brand-checked getters)', async () => {
+ process.env.PROVIDER_HTTP_TIMEOUT_MS = '5000';
+ // A real undici Response exposes ok/status via private-class-field getters that
+ // THROW if accessed with the wrong receiver. This guards the Proxy's
+ // Reflect.get(target, prop, target): a "helpful" refactor to `receiver` (the
+ // Proxy) would make every getter throw. The other tests mock fetch with plain
+ // objects and would NOT catch that; this one would.
+ global.fetch = async () => new Response(JSON.stringify({ id: 'ch_real' }),
+ { status: 200, headers: { 'content-type': 'application/json' } });
+ try {
+ const res = await fetchT('https://provider.test/real');
+ assert.equal(res.ok, true, 'res.ok reads off a real Response through the Proxy');
+ assert.equal(res.status, 200, 'res.status reads off a real Response through the Proxy');
+ assert.deepEqual(await res.json(), { id: 'ch_real' }, 'body parsed off a real Response through the Proxy');
+ } 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'));
← 97da4e7 cycle 5: update YOLO_NOTES.md with PRE-FLIGHT #6 completion
·
back to Costa Rica
·
cycle 6 docs: YOLO_NOTES ledger + GO-LIVE runbook — #4/#5/#6 9b74e96 →