The Fetch Wrapper I Keep in Every Project
My zero-dep `apiFetch` for Node and the browser. Adds a per-request timeout, retries with jittered backoff on 5xx and network failures, parses JSON, and attaches an auth token without leaking it into errors.
By @leoeriksson
April 13, 2026
·
Updated August 12, 2026
1,034 views
32
4.2 (9)
// apiFetch v1: timeout + JSON parse + structured error.
// Stage 1 of 4. Each stage is independently runnable.
class ApiError extends Error {
constructor(status, code, body) {
super(`api ${status} ${code}`);
this.status = status;
this.code = code;
this.body = body;
}
}
async function apiFetch(url, opts = {}) {
const { timeoutMs = 8000, headers = {}, ...rest } = opts;
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
const res = await fetch(url, {
...rest,
signal: ac.signal,
headers: { accept: 'application/json', ...headers },
});
const text = await res.text();
const body = text ? safeParseJson(text) : null;
if (!res.ok) throw new ApiError(res.status, body && body.code, body);
return body;
} finally {
clearTimeout(timer);
}
}
function safeParseJson(text) {
try { return JSON.parse(text); } catch { return { raw: text }; }
}
// Demo against a mocked fetch.
globalThis.fetch = async () => ({
ok: true, status: 200,
text: async () => JSON.stringify({ id: 7, ok: true }),
});
apiFetch('/users/7').then((b) => console.log('got:', b));This is the smallest version of the wrapper I will tolerate in a service. The AbortController plus setTimeout pair is the only portable way to bound a fetch call in both Node 18+ and browsers, and clearTimeout in finally matters: if you forget it, an abort fires after the request already returned and you get spurious noise in your logs. safeParseJson exists because real APIs return HTML error pages on 502, and a top-level JSON.parse throw is much harder to debug than a { raw: '...' } payload. The custom ApiError carries status and code separately so callers can branch on err.status === 429 without string-matching the message.
// apiFetch v2: timeout + retry on 5xx and network errors with full jitter.
// Stage 2 of 4. Inlines stage-1 helpers so it runs standalone.
class ApiError extends Error {
constructor(status, code, body) {
super(`api ${status} ${code}`);
this.status = status; this.code = code; this.body = body;
}
}
function safeParseJson(text) {
try { return JSON.parse(text); } catch { return { raw: text }; }
}
async function apiFetch(url, opts = {}) {
const {
timeoutMs = 8000,
retries = 3,
baseDelayMs = 200,
headers = {},
...rest
} = opts;
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
const res = await fetch(url, {
...rest, signal: ac.signal,
headers: { accept: 'application/json', ...headers },
});
const text = await res.text();
const body = text ? safeParseJson(text) : null;
if (res.ok) return body;
// Retry only on 5xx; everything else is a hard error.
if (res.status < 500 || attempt === retries) {
throw new ApiError(res.status, body && body.code, body);
}
lastErr = new ApiError(res.status, body && body.code, body);
} catch (err) {
if (attempt === retries) throw err;
lastErr = err;
} finally {
clearTimeout(timer);
}
const delay = Math.floor(Math.random() * baseDelayMs * (2 ** attempt));
await new Promise((r) => setTimeout(r, delay));
}
throw lastErr;
}
// Demo: first call fails with 503, second succeeds.
let n = 0;
globalThis.fetch = async () => {
n++;
if (n === 1) return { ok: false, status: 503, text: async () => '{"code":"upstream"}' };
return { ok: true, status: 200, text: async () => '{"id":7}' };
};
apiFetch('/users/7', { baseDelayMs: 5 }).then((b) => console.log('after retry:', b, 'attempts:', n));Full jitter (uniform random in [0, baseDelay * 2^attempt)) is the backoff I default to because it spreads the thundering herd better than equal jitter when many clients retry the same outage. The retry rule is deliberately narrow: 5xx and network errors only, never 4xx. A retried 401 is just an extra log line; a retried 429 is a way to get rate-limit-banned faster. The lastErr shuffle is so the loop's final throw carries the most recent failure rather than whatever the first attempt threw, which is what an on-call engineer wants to see when they open the trace.
// apiFetch v3: token injection that never appears in thrown errors or logs.
// Stage 3 of 4. Inlines prior helpers.
class ApiError extends Error {
constructor(status, code, body) {
super(`api ${status} ${code}`);
this.status = status; this.code = code; this.body = body;
}
}
function makeApiClient({ baseUrl, getToken }) {
return async function apiFetch(path, opts = {}) {
const { timeoutMs = 8000, headers = {}, ...rest } = opts;
const token = await getToken();
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
const res = await fetch(baseUrl + path, {
...rest, signal: ac.signal,
headers: {
accept: 'application/json',
...headers,
// Token added LAST so a caller cannot accidentally override it,
// and it is never spread into any object we serialize.
authorization: `Bearer ${token}`,
},
});
const text = await res.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch { body = { raw: text }; }
if (!res.ok) {
const err = new ApiError(res.status, body && body.code, body);
err.requestHeaders = scrubAuth(headers);
throw err;
}
return body;
} finally {
clearTimeout(timer);
}
};
}
function scrubAuth(headers) {
const out = {};
for (const [k, v] of Object.entries(headers)) {
out[k] = /^authorization$/i.test(k) ? '[REDACTED]' : v;
}
return out;
}
globalThis.fetch = async (url, init) => {
console.log('outgoing has authorization?', !!init.headers.authorization);
return { ok: false, status: 500, text: async () => '{"code":"oops"}' };
};
const api = makeApiClient({ baseUrl: 'https://x', getToken: async () => 'sk_live_super_secret' });
api('/me', { headers: { authorization: 'caller_provided' } }).catch((e) => {
console.log('error msg has token?', e.message.includes('super_secret'));
console.log('headers on error:', e.requestHeaders);
});The shape here is a factory that closes over getToken so the token never lives in a module-level variable. The two careful bits are the authorization header being added last in the spread (callers cannot stomp it accidentally) and the explicit scrubAuth pass before attaching headers to the thrown error. I learned to do the second the hard way after a Sentry breadcrumb captured a Bearer token because we attached init.headers straight to the exception. Keeping the secret out of the error means you can paste a stack trace into Slack without a panic afterward.
// apiFetch v4: the full thing. Auth + retry + timeout + scrubbed errors.
// Stage 4 of 4. Self-contained.
class ApiError extends Error {
constructor(status, code, body) {
super(`api ${status} ${code}`);
this.status = status; this.code = code; this.body = body;
}
}
function safeParseJson(t) { try { return JSON.parse(t); } catch { return { raw: t }; } }
function scrubAuth(h) {
const o = {};
for (const [k, v] of Object.entries(h)) o[k] = /^authorization$/i.test(k) ? '[REDACTED]' : v;
return o;
}
function makeApiClient({ baseUrl, getToken, defaults = {} }) {
return async function apiFetch(path, opts = {}) {
const merged = { ...defaults, ...opts };
const { timeoutMs = 8000, retries = 3, baseDelayMs = 200, headers = {}, ...rest } = merged;
const token = await getToken();
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
const res = await fetch(baseUrl + path, {
...rest, signal: ac.signal,
headers: { accept: 'application/json', ...headers, authorization: `Bearer ${token}` },
});
const text = await res.text();
const body = text ? safeParseJson(text) : null;
if (res.ok) return body;
const err = new ApiError(res.status, body && body.code, body);
err.requestHeaders = scrubAuth(headers);
if (res.status < 500 || attempt === retries) throw err;
lastErr = err;
} catch (err) {
if (attempt === retries) throw err;
lastErr = err;
} finally { clearTimeout(timer); }
await new Promise((r) => setTimeout(r, Math.floor(Math.random() * baseDelayMs * (2 ** attempt))));
}
throw lastErr;
};
}
let n = 0;
globalThis.fetch = async () => {
n++;
if (n < 2) return { ok: false, status: 502, text: async () => '{"code":"bad_gateway"}' };
return { ok: true, status: 200, text: async () => '{"id":7,"name":"Ada"}' };
};
const api = makeApiClient({ baseUrl: '', getToken: async () => 'tok_dev', defaults: { retries: 2, baseDelayMs: 5 } });
api('/users/7').then((u) => console.log('user:', u, 'attempts:', n));Tying the three concerns together gives the shape I actually paste into a lib/api.ts file in every Node service. The factory returns a function so you can have one apiFetch per upstream (different baseUrl, different getToken), which is the configuration shape that scales when you start calling Stripe and your own backend from the same process. Defaults are merged-then-overridden so a single endpoint can opt out of retries by passing retries: 0 for non-idempotent POSTs without re-implementing the wrapper. About 70 lines, no dependencies, and it survives every refactor I throw at it.
