File size: 4,528 Bytes
ed57015
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// Process-level safety net for late network transport errors (fork-validated:
// jasnoorgill's safe-fetch / process-safety-net).
//
// The failure this guards against: undici resolves `fetch()` and we move on, but
// the underlying HTTP/2 / TLS socket is later reset by a CDN edge (CloudFront,
// Cloudflare) AFTER the response was handed off. undici emits that late error on
// a stream with no listener, Node escalates it to an `uncaughtException`, and —
// with no handler installed — the whole proxy exits 1. For a gateway whose entire
// job is uptime in front of flaky free-tier providers, a third party closing a
// socket must never take the process down.
//
// Design: swallow ONLY a tight allowlist of transport-error codes/messages, and
// preserve Node's default fail-fast (exit 1) for everything else, so genuine
// bugs still surface loudly. The classifier is a pure function so it can be
// unit-tested without registering global handlers.

const TRANSPORT_ERROR_CODES = new Set([
  // Node socket-level codes
  'ECONNRESET', 'ECONNREFUSED', 'ECONNABORTED', 'ETIMEDOUT', 'EPIPE',
  'EAI_AGAIN', 'ENOTFOUND', 'EHOSTUNREACH', 'ENETUNREACH', 'EADDRNOTAVAIL',
  // undici codes (the late-error culprits)
  'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT',
  'UND_ERR_BODY_TIMEOUT', 'UND_ERR_ABORTED',
]);

const TRANSPORT_MESSAGE_HINTS = [
  'fetch failed', 'other side closed', 'socket hang up', 'terminated',
  'premature close', 'econnreset', 'request aborted',
];

// undici wraps the real socket error in `err.cause`; sometimes nested further.
// Walk the cause chain (bounded, cycle-safe) collecting codes and messages.
function walkErrorChain(err: unknown): Array<{ code?: string; message?: string }> {
  const out: Array<{ code?: string; message?: string }> = [];
  let cur: any = err;
  const seen = new Set<unknown>();
  for (let depth = 0; cur && typeof cur === 'object' && depth < 6; depth++) {
    if (seen.has(cur)) break;
    seen.add(cur);
    out.push({ code: typeof cur.code === 'string' ? cur.code : undefined, message: typeof cur.message === 'string' ? cur.message : undefined });
    cur = cur.cause;
  }
  return out;
}

/** Pure: true when the error is a recoverable network transport failure that
 *  should not crash the process (vs. a programming bug, which should). */
export function isTransportError(err: unknown): boolean {
  if (err == null) return false;
  const links = walkErrorChain(err);
  for (const { code } of links) {
    if (code && TRANSPORT_ERROR_CODES.has(code)) return true;
  }
  const joined = links.map(l => l.message ?? '').join(' | ').toLowerCase();
  return TRANSPORT_MESSAGE_HINTS.some(h => joined.includes(h));
}

export type ProcessErrorDecision = 'swallow' | 'fatal';

/** Pure: swallow transport errors, treat everything else as fatal. */
export function classifyProcessError(err: unknown): ProcessErrorDecision {
  return isTransportError(err) ? 'swallow' : 'fatal';
}

function describeError(err: unknown): string {
  const links = walkErrorChain(err);
  const code = links.find(l => l.code)?.code;
  const message = links.find(l => l.message)?.message;
  return code ? `${code} (${message ?? 'no message'})` : (message ?? String(err));
}

export interface SafetyNetHooks {
  log?: (...args: unknown[]) => void;
  exit?: (code: number) => void;
}

/** Decide and act on a process-level error. Returns the decision so callers and
 *  tests can assert behavior. `fatal` exits 1 (preserving Node's default
 *  fail-fast); `swallow` logs and lets the process continue. */
export function handleProcessError(
  kind: 'uncaughtException' | 'unhandledRejection',
  err: unknown,
  hooks: SafetyNetHooks = {},
): ProcessErrorDecision {
  const log = hooks.log ?? console.error;
  const decision = classifyProcessError(err);
  if (decision === 'swallow') {
    log(`[safety-net] swallowed transient ${kind}: ${describeError(err)}`);
    return 'swallow';
  }
  log(`[safety-net] fatal ${kind}:`, err);
  (hooks.exit ?? process.exit)(1);
  return 'fatal';
}

let installed = false;

/** Install the global handlers once. Idempotent. Call as early as possible at
 *  boot (before the server starts taking traffic). */
export function installProcessSafetyNet(hooks: SafetyNetHooks = {}): void {
  if (installed) return;
  installed = true;
  process.on('uncaughtException', (err) => handleProcessError('uncaughtException', err, hooks));
  process.on('unhandledRejection', (reason) => handleProcessError('unhandledRejection', reason, hooks));
}