Every API call can fail, and the question is not if but when. In production, networks partition, rate limits hit, services restart, and auth tokens expire. How your code handles those moments determines whether users see a graceful message or a blank screen — and whether on-call engineers have enough context to fix the problem in minutes or hours.
What changed in 2026
- Typed fetch clients are default.
openapi-fetch, zodios, and Hono's RPC layer generate typed error shapes from your OpenAPI spec, catching mismatches at compile time.
- AI services add new failure modes. LLM APIs time out on long generations, return streaming chunks that error mid-stream, and hit concurrency limits. Classic HTTP error handling needs extension.
- Edge runtimes are everywhere. Cloudflare Workers and Vercel Edge run on limited runtimes — no
node:net, limited retry windows, 50 ms CPU budget. Error handling must be lightweight.
- OpenTelemetry is the standard. Errors land in distributed traces automatically if you propagate the context; correlation IDs are no longer optional.
HTTP status classes
| Class |
Range |
Meaning |
Retry? |
| 2xx |
200–299 |
Success |
N/A |
| 4xx |
400–499 |
Client error |
No (fix the request) |
| 429 |
Too Many Requests |
Rate limited |
Yes, after Retry-After header |
| 5xx |
500–599 |
Server error |
Yes, with back-off |
| Network |
fetch throws |
DNS/TLS/timeout |
Yes, limited attempts |
Never retry a 400 or 404 — the server is telling you that your request is wrong.
Exponential back-off with jitter
async function fetchWithRetry<T>(
url: string,
options: RequestInit,
maxAttempts = 4,
): Promise<T> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch(url, options);
if (res.ok) return res.json() as Promise<T>;
// Don't retry client errors (except 429)
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
throw new ApiError(res.status, await res.text());
}
if (attempt === maxAttempts - 1) throw new ApiError(res.status, "max retries");
// Exponential back-off with full jitter
const base = Math.min(1000 * 2 ** attempt, 30_000);
const delay = Math.random() * base;
await new Promise((r) => setTimeout(r, delay));
}
throw new Error("unreachable");
}
Jitter prevents the "thundering herd" problem where all clients retry simultaneously after an outage.
Typed errors in TypeScript
type ApiSuccess<T> = { ok: true; data: T };
type ApiFailure = { ok: false; status: number; message: string };
type ApiResult<T> = ApiSuccess<T> | ApiFailure;
async function getUser(id: string): Promise<ApiResult<User>> {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
return { ok: false, status: res.status, message: await res.text() };
}
return { ok: true, data: await res.json() };
} catch (err) {
return { ok: false, status: 0, message: "network error" };
}
}
// Caller is forced to handle both cases
const result = await getUser("123");
if (!result.ok) {
console.error("Failed:", result.message);
return;
}
console.log(result.data.name);
This discriminated union makes the error path impossible to accidentally skip.
How to pick an error strategy
| Scenario |
Strategy |
| User-facing UI action |
Show specific message, offer retry button |
| Background job |
Retry with back-off, dead-letter after N failures |
| Webhook receiver |
Return 200 immediately, process async, retry internally |
| Streaming (SSE/LLM) |
Detect mid-stream error, close gracefully, show partial result |
| Critical write (payment) |
Idempotency key + exactly-once check before retry |
Logging and observability
import { trace, context } from "@opentelemetry/api";
async function callExternalApi(payload: unknown) {
const span = trace.getActiveSpan();
try {
const res = await fetch("https://api.example.com/process", {
method: "POST",
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const body = await res.text();
span?.setStatus({ code: 2, message: body }); // ERROR
span?.setAttribute("http.status_code", res.status);
throw new ApiError(res.status, body);
}
return res.json();
} catch (err) {
span?.recordException(err as Error);
throw err;
}
}
Always attach the HTTP status code, the request path, and any correlation ID the server returns. Redact sensitive fields before logging.
Common mistakes
Catching and swallowing. catch (e) {} means errors disappear silently. At minimum, log or re-throw.
Retrying non-idempotent requests. Retrying a POST /orders creates duplicate orders. Attach an idempotency key header (Idempotency-Key: <uuid>) for all mutations.
Not handling fetch throws. fetch throws on network failure, not on 4xx/5xx. Always wrap in try/catch and check res.ok.
No timeout. A hung request blocks UI or a worker thread forever. Use AbortController with a deadline:
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
What to skip
- Generic error boundaries that hide all errors — use them for truly unexpected crashes, not expected API failures with recoverable state.
- Polling status endpoints without back-off — you will hammer the server during outages; use exponential back-off or webhooks.
- Rolling your own HTTP client from scratch —
ky, ofetch, or axios (with interceptors) already handle retries, timeout, and JSON parsing.
FAQ
Should I throw errors or return result types?
Both have valid uses. Throw for truly unexpected errors; return result types (discriminated unions) for expected failures you want callers to handle explicitly.
How long should I wait between retries?
Start at ~500 ms, double each attempt, cap at ~30 s. Add jitter of ±50 % to spread load.
What is an idempotency key?
A unique ID (usually a UUID) you attach to a request. The server deduplicates: if it sees the same key twice, it returns the first response instead of processing again.
How do I test error handling?
Use msw (Mock Service Worker) to simulate 429s, 500s, and network timeouts in unit and integration tests — no real server needed.
Where to go next