OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge / pds / lib / deadline.js

55 lines · javascript ref:
// withDeadline — a settle guarantee for upstream calls.
//
// Both `doFetch` editions (server-bare.js over bare-http1, server.js over global fetch) reject only
// when the request emits an error. That covers a REFUSED connection (~5ms) but NOT an unreachable
// HOST: with packets blackholed, bare-http1's global agent (`agent.js:217`, timeout 5000) destroys
// the socket, and `client-connection.js:73-75` turns that into a bare `req.destroy()` — no error
// event, so nothing rejects and the proxied XRPC hangs until the caller gives up. Measured: still
// pending after 30s. Passing `timeout` per-request does not help either, because the agent merges
// its own opts LAST (`agent.js:128`), overwriting yours.
//
// So the deadline lives here, above the transport, and hands back an `onTimeout` hook so the caller
// can destroy the in-flight request rather than leak the socket.

export const UPSTREAM_TIMEOUT = 'UPSTREAM_TIMEOUT'

/**
 * Race `promise` against a deadline.
 *
 * @param {Promise} promise      the in-flight upstream call
 * @param {number}  timeoutMs    deadline in ms; <= 0 (or non-finite) disables the guard
 * @param {Function} [onTimeout] called ONCE if the deadline blows — destroy the request here
 * @returns {Promise} the promise's value, or rejects with a UPSTREAM_TIMEOUT error
 */
export function withDeadline (promise, timeoutMs, onTimeout = null) {
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise

  return new Promise((resolve, reject) => {
    let done = false

    const timer = setTimeout(() => {
      if (done) return
      done = true
      // Destroy the request FIRST, then reject: a caller that retries another upstream must not be
      // racing a socket we have already given up on.
      if (onTimeout) { try { onTimeout() } catch { /* a failed abort must not mask the timeout */ } }
      const err = new Error(`upstream timed out after ${timeoutMs}ms`)
      err.code = UPSTREAM_TIMEOUT
      err.timeoutMs = timeoutMs
      reject(err)
    }, timeoutMs)
    // NOT unref'd, deliberately. An unref'd deadline is skipped whenever nothing else holds the loop
    // open — i.e. it stops guaranteeing a settle in exactly the idle case. It is always cleared below,
    // so it outlives the call by nothing.

    promise.then(
      (v) => { if (done) return; done = true; clearTimeout(timer); resolve(v) },
      // An upstream error is passed through VERBATIM. Masking ECONNREFUSED as a timeout would make
      // "the box is off" and "the process is down" indistinguishable — they need different responses.
      (e) => { if (done) return; done = true; clearTimeout(timer); reject(e) }
    )
  })
}

export default withDeadline