Topic 1.3
When the Request Fails
In one line
Every failure has a different cause and deserves a different message: a bad request, a missing key, a rate limit with a wait time, a broken server, a gateway page that is not JSON, or no connection at all. Read the error body safely, and never show the provider's raw words to a visitor.
Think of it like this
A parcel that cannot be delivered. The courier leaves a card, and the card says different things: "nobody home, we will try tomorrow", "address not found, please contact the sender", "too many parcels today, try again in an hour". You act differently on each. A card that just said "error" would be useless. Each HTTP failure is a different card, and your screen should read it and say the right thing.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- HTTP status
- A three-digit number in every response. 2xx is success, 4xx means the request was wrong, 5xx means the server failed.
- res.ok
- True for statuses 200 to 299.
fetchdoes not throw for 4xx and 5xx, so check it yourself. - Rate limit
- A cap on how many requests (or tokens) you may use in a period. Going over it earns a 429.
- Retry-After
- A response header saying how long to wait before trying again. Normally a number of seconds.
- Gateway
- A server in front of another one (a proxy, load balancer or CDN). It can fail with its own error page.
- TypeError (from fetch)
- What fetch rejects with when no response arrived: offline, blocked, DNS failure or dropped connection.
- Error body
- The content of a failed response. Usually JSON with a message, but it can be HTML or empty.
- Request id
- A short identifier you attach to a failure, log on the server and show to the visitor, so support can find it.
Step by step
01Two kinds of failure, two code paths
A response with a bad status and no response at all look different in code. Keep both in one try so the rest of your screen sees a single error object, but tell them apart inside.
02Map each status to words
The first live example sends six requests and the stand-in fails each one differently. Look at the two 429 lines: one has Retry-After: 7 and one has none. The message changes from "in 7 seconds" to "in a moment". If the header is missing, say less rather than invent a number.
The mapping function takes only the status and the wait time. It never looks at the provider's message. That is on purpose: the provider's text is for the log, not the screen.
03Survive a body that is not JSON
The second example fails three ways that are not tidy JSON: an HTML page from a gateway (502), a 500 with an empty body, and a request to a path nobody serves. The last one is what offline looks like to your code.
readErrorBody reads text first, then tries to parse it. The naive await res.json() on the 502 page throws a SyntaxError, which a careless catch would report as "you are offline". Wrong cause, wrong message.
async function readErrorBody(res) {
const text = await res.text().catch(() => ""); // reading can fail too
try {
return { kind: "json", value: JSON.parse(text) };
} catch {
return { kind: "text", value: text.slice(0, 40) }; // keep it short: this is for your log
}
}04Keep the details for the log
The third example has two routes that call the same failing provider. /api/leaky returns the provider's own words, a made-up key fragment and an organisation name included. /api/safe writes those to its log with an id and sends the browser a short error and the id.
In a real app the server log is a tool such as your host's log viewer. The visitor can quote err_1042, and you search for it.
05Offline is a hint, not a fact
navigator.onLine is false when the device knows it has no network, but true does not mean the internet works (a Wi-Fi network can be connected and dead). Use it to improve the message, never to decide whether to try: if the request fails, show the offline message, and if it works, it works.
Try it yourself
- 1
Add a status you have not handled
In the first example add
{ fail: { status: 404, message: "The model does not exist." } }to the script and change the loop to 7 iterations.A 404 reaches the last line of
messageForand shows "Something went wrong. Please try again." A 404 here would mean *you* named a model that does not exist, which is another configuration problem. Add a branch that treats it like a 401, and log it for yourself. - 2
Serve a 429 as plain text
In the second example add
serve("/api/busy", () => new Response("Slow down", { status: 429, headers: { "retry-after": "3" } }));andawait ask("/api/busy");.The output line is
/api/busy → status 429 | body is text "Slow down". The body is text, not JSON, soreadErrorBodyreturnstext, and the status (429) and theretry-afterheader (read it withres.headers.get("retry-after")) still tell you everything you need. The headers matter more than the body. - 3
Log the visitor's whole request by accident
In the third example, add
const raw = await req.clone().text();as the first line of/api/safe(clone beforeproviderFailure(req)reads the body: a body can be read only once, and cloning afterwards throws), then addrawat the end of theconsole.logline. The server log now ends with{"messages":[{"role":"user","content":"hi"}]}. Think about what the log holds on a busy day.Requests contain the visitor's whole question, and often personal details. Logs are read by many people and kept for a long time. Topic 9.7 covers what to keep and what never to log.
Code & diagrams
Not a real provider: the failures are scripted. The `body` column is what a provider says (for your log). The `shown` column is what the visitor reads. They are different on purpose.
Preview
Console
Nothing logged yet.Expected console output
400 | retry-after: null | body: "Invalid value for 'messages'." | shown: Mitra could not understand that. Try shorter or different words.
401 | retry-after: null | body: "Incorrect API key provided." | shown: Mitra is not set up correctly. The shop has been told.
429 | retry-after: 7 | body: "Rate limit reached." | shown: Mitra is busy. Try again in 7 seconds.
429 | retry-after: null | body: "Rate limit reached." | shown: Mitra is busy. Try again in a moment.
500 | retry-after: null | body: "The server had an error." | shown: Mitra had a problem on its side. Please try again.
503 | retry-after: null | body: "The engine is currently overloaded." | shown: Mitra had a problem on its side. Please try again.The route handlers here are a stand-in for a real network. The browser's own message for "no response" differs by browser (Chrome: "Failed to fetch"), so test for `TypeError`, never for the message text. An unserved path in this preview behaves like an offline phone. The `SyntaxError` wording ("Unexpected token '<'…") is the browser's own and differs between browsers, so never test for it. The last case matters too: a hotel or airport Wi-Fi sign-in page can answer your request with "200 OK" and HTML, so `res.ok` is true and parsing the success body still needs its own `try`.
Preview
Console
Nothing logged yet.Expected console output
/api/ok → ok: Orders ship in 1 to 2 working days.
/api/gateway-down → status 502 | body is text "<html><body><h1>502 Bad Gateway</h1></bo"
/api/empty-error → status 500 | body is text ""
/api/nowhere → no response | TypeError: Failed to fetch | instanceof TypeError: true
naive res.json() on the 502 page → SyntaxError: Unexpected token '<', "<html><bod"... is not valid JSON
captive page: res.ok = true
200 but not JSON → SyntaxError: Unexpected token '<', "<html><bod"... is not valid JSONNever log a real key, even on the server: log the status and a short reason, and scrub secrets. Here the made-up key shows what a careless pass-through hands to every visitor. Lines starting with (server log) are what only you would see.
Preview
Console
Nothing logged yet.Expected console output
/api/leaky → the visitor sees: Incorrect API key provided: sk-FAKE-1234abcd. Find your key at the provider dashboard (org: chai-and-co).
(server log) err_1042 401 Incorrect API key provided: sk-FAKE-1234abcd. Find your key at the provider dashboard (org: chai-and-co).
/api/safe → the visitor sees: Mitra is not available right now. (reference err_1042)Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Parse every error body as JSON
In the second example, replace const body = await readErrorBody(res); with const body = { kind: "json", value: await res.json() };.
Break #2
Pass the provider's error through
In the third example, change if (upstream.ok) return upstream; in /api/safe to return upstream;.
Myth vs fact
Myth
fetch rejects when the server returns an error.
Fact
It rejects only when no response arrives. A 400, 429 or 500 is a response, so fetch resolves and you must check res.ok.
Myth
Show the provider's message, it explains what went wrong.
Fact
It explains it to a developer, and can leak secrets and internals. Write your own messages and keep the provider's words in the log.
Myth
Every failure should be retried.
Fact
A 400 or 401 will fail again exactly the same way. Retrying makes sense for timeouts, 429 (after waiting) and most 5xx (Topic 1.4).
Myth
A TypeError from fetch always means the visitor is offline.
Fact
It means no response arrived: offline, but also a blocked request, a DNS failure, a bad certificate or your server being down. "Cannot reach Mitra" is true in all of them.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Do the mapping twice. The server turns whatever the provider said into a small set of your own codes (
rate_limited,bad_request,provider_error) and the status that fits. The browser turns those codes into words (and, later, translations). The browser then never depends on any provider's wording, so switching provider changes only the server. - ▸
Return the same error shape from every route:
{ error: { code, message, id } }. One shape means onereadErrorfunction in the browser, and the id gives support a handle. A route that sometimes returns plain text and sometimes JSON is how "unexpected token <" reaches users. - ▸
Count failures by status in your logs (Topic 13.5). A sudden rise in 401s means a key was rotated or revoked. A rise in 429s means you are near your quota. A rise in 502s that does not appear in the provider's status page often means your own gateway. These numbers tell you which phone call to make.
- ▸
A rate limit can mean either "too many requests" or "you are out of money". Some providers use 429 for both. If the message in the log says quota or billing, the visitor's wait time is meaningless: alert yourself, and show "Mitra is not available right now".
Remember this
- 1
The problem this solves: A model call fails more often than an ordinary API call. Providers rate-limit, overload and change. Your route, a gateway in front of it, the visitor's phone and the provider can each fail in their own way, and each leaves a different trail. This topic teaches you to read the trail, decide what happened, and say something true and useful, without leaking secrets and without crashing while reading a body you did not expect.
- 2
Failures arrive in two ways. Either
fetchreturns a response whose status is bad (res.okisfalse), orfetchitself rejects because there is no response at all. They need separate code paths. A 429 or 500 is a *response*:fetchdoes not throw, so you must checkres.okorres.status(Topic 0.4 showed this). Offline, DNS failure, a blocked request or a dropped connection are *rejections*, and in the browser they all look the same: aTypeError(Chrome says "Failed to fetch", Firefox "NetworkError when attempting to fetch resource.", Safari "Load failed"). The browser deliberately says no more, for the visitor's privacy. - 3
The status codes you will meet, and what they mean.
400: the request was malformed (bad JSON, missing field, a bad value). *Your* code or input is wrong, so retrying the same request will not help.401: the key is missing or wrong. This is *your* configuration problem: the visitor did nothing wrong and must never be asked to fix it.403: the key works but is not allowed to do this.404: a wrong URL, or a model name that does not exist.413: the request is too large.429: too many requests, or you ran out of quota; this one often carries a wait time.500: the provider broke.502,503,504: a gateway or the provider is overloaded, restarting or too slow. For 5xx a retry often works. - 4
**
429andRetry-After.** When the server limits you, it can tell you how long to wait in a response header calledRetry-After. Its value is normally a whole number of seconds (the standard also allows a date; if the value is not a number, ignore it and use your own backoff). Some providers also send their own headers about remaining quota, so read their documentation. Show the wait to the visitor when you have it ("Try again in 7 seconds") and use it for the retry (Topic 1.4). Topic 3.6 turns it into a countdown. - 5
Read the error body without ever throwing. A well-behaved provider sends JSON like
{ "error": { "message": …, "type": … } }(Recall from Topic 1.1: the broken requests there got exactly this shape back). But the response could also be an HTML page from a gateway ("502 Bad Gateway"), an empty body, or text. Callingres.json()on those throws aSyntaxError, and if that happens inside your error handling, you lose the real status. Read the body as text, try to parse it, and fall back to a short piece of text. The second live example does exactly this, and shows what the naive version does. - 6
Never show the provider's words to a visitor. The message can contain a fragment of your secret key, your organisation name, the internal model name, or a stack trace. Even harmless ones ("The engine is currently overloaded") confuse a shopper. The rule: details go to your logs (with an id), and the visitor gets a message *you* wrote for that failure, ideally with the id so support can find the log line. Do this on the server (map to your own error codes) and again in the browser (map codes to words). The third live example shows the difference.
- 7
What each failure deserves on screen. The words should say what happened in human terms and what to do next. *400*: "Mitra could not understand that. Try shorter or different words." *401/403*: "Mitra is not set up correctly. The shop has been told." (and tell yourself: alert the team). *429*: "Mitra is busy. Try again in 7 seconds." *5xx*: "Mitra had a problem on its side. Please try again." *Offline*: "Cannot reach Mitra. Check your connection." Notice that none of them contains a number, a stack trace or the word "API".
Explain it without notes
How do the two ways a fetch call can fail differ, and how does the code tell them apart?
A request returns 429 with Retry-After: 7. What should the code and the screen do?
Why must the error handler read a failed response body defensively?
Why should a visitor never see the provider's error message, and what do you show instead?
Practice
Write friendlyError(error) that takes { kind: "offline" | "status", status?, retryAfter? } and returns the message to show. Cover offline, 400, 401, 429 (with and without a wait), and a 5xx.
Make the second example's ask function return the failure as an object ({ ok: false, status, retryAfter, body }) instead of printing, so a screen could render it.
Trade-offs
- ↔
A short, generic message is safe and easy to translate, but less helpful than a specific one. Use specific messages where the visitor can act on them (wait, shorten, check the connection) and a generic one where they cannot.
- ↔
Mapping errors on both server and browser means two places to keep in step. It is worth it: the server mapping protects your secrets, and the browser mapping keeps your words in one place.
Done when you can
I can tell a failed response from a rejected fetch and handle each.
I can say what 400, 401, 429, 500 and 503 mean and which can be retried.
I can read
Retry-Afterand show a wait time.I can read an error body without it throwing.
I can write a message for each failure that never shows the provider's text.