The Fundamentals helper sorted failures into two buckets: retryable and not. That is enough for a script. It is not enough for a service that makes a hundred thousand calls a day, because "not retryable" covers four different situations that need four different people. A 400 is a bug in your code. A 401 is a deployment that shipped without its secret. A 429 with no credits left is a billing event. A 404 from a lookup is often not a failure at all.
When all of those land in one catch block that logs Request failed, the on-call engineer gets paged for typos and the billing problem sits in a log nobody reads. This lesson builds the classification layer that the rest of the course depends on: every failure gets a type, every type gets one action.
Four layers where a call can fail
A call to an API passes through four layers, and each layer fails in its own way. Check them in order, because a failure at one layer means the layers after it never ran.
| Layer | What failed | How you see it | Example |
|---|---|---|---|
| Transport | No usable HTTP response arrived | fetch rejects | DNS lookup failed, connection reset, your own timeout fired |
| HTTP status | A response arrived and says the request failed | res.status outside 2xx | 401, 429, 503 |
| Envelope | The body says the call failed | body.status === "error" | A 200 whose envelope carries an error |
| Item | One element inside a successful multi-result call failed | results[i].status === "error" | Item 14 of a batch was rate limited |
Most retry bugs come from skipping a layer. Code that only checks res.ok treats a batch where every item failed as a success. Code that calls res.json() without a guard crashes on an HTML error page from a proxy and reports a JSON parse error, which sends the next person to debug the wrong thing.
Transport errors
A transport error means you have no HTTP status to reason about. In Node 18+ fetch rejects with TypeError: fetch failed, and the useful detail is on err.cause:
err.cause.code (or err.name) | Meaning | Did the request reach the server? |
|---|---|---|
ENOTFOUND, EAI_AGAIN | DNS lookup failed | No |
ECONNREFUSED | Nothing accepted the connection | No |
UND_ERR_CONNECT_TIMEOUT | Connecting took too long | No |
ECONNRESET, UND_ERR_SOCKET | Connection dropped mid-flight | Unknown |
TimeoutError / AbortError (on err.name) | Your own deadline fired | Unknown |
The last column is the one that matters. A failure before the connection is established is safe to retry for any operation, because the server never saw the request. A reset or timeout after the request was sent is ambiguous: the server may have done the work and the response was lost on the way back. For a read or a pure transformation that is harmless. For an operation with side effects it is the classic double-charge, and lesson four deals with it.
There is a fifth transport-shaped failure that fetch does not reject on: a response that is not the API's JSON. A corporate proxy, a captive Wi-Fi portal or a load balancer in front of your own service can answer with an HTML page. Treat "status is set but the body is not a JSON envelope" as its own class. It is usually transient, and it is never evidence about your input.
HTTP statuses on APIVerve
Each status carries a specific meaning on APIVerve, and each needs its own action. The verdicts below are what the platform actually returns; check the errors reference when in doubt.
| Status | What it means here | Action |
|---|---|---|
400 | Bad input. The error sentence names the parameter. | Fix input. Log the input and the message; send nothing again until the input changes. |
401 | No key, a malformed key, or a key that is not recognised. The body is JSON: {"status":"error","error":"unauthorized",...} | Stop and alert. It is configuration. Every call with this key will fail the same way. |
403 | A valid key that is not permitted to make this call: key scoping blocks the endpoint, the caller's IP is not on the key's allow-list, or the key was revoked. | Stop and alert. Not an identity problem; read error to see which of the three it is. |
404 | An unknown API id, an API not offered on this product, or (on some lookups) nothing found for the input. | Depends. An unknown API is a bug. "Not found for this input" is a valid answer. |
429 with credits left | Per-minute rate limit. | Retry after retry-after, and slow the whole client down. |
429 with credits at zero | The billing cycle's credits are spent. | Stop and alert, as a billing event. Nothing changes until renewal or a top-up. |
500, 503 | Failure on the platform side, or the upstream source is unavailable. | Retry with backoff. Page a human if it persists. |
Other 5xx (502, 504) | Usually an intermediary between you and the API. | Retry with backoff. |
Two rows need care.
404 has three meanings. An unknown id comes back from the edge as {"status":"error","error":"Unknown API 'nosuchapi'","data":null}. That is a typo or a removed integration, and it should fail loudly in tests. But a dictionary lookup for a word it has no entry for also answers 404, and that is information about the word, not about your code. Decide per endpoint whether 404 is a result or a bug, and encode the decision in the wrapper for that endpoint rather than in the generic layer.
429 has two meanings, and the message is not how you tell them apart. Read the x-api-remaining-credits header. On a credit-exhaustion 429 the header is present and reads 0 or less. On a rate-limit 429 the rate check runs before the credit headers are written, so the header is usually absent, and headers.get() returns null. That makes the classic bug worse than it looks:
// Wrong: Number(null) === 0 and Number('') === 0, so every rate limit looks like exhaustion
if (Number(res.headers.get('x-api-remaining-credits')) === 0) stopEverything();A missing header means "unknown", and unknown on a 429 should be treated as a rate limit with a bounded retry. Only a header that is present and parses to a number at or below zero means the month is spent.
curl "https://api.apiverve.com/v1/httpstatuslookup?code=429" \
-H "x-api-key: YOUR_API_KEY"The generic meaning of 429 is one sentence. On APIVerve it carries two meanings, and only the credit header tells you which one you got.
The decision as a pure function
Before writing classes, write the retry decision as a function with no I/O. It takes the status and the raw header string, and returns whether a retry could change the outcome. Pure functions are trivial to unit test, and this one is the heart of every client you will write in this course.
/**
* @param {number} status HTTP status of the response
* @param {string|null} remainingCreditsHeader raw x-api-remaining-credits value, or null
* @returns {boolean} true when the same request might succeed later
*/
export function shouldRetry(status, remainingCreditsHeader) {
if (status === 429) {
if (remainingCreditsHeader === null || remainingCreditsHeader === undefined) return true;
const text = String(remainingCreditsHeader).trim();
if (text === '') return true; // present but empty: unknown, not zero
const credits = Number(text);
if (Number.isNaN(credits)) return true; // unparseable: unknown
return credits > 0; // 0 or negative: credits exhausted
}
return status >= 500 && status <= 599;
}Notice what it deliberately does not do. It never retries a 4xx other than 429, because those describe the request, and APIVerve throttles a call that keeps failing the same way and replays the original error to it, so a loop of identical bad requests does not even reach the API. And it checks credits > 0 rather than === 0: the remaining figure is computed as allowance minus usage, so a key that went slightly over can report a negative number. Treat anything at or below zero as exhausted.
A few cases are worth pinning in tests, because they are exactly where hand-written versions go wrong:
import assert from 'node:assert/strict';
import { shouldRetry } from './should-retry.mjs';
assert.equal(shouldRetry(429, null), true); // header absent: rate limit
assert.equal(shouldRetry(429, ''), true); // empty is not zero
assert.equal(shouldRetry(429, '0'), false); // credits spent
assert.equal(shouldRetry(429, '-3'), false); // overdrawn is also spent
assert.equal(shouldRetry(429, '1250'), true); // credits left: rate limit
assert.equal(shouldRetry(503, null), true);
assert.equal(shouldRetry(400, '1250'), false);
assert.equal(shouldRetry(403, null), false);Transport failures sit outside this function because they have no status. Handle them one level up: retry the ones where the request never left, and retry the ambiguous ones only when the operation is safe to repeat.
A call returns 429 and res.headers.get('x-api-remaining-credits') returns null. What does that most likely mean on APIVerve?
Envelope and item failures
A 2xx status is a statement about the transport, not about the result. Always check the envelope:
if (res.ok && body?.status === 'ok') return body.data;On single calls a non-2xx status and status: "error" normally arrive together. The place they separate is multi-result calls. A batch request (POST /v1/<api>/batch with up to 200 items) answers 200 whenever the batch itself ran, even if every item failed. Each element of results is an ordinary envelope, in the order you sent them, and two kinds of refusal carry their own code:
{ "status": "error", "error": "insufficient credits", "code": 402, "data": null }
{ "status": "error", "error": "Rate limit exceeded ... Retry shortly.", "code": 429, "retryAfter": 41, "data": null }
A 402 item ran past your remaining credits. A 429 item did not fit in this minute's rate window and can be resent after retryAfter seconds. Items that failed validation come back as an error envelope with the API's message, and they are not billed. So the per-item classification is: code: 402 stops the job like a credit-exhaustion 429, code: 429 goes back into the queue, an error without one of those codes is an input problem for that one item, and status: "ok" is done.
One more thing is not a failure: a null field. When a plan does not include a premium field, the platform sets the field to null rather than removing it. A client that treats null as "the call broke" will raise false alarms; a client that assumes a field is never null will crash on data.a.b. Write optional chaining at every level of a path and decide what a missing value means for your feature.
A typed error hierarchy
Now turn the taxonomy into types. The goal is that the code which catches an error never inspects status codes or headers again: it asks the error what it is and what to do.
// Node 18+, ESM. One class per action, not one per status code.
export class ApiCallError extends Error {
constructor(message, { status = 0, api, cause, trace = {} } = {}) {
super(message, { cause });
this.name = this.constructor.name;
this.status = status; // 0 when no HTTP response arrived
this.api = api;
this.trace = trace; // cf-ray / x-cloud-trace-context for support tickets
}
get retryable() { return false; }
get action() { return 'fix-input'; } // fix-input | retry | stop-alert | page
}
export class TransportError extends ApiCallError {
constructor(message, opts = {}) { super(message, opts); this.sent = opts.sent ?? 'unknown'; }
get retryable() { return true; } // caller still checks `sent` for unsafe operations
get action() { return 'retry'; }
}
export class BadResponseError extends ApiCallError { // HTML from a proxy, truncated JSON
get retryable() { return true; }
get action() { return 'retry'; }
}
export class InputError extends ApiCallError {} // 400
export class NotFoundError extends ApiCallError {} // 404: wrappers decide if it is a result
export class AuthError extends ApiCallError { // 401
get action() { return 'stop-alert'; }
}
export class PermissionError extends ApiCallError { // 403
get action() { return 'stop-alert'; }
}
export class RateLimitError extends ApiCallError { // 429 with credits left
constructor(message, opts = {}) { super(message, opts); this.retryAfter = opts.retryAfter ?? null; }
get retryable() { return true; }
get action() { return 'retry'; }
}
export class CreditsExhaustedError extends ApiCallError { // 429 with credits at or below 0
get action() { return 'stop-alert'; }
}
export class UpstreamError extends ApiCallError { // 5xx
get retryable() { return true; }
get action() { return 'retry'; } // escalates to 'page' when the breaker opens (lesson two)
}The classifier is the only place that knows about statuses and headers:
import * as E from './errors.mjs';
import { shouldRetry } from './should-retry.mjs';
const NOT_SENT = new Set(['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'UND_ERR_CONNECT_TIMEOUT']);
export function classifyThrown(err, api) {
const code = err?.cause?.code;
if (err?.name === 'TimeoutError' || err?.name === 'AbortError') {
return new E.TransportError(`${api}: timed out`, { api, cause: err, sent: 'unknown' });
}
const sent = NOT_SENT.has(code) ? 'no' : 'unknown'; // resets and unknown codes: assume it may have arrived
return new E.TransportError(`${api}: network error ${code ?? err?.message}`, { api, cause: err, sent });
}
export function classifyResponse(res, body, api) {
const trace = {
cfRay: res.headers.get('cf-ray'),
cloudTrace: res.headers.get('x-cloud-trace-context'),
};
const opts = { status: res.status, api, trace };
if (!body || typeof body !== 'object' || !('status' in body)) {
return new E.BadResponseError(`${api}: HTTP ${res.status} without a JSON envelope`, opts);
}
const msg = `${api}: ${body.error ?? `HTTP ${res.status}`}`;
const credits = res.headers.get('x-api-remaining-credits');
switch (res.status) {
case 400: return new E.InputError(msg, opts);
case 401: return new E.AuthError(msg, opts);
case 403: return new E.PermissionError(msg, opts);
case 404: return new E.NotFoundError(msg, opts);
case 429:
return shouldRetry(429, credits)
? new E.RateLimitError(msg, { ...opts, retryAfter: res.headers.get('retry-after') })
: new E.CreditsExhaustedError(msg, opts);
}
if (shouldRetry(res.status, credits)) return new E.UpstreamError(msg, opts);
return new E.InputError(msg, opts); // any other 4xx describes the request
}The trace object is what makes "page a human" useful. APIVerve does not set an x-request-id; the identifiers support can look up are cf-ray, present on every response including the ones the edge answers itself, and x-cloud-trace-context, present once the request reached the origin. Both are readable from a server. Neither is in the browser's CORS-exposed set, so capture them server side.
Your batch call returns HTTP 200. results[7] is {"status":"error","error":"insufficient credits","code":402,"data":null}. How should the job treat it?
From type to action
With the hierarchy in place, the caller's error handling is a short switch on action, and each branch goes to a different place:
export function route(err, { logger, alert, pager }) {
switch (err.action) {
case 'fix-input':
logger.warn({ api: err.api, status: err.status, msg: err.message }, 'rejected input');
return 'drop'; // or dead-letter the message with the reason
case 'retry':
return 'retry'; // lesson two decides how, and whether the budget allows it
case 'stop-alert':
alert(`${err.name} on ${err.api}: ${err.message}`, err.trace);
return 'halt'; // stop the worker, keep the queue intact
case 'page':
pager(`${err.api} failing: ${err.message}`, err.trace);
return 'halt';
}
}The split between alert and page is a judgment, and these defaults hold up well:
- Fix input goes to logs and a dead-letter queue, with the API's own sentence. A rising rate of input errors after a deploy is a regression; a steady trickle is user data.
- Stop and alert is for anything that will fail identically on every call until a person changes something: a rejected or revoked key, a key scoped away from an endpoint you now need, an IP that is not on the allow-list, exhausted credits. The alert goes to whoever owns the account, not to on-call, and the worker halts so it does not spend the night producing the same error.
- Retry is handled inside the client and is invisible unless it fails.
- Page is for sustained upstream failure that retries could not absorb. That decision belongs to the circuit breaker in the next lesson, not to any single call.
A quick self-check before moving on: for every catch in your codebase that touches an API, can you say which of these four branches it is? If the answer is "it logs and continues", it is silently dropping one of them.
What you have now
Every failure your client can see now has a name and an owner: transport errors know whether the request left, statuses map to fix-input, retry or stop-and-alert, the two 429s are separated by the credit header with the null trap closed, and batch items are classified on their own. The retry branch is still naive. Next you bound it with deadlines, budgets and a circuit breaker: Retry budgets and circuit breakers.