Spaces:
Sleeping
Sleeping
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));
}
|