[object Object]

← 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

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 →