What can go wrong when you call an API
Every kind of failure, caused on purpose, and the one question that decides how to handle each.
When your code calls an API, the request leaves your machine, crosses networks you don't control, and is handled by software someone else runs. Most of the time all of that works. This tutorial is about the rest of the time. A resilientclient is one that keeps behaving sensibly when the API doesn't: it recovers from problems that fix themselves, and fails quickly and clearly from the ones that don't. Before you can handle failures, you need to know what they are and what each looks like from your code, so that's where we start.
Running the examples
Every lesson uses code from one small project, written in plain JavaScript with no libraries. You need Node.js 22 or later. Download the project, unzip it, and run the tests:
$ node --version
v24.14.1
$ cd resilience
$ npm test
...
ℹ tests 33
ℹ pass 33
ℹ fail 0
ℹ duration_ms 30901The tests call the live API at api.testingapis.com, which has endpoints that fail on purpose: they return errors, hang, drop the connection or cut a response short whenever you ask. That's what makes the tutorial work. You can cause each failure yourself, as many times as you like, and watch your code handle it. You'll need to be comfortable with async functions, await and fetch; nothing else is assumed.
The path a request takes
A single API call goes through several steps, and each one is a place where it can fail:
your code
│
│ 1. DNS: look up api.testingapis.com and get an IP address
│ 2. TCP: open a connection to that address
│ 3. TLS: agree on encryption and check the certificate
â–¼
proxy or load balancer receives the request first and passes it on
│
â–¼
the API's application ───► its database and other services
│
â–¼
the response travels back the same way, and your code reads it| Where it fails | Example | What your code gets |
|---|---|---|
| Looking up the name (DNS) | A typo in the hostname, or DNS is down | An error, no response at all |
| Connecting | Nothing is listening on that address, or a firewall blocks it | An error, no response at all |
| Waiting | The server is overloaded, or stuck waiting on its database | Nothing, for as long as you keep waiting |
| In the proxy | The application behind it crashed or restarted | A 502, 503 or 504 written by the proxy, not the API |
| In the application | A bug, a full disk, a dependency that is down | A 5xx status from the API |
| In your request | A missing field, a wrong ID, no credentials | A 4xx status |
| Sending the response | The connection breaks halfway through the body | A status and headers, then an error while reading |
Each failure, caused on purpose
The script demos/failures.mjs causes seven failures, one after another, and prints what Node's fetch handed back for each, the name our code gives it, and how long it took:
$ node demos/failures.mjs
404 for a missing product HTTP 404 client-error 242 ms
503 from a failing server HTTP 503 server-error 134 ms
429 rate limited (simulated) HTTP 429 rate-limited 40 ms
server hangs, we wait 2 s TimeoutError: The operation was aborted due to timeout timeout 2013 ms
body cut off halfway TypeError: terminated (UND_ERR_SOCKET) truncated 1246 ms
host does not exist TypeError: fetch failed (ENOTFOUND) network 59 ms
connection dropped by the server HTTP 503 server-error 611 msTaking the rows one at a time:
- 404 is a client error: the 4xx family means the problem is in the request. Here the product doesn't exist. Note that
fetchdoesn't throw for it. Tofetch, any response is a success; it only throws when there is no usable response at all. - 503 is a server error: the 5xx family means the request was fine but the server couldn't handle it.
/api/chaos/flaky?failRate=1sign in to see fails every time; withfailRate=0.3it fails about three times in ten, at random. - 429means "too many requests". The server is refusing you because you've sent more than it allows. Adding
?error=429to any URL on this API makes it answer with that status, without counting anything; lesson 5 uses the real rate limiter. - TimeoutError came from our own code. The server was told to hang for 10 seconds, and we only agreed to wait 2. Without that limit,
fetchwould have waited the full 10. Lesson 2 is about this. - terminated is the strangest one. The server sent a 200 status and promised 10,000 bytes, sent 64 bytes, then closed the connection.
fetchreturned a response normally; the error only appeared when the code read the body.UND_ERR_SOCKETis the code from undici, the library inside Node'sfetch, for a connection that closed early. Against a copy of the API on your own machine the code isECONNRESETinstead; the message,terminated, is the same either way. - fetch failed with
ENOTFOUNDmeans DNS found no such host. The.invalidending is reserved for names that must never exist (RFC 2606), so this one fails everywhere. A refused connection gives the same message withECONNREFUSED. The real reason is always inerror.cause. - The dropped connection came back as a 503, not an error. That needs its own section.
What's between you and the server changes what you see
/api/chaos/abortsign in to seecloses the connection without answering. If you ran the API on your own machine and called it directly, you'd get fetch failed. Through the public address you get this:
$ curl -i https://api.testingapis.com/api/chaos/abort
HTTP/1.1 503 Service Unavailable
content-length: 118
content-type: text/plain
upstream connect error or disconnect/reset before headers. retried and the latest reset reason: connection terminationThe API runs behind a proxy, a server that receives every request first and forwards it to the application. When the application dropped the connection, the proxy didn't pass the silence on. It wrote its own 503, in plain text, not the JSON the API always sends. Its message also says retried: the proxy sent the request to the application a again on its own before giving up. Keep that in mind for lesson 3; your request may be retried by machinery you never configured.
Two practical rules follow. First, handle error statuses whose body isn't the API's usual format: calling response.json() on that 503 throws a parse error that hides the real problem. Check the status, and the Content-Type, before parsing. Second, test against the same path your users take. A test that calls the application directly will never see the proxy's behavior.
Transient or permanent
The first question to ask about any failure is whether it might go away by itself. A transient failure is temporary: the server was overloaded for a second, a deploy restarted it, a network link dropped some packets. Sending the same request again a moment later will probably work. A permanentfailure happens every time: the product doesn't exist, the token has expired, the body is missing a field. Sending it again gets the same answer, so the only fix is to change the request.
| Failure | Usually | Why |
|---|---|---|
| 400, 401, 403, 404, 422 | Permanent | Something about the request is wrong; the same request fails the same way |
| 429 | Transient | You'll be allowed again after a wait |
| 500, 502, 503, 504 | Transient | Overload, restarts and crashes tend to pass |
| 501 Not Implemented | Permanent | The server doesn't support this at all |
| Timeout | Transient | The next request may be faster |
| Connection dropped, body cut off | Transient | Networks and servers recover |
| DNS: host not found | It depends | A DNS outage passes; a typo in the hostname never does |
The last row is a reminder that the table describes the usual case. Your code can only see the kind of failure, not its cause, so it has to make a reasonable guess and limit the damage when the guess is wrong. That's why retries always come with a maximum, as you'll see in lesson 3.
Did the server act on the request?
The second question matters as soon as a request changes something, such as creating an order or charging a card. When it fails, did the change happen or not?
| Failure | Did the server act? |
|---|---|
| DNS failure, connection refused | No. The request never reached it. |
| 4xx | Usually not. The server rejected the request, normally before doing any work. |
| 429 | No. The server refused it because of the limit. |
| 5xx | Unknown. The error might have happened before the work, or after it. |
| Timeout | Unknown. You stopped waiting; the server may have finished a moment later. |
| Connection dropped | Unknown, for the same reason. |
| Body cut off | Yes, almost certainly. The server had finished and was sending the result. |
Look at how many rows say unknown. If a request to create an order times out, the order may exist, and sending the request again could create a second one. This one problem shapes most of the tutorial: lesson 3 decides which requests are safe to repeat, and lesson 6 shows how APIs make even a payment safe to repeat.
Naming failures in code
To decide what to do about a failure, code first needs to know which kind it is. src/classify.mjs takes what fetch returned or threw and gives it one of the names from the demo:
// Names what went wrong with an HTTP call, so the code around it can decide what to do next.
// Pass the response (anything with a numeric `status`, such as a fetch Response), or the error that fetch,
// or reading the body, threw.
export function classify(outcome) {
if (typeof outcome?.status === "number") {
if (outcome.status >= 200 && outcome.status <= 299) return "success";
if (outcome.status === 429) return "rate-limited";
if (outcome.status >= 500) return "server-error";
return "client-error";
}
if (outcome?.name === "TimeoutError") return "timeout"; // our own deadline passed
if (outcome?.name === "AbortError") return "cancelled"; // our own code called abort()
if (outcome?.message === "terminated") return "truncated"; // the body stopped arriving partway through
if (outcome?.message === "fetch failed") return "network"; // no response at all: DNS, refused, reset
return "unknown";
}A response is anything with a numeric status, so the function works with a fetch Response and with the plain objects later lessons build. For errors, it looks at name and message, because those are what Node's fetch sets: TimeoutError and AbortError for the two ways a request can be stopped on purpose, terminated for a body that stopped arriving, and fetch failed for everything that happened before a response existed.
Each row of the demo is also a test in test/failures.test.mjs. Here is the one for the cut-off body:
test("a truncated body: the response starts, then stops", async () => {
const outcome = await outcomeOf(`${BASE_URL}/api/chaos/partial`);
assert.equal(outcome.message, "terminated");
assert.equal(classify(outcome), "truncated");
});The helper outcomeOf sends the request, reads the whole body, and returns the response or the error, whichever happened. Reading the body is the important part. Leave it out and this test gets a perfectly normal 200 response, because the failure only shows up while the body arrives.
Check your understanding
1.A request to create an order times out. Does the order exist?Show answer
You can't tell. The server may have finished the work just after you stopped waiting. That's why you can't simply send the request again, and why lesson 6 exists.
2.Which of these are transient: 404, 503, 401, 504?Show answer
503 and 504. A 404 or 401 will come back the same way until the request changes: a different ID, or valid credentials.
3.Why did /api/chaos/abort return a 503 instead of a network error?Show answer
A proxy sits between you and the application. It saw the connection drop and answered with its own 503, after retrying it. Called directly, without the proxy, the same endpoint gives a network error.
4.Does fetch throw when the server returns a 500?Show answer
No.
fetchresolves with the response for any status, 500 included. It only throws when there's no usable response: DNS failures, refused or dropped connections, a timeout, or a body that stops arriving.
Exercise
Run node demos/failures.mjs and compare your output with the one above; the timings will differ. Then add two cases to the script: ?error=504 on any endpoint, and http://127.0.0.1:59999/, a port where nothing is listening on your machine. Predict the status or error and the classification for each before you run it.