An integration calling an FX rate provider without a timeout. When the provider slowed down, every worker calling this hung for 5+ minutes waiting on a TCP read. Workers piled up; the service became unresponsive.
The vulnerable diff
// integrations/exchange-rate.ts
export async function getRate(base: string, quote: string): Promise<number> {
// BUG: no timeout — hangs forever on slow upstream
const r = await fetch(`https://fx.example/rate?from=${base}&to=${quote}`);
return (await r.json()).rate;
}What is wrong
Node's `fetch` (since 18) does NOT have a default timeout. Neither does the older `http.request`. Slow or hung upstreams hold the connection open until the OS TCP keepalive timeout — typically minutes. Every concurrent caller is blocked for the same duration. Worker pools exhaust; latency spikes; if your service is single-threaded (Node), the whole event loop fills with pending fetches. The fix is to always pass `AbortSignal.timeout(ms)` or an explicit AbortController with a deadline.
The attack
Outage timeline:
t+0:00 — Upstream FX provider degrades. Response time 30s.
t+0:01 — Workers calling getRate now hang for 30s each.
t+0:05 — Worker pool full. New requests queue.
t+0:10 — Queue overflows. Requests time out at the LB.
t+0:30 — Pager. SRE finds the hung fetch in stack trace.
t+0:45 — Hotfix: add AbortSignal.timeout(5_000). Deploy.
Same shape under any outbound call without timeout.
Mesrai's review comment
mesraipilot · Bot · reviewed 1 min ago
[mesrai] [code-review] [Performance] [CWE-400] [critical]
`fetch` without timeout. Hung upstreams freeze workers indefinitely.
Always pass AbortSignal.timeout:
const r = await fetch(url, { signal: AbortSignal.timeout(5_000) });
Or with retry + backoff:
async function safeFetch(url: string) {
for (let i = 0; i < 3; i++) {
try {
return await fetch(url, { signal: AbortSignal.timeout(5_000) });
} catch (err) {
if (err.name === "TimeoutError" && i < 2) {
await sleep(2 ** i * 200);
continue;
}
throw err;
}
}
throw new Error("unreachable");
}
For axios: `{ timeout: 5000 }`.
For http.get: pass `timeout` option + listen for 'timeout' event.The fix
// integrations/exchange-rate.ts — fixed
export async function getRate(base: string, quote: string): Promise<number> {
const r = await fetch(
`https://fx.example/rate?from=${base}&to=${quote}`,
{ signal: AbortSignal.timeout(5_000) }
);
if (!r.ok) throw new Error(`FX provider ${r.status}`);
return (await r.json()).rate;
}5-second timeout. Upstream slowdown surfaces as a clean TimeoutError that the caller can handle (retry, fallback, surface as 503).
Why human review missed it
Missing timeout is the most common cause of cascading outages in microservices. Mesrai catches every outbound HTTP call without an explicit timeout.
Related rules + further reading
Mesrai rule pack: performance/http-timeout-required — flags fetch/axios/http.get without timeout.
CWE-400 — Uncontrolled Resource Consumption.
Common cause of cascade failures across services.
Takeaway
Every outbound HTTP needs a timeout. AbortSignal.timeout for fetch. Mesrai catches every untimed call.