TestingAPIs is still being built. If something breaks, email support@testingapis.com.

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:

Terminal
$ node --version
v24.14.1

$ cd resilience
$ npm test
...
ℹ tests 33
ℹ pass 33
ℹ fail 0
ℹ duration_ms 30901

The 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:

Diagram
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 failsExampleWhat your code gets
Looking up the name (DNS)A typo in the hostname, or DNS is downAn error, no response at all
ConnectingNothing is listening on that address, or a firewall blocks itAn error, no response at all
WaitingThe server is overloaded, or stuck waiting on its databaseNothing, for as long as you keep waiting
In the proxyThe application behind it crashed or restartedA 502, 503 or 504 written by the proxy, not the API
In the applicationA bug, a full disk, a dependency that is downA 5xx status from the API
In your requestA missing field, a wrong ID, no credentialsA 4xx status
Sending the responseThe connection breaks halfway through the bodyA 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:

Terminal
$ 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 ms

Taking 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 fetch doesn't throw for it. To fetch, 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; with failRate=0.3 it 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=429 to 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, fetch would 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. fetch returned a response normally; the error only appeared when the code read the body. UND_ERR_SOCKET is the code from undici, the library inside Node's fetch, for a connection that closed early. Against a copy of the API on your own machine the code is ECONNRESET instead; the message, terminated, is the same either way.
  • fetch failed with ENOTFOUND means DNS found no such host. The .invalid ending is reserved for names that must never exist (RFC 2606), so this one fails everywhere. A refused connection gives the same message with ECONNREFUSED. The real reason is always in error.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:

Terminal
$ 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 termination
This example uses endpoints for signed-in members.Sign inCreate a free account

The 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.

FailureUsuallyWhy
400, 401, 403, 404, 422PermanentSomething about the request is wrong; the same request fails the same way
429TransientYou'll be allowed again after a wait
500, 502, 503, 504TransientOverload, restarts and crashes tend to pass
501 Not ImplementedPermanentThe server doesn't support this at all
TimeoutTransientThe next request may be faster
Connection dropped, body cut offTransientNetworks and servers recover
DNS: host not foundIt dependsA 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?

FailureDid the server act?
DNS failure, connection refusedNo. The request never reached it.
4xxUsually not. The server rejected the request, normally before doing any work.
429No. The server refused it because of the limit.
5xxUnknown. The error might have happened before the work, or after it.
TimeoutUnknown. You stopped waiting; the server may have finished a moment later.
Connection droppedUnknown, for the same reason.
Body cut offYes, 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:

src/classify.mjs
// 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/failures.test.mjs
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");
});
This example uses endpoints for signed-in members.Sign inCreate a free account

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. 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. 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. 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. 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.

Practise and go further