← back to Costa Rica

lib/payments/http.js

121 lines

'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 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, 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.

// 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);
}

// 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);
  // 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 {
    // Ours is the only signal here (call sites don't pass their own).
    res = await fetch(url, { ...opts, signal: ctl.signal });
  } catch (e) {
    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); } };

  // Wrap a Response so its body-consuming methods (json/text) run under the shared
  // deadline and relabel an abort as PROVIDER_TIMEOUT. Applied to the original AND
  // to any clone(): a naked `res.clone()` returns the RAW Response, so
  // `res.clone().json()` would be an UNBOUNDED body read with no signal that
  // anything is wrong — the clone must carry the same bound. clone tees the same
  // incoming stream and shares this signal, so an abort still aborts both readers;
  // once either read completes it clears the shared timer, and a second read then
  // draws from already-buffered bytes (no further network stall). No current caller
  // uses clone(); this is hardening before a future retry-with-clone caller bites.
  // Every body-consuming Response method — not just json()/text() — is an unbounded
  // read that must run under the deadline (a caller could switch to arrayBuffer/blob
  // and silently lose the bound). Wrap the whole class.
  const BODY_METHODS = new Set(['json', 'text', 'arrayBuffer', 'blob', 'formData', 'bytes']);
  const wrap = (r) => new Proxy(r, {
    get(target, prop) {
      if (typeof prop === 'string' && BODY_METHODS.has(prop) && typeof target[prop] === 'function') {
        return async (...args) => {
          try {
            return await target[prop](...args);
          } catch (e) {
            throw asTimeout(e, ms, url, 'body read', ctl);
          } finally {
            done();
          }
        };
      }
      if (prop === 'clone') return () => wrap(target.clone());
      const v = Reflect.get(target, prop, target);
      return typeof v === 'function' ? v.bind(target) : v;
    },
  });
  return wrap(res);
}

module.exports = { fetchT, timeoutMs };