29 β Resilience β Timeouts, Retries, Rate Limits and Pagination
Level: Intermediate Β· Time: ~40 min Β· Prerequisites: Lesson 28 β Authentication, Secrets and Local Mock Servers
Why this matters
One call to NASA POWER is a single coin toss; a year of irradiance is 365 of them, and the arithmetic is unforgiving β an endpoint that succeeds 99 times in 100 still fails somewhere in a 365-call batch with a probability above 97%. Lesson 21 gave you the primitives and Lesson 27's helper put a timeout on every call; this lesson turns those pieces into a policy you apply without thinking, so a flaky API costs you a delay rather than a night of debugging.
Every call carries a deadline
fetch has no default timeout, so a stalled connection stays open for as long as the operating system
allows β minutes β and nothing rejects while your tool waits. Lesson 27's helper already passes
AbortSignal.timeout(...); two additions make it the base below: the error must carry the server's delay.
// src/http.ts β the two additions Lesson 27's helper needs
export class HttpError extends Error {
constructor(readonly status: number, message: string, readonly retryAfterSeconds?: number) {
super(message);
this.name = "HttpError";
}
}
// inside request(): combine the caller's signal (shutdown) with the per-call deadline
const timeout = AbortSignal.timeout(options.timeoutMs ?? 15_000);
const signal = options.signal === undefined ? timeout : AbortSignal.any([options.signal, timeout]);
// and where a bad status is turned into an error:
const header = response.headers.get("retry-after");
const seconds = header === null ? undefined : Number(header);
throw new HttpError(response.status, `${response.status} from ${url.pathname}`,
seconds !== undefined && Number.isFinite(seconds) ? seconds : undefined);
AbortSignal.any needs Node 20.3, and reading Retry-After here β the only place that sees the response β is what lets the policy obey the server instead of guessing.
Retry β which failures, how often, how long
Retrying everything is as wrong as retrying nothing: a 400 will be a 400 on the fourth attempt too.
| Failure | Retry? | Why |
|---|---|---|
fetch rejects with TypeError: fetch failed |
yes | the connection never completed: DNS, reset socket, TLS |
TimeoutError from AbortSignal.timeout |
yes | the server was slow, not wrong |
408, 425, 429 |
yes | the server explicitly asked you to come back |
500, 502, 503, 504 |
yes | usually transient capacity problems |
400, 401, 403, 404, 410, 501 |
no | the request is wrong, gone, or not implemented |
| A response that fails schema validation | no | it fails identically every time β Lesson 30 |
[!IMPORTANT] A retry may only repeat what is safe to repeat: a
POSTthat creates a row will happily create two, because the first attempt may have succeeded and lost its response on the way back.
// src/withRetry.ts
import { HttpError } from "./http.js";
export const defaultPolicy = { attempts: 4, baseMs: 300, maxDelayMs: 8_000, deadlineMs: 30_000 };
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
export function isRetryable(error: unknown): boolean {
if (error instanceof HttpError) {
if (error.status === 408 || error.status === 425 || error.status === 429) return true;
return error.status >= 500 && error.status !== 501;
}
if (error instanceof TypeError) return true; // undici reports a failed connection this way
return (error as { name?: string } | null)?.name === "TimeoutError"; // the deadline fired
}
export async function withRetry<T>(operation: (attempt: number) => Promise<T>, policy = defaultPolicy): Promise<T> {
const deadline = Date.now() + policy.deadlineMs;
let lastError: unknown;
for (let attempt = 1; attempt <= policy.attempts; attempt++) {
try { return await operation(attempt); } catch (error) {
lastError = error;
if (!isRetryable(error) || attempt === policy.attempts) break;
const backoff = Math.min(policy.baseMs * 2 ** (attempt - 1), policy.maxDelayMs) + Math.random() * policy.baseMs;
const waitMs = error instanceof HttpError && error.retryAfterSeconds !== undefined
? error.retryAfterSeconds * 1_000 : backoff;
if (Date.now() + waitMs >= deadline) { console.error(JSON.stringify({ event: "abandoned", attempt })); break; }
console.error(JSON.stringify({ event: "retry", attempt, waitMs: Math.round(waitMs) }));
await delay(waitMs);
}
}
throw lastError; // attempts is at least 1, so something was always caught
}
Four things carry all of it: attempts, delay, a total deadline that stops a bad afternoon becoming a hung
process, and one log line per attempt. Retry-After wins over the computed backoff, because the API knows
when its window reopens and you do not, and jitter matters as much as doubling.
Rate limits, and what a retry is allowed to repeat
A 429 is not a failure to be beaten with more attempts; it is an instruction. Retry-After carries the
wait in seconds, and some servers send an HTTP date instead, which Date.parse understands. The
X-RateLimit-* family is advisory and spelled differently by each vendor, so treat it as guidance.
Your own behaviour is not advisory: a limiter that spaces calls out β keep nextAt, compare it with
Date.now(), sleep the difference, then move nextAt forward β turns a burst into a steady trickle. Keep
one per remote host, because a limiter per worker is defeated by the pool it protects.
The fix is Lesson 21's idempotency rule made concrete: generate one key before the loop β
randomUUID() from node:crypto β and send it as Idempotency-Key on every attempt, so the server returns
the first result for the repeats. When an API has no such header, do not retry the write: insert against a
natural unique key and let the database reject the duplicate.
Pagination β fetch until there is no next page
Offset and limit (?limit=100&offset=200) is easy to read and to parallelise, but the rows shift while you
walk β ingest day by day while the API also receives today's reading, and the walk returns one day twice. A
cursor shape is stable but sequential. Either way: a page cap, a cursor that must advance, an empty-page stop.
// src/paginate.ts
interface ItemPage<T> { readonly items: readonly T[]; readonly nextCursor: string | null; }
export async function fetchAllPages<T>(fetchPage: (cursor: string | null) => Promise<ItemPage<T>>, maxPages = 50): Promise<T[]> {
const items: T[] = [];
let cursor: string | null = null;
for (let page = 1; page <= maxPages; page++) {
const result = await fetchPage(cursor);
items.push(...result.items);
if (result.items.length === 0 || result.nextCursor === null) return items;
if (result.nextCursor === cursor) throw new Error(`cursor did not advance on page ${page}`);
cursor = result.nextCursor;
}
throw new Error(`stopped after ${maxPages} pages; the API may be ignoring the cursor`);
}
A cursor that comes back unchanged is an infinite loop in disguise and deserves an exception, not fifty
duplicate pages. Retry each page with withRetry: reads are idempotent.
Caching that pays for itself
Most repeated requests ask for data that has not changed, and three cheap layers remove most of them. An
in-memory cache keyed on the request with a short time-to-live covers repeats inside one run. Conditional
requests β send the stored ETag as If-None-Match, and a 304 Not Modified means "reuse what you have" β
save a round trip's bandwidth and have no body, so reading one throws. The raw response written under
data/raw/ outlives the process and becomes the fixture of Lesson 30. Never cache a write, a per-user
response, or anything documented as live.
Common mistakes
| Mistake | What happens | The fix |
|---|---|---|
Calling fetch with no timeout |
A stalled connection hangs the tool for minutes with no error | Pass AbortSignal.timeout, as the Lesson 27 helper does |
| Retrying every failure | Four identical 400s, wasted quota, and a slow failure |
Retry only the statuses in the table above |
Retrying a POST that creates a row |
Duplicate readings, because the first attempt may have succeeded | Send an idempotency key, or do not retry writes |
| Backoff with no jitter | Clients that failed together retry together and re-break the API | Add Math.random() * baseMs to every delay |
| No total deadline, only attempts | A long outage becomes a run that finishes after midnight | Budget the whole operation and log the abandonment |
| Walking pagination with no page cap | An offset walk over shifting data, or a cursor that never advances | Cap the pages, require progress, stop on an empty page |
Try it
- Add
src/withRetry.tsas above and the two additions tosrc/http.ts, then extend the mock server of Lesson 28 so a failure can be temporary β a counter outside the handler, and a mode that fails twice:
- Call it through the policy and watch the decision being made:
const mock = await startMockServer("test-token");
const fixture = await withRetry(() => request(mock.url, "/api/v1/daily?mode=flaky"));
console.log("succeeded after retries", fixture);
await mock.close();
Expected output: two lines on stderr β {"event":"retry","attempt":1,β¦} and β¦"attempt":2β¦ β then succeeded after retries with the fixture. The waits grow and are never identical twice: that is the jitter.
- Prove the deadline: call
?mode=slowwith{ timeoutMs: 500 }, time the run withtime node dist/index.js, then setattempts: 2.
Expected output: four attempts, each ending in a timeout, a wall clock bounded by deadlineMs, and a
final TimeoutError; then the same failure, sooner.
Key takeaways
- There is no default timeout on
fetch: a deadline belongs on every request, and the signal must reach it. - Retry decisions come from the status: timeouts,
408,425,429and5xxexcept501are retryable;400,401,403,404are not. - A policy is attempts, delay and a total budget plus jitter; it may only repeat what is safe to repeat, so writes need an idempotency key.
Retry-Afterand theX-RateLimit-*headers are instructions, your own shared limiter is cheaper than being throttled, and pagination needs a page cap plus a cursor that must advance.
Check yourself
fetchrejects withTimeoutErroron the fourth attempt of aGET. Should a fifth happen, and what else has to be true before it is useful?- The API answers
429withRetry-After: 30while your backoff would have been 300 ms. Which wins, and what does the deadline do? - Why is the idempotency key generated before the retry loop rather than inside it?
- Your pagination loop sees a
nextCursoridentical to the cursor it just used. Name two possible causes, and say why a higher page cap is the wrong response to either.