Classify API errors and failures

Sort API failures into transport, HTTP status, envelope and per-item errors, map each to one action, and encode them in a typed error class hierarchy.

  • 40 min
  • Advanced

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.

LayerWhat failedHow you see itExample
TransportNo usable HTTP response arrivedfetch rejectsDNS lookup failed, connection reset, your own timeout fired
HTTP statusA response arrived and says the request failedres.status outside 2xx401, 429, 503
EnvelopeThe body says the call failedbody.status === "error"A 200 whose envelope carries an error
ItemOne element inside a successful multi-result call failedresults[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)MeaningDid the request reach the server?
ENOTFOUND, EAI_AGAINDNS lookup failedNo
ECONNREFUSEDNothing accepted the connectionNo
UND_ERR_CONNECT_TIMEOUTConnecting took too longNo
ECONNRESET, UND_ERR_SOCKETConnection dropped mid-flightUnknown
TimeoutError / AbortError (on err.name)Your own deadline firedUnknown

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.

StatusWhat it means hereAction
400Bad input. The error sentence names the parameter.Fix input. Log the input and the message; send nothing again until the input changes.
401No 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.
403A 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.
404An 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 leftPer-minute rate limit.Retry after retry-after, and slow the whole client down.
429 with credits at zeroThe billing cycle's credits are spent.Stop and alert, as a billing event. Nothing changes until renewal or a top-up.
500, 503Failure 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.

Try it liveHTTP Status Code Lookup1 credit
GEThttps://api.apiverve.com/v1/httpstatuslookup
curl "https://api.apiverve.com/v1/httpstatuslookup?code=429" \
  -H "x-api-key: YOUR_API_KEY"
Sign in or create a free account to run this with your own 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.

should-retry.mjs
/**
 * @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:

should-retry.test.mjs
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.

Check yourself

A call returns 429 and res.headers.get('x-api-remaining-credits') returns null. What does that most likely mean on APIVerve?

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales