Consuming a JSON API means dealing with whatever the server decided to send back, including the failures. A successful response is the easy case: an object with a known shape. The hard case is that error responses arrive in several different shapes, and some of them are not JSON at all. This page shows how to read a response safely, turn any error body into one predictable object, and react correctly to empty replies and rate limits.
What shapes do API errors actually come in?
There is no standard for the body of an error response. Four shapes cover most of what you will meet in the wild, and they come from different frameworks and different hand-written servers.
| Shape | Example body | Where it comes from |
|---|---|---|
| Flat message | { "message": "Not found" } | Hand-written servers, many REST frameworks |
| Nested error object | { "error": { "code": "E42", "message": "Invalid card" } } | APIs modelled on Google and GitHub |
| Error as a string | { "error": "Invalid card" } | Smaller APIs and older frameworks |
| List of problems | { "errors": [{ "code": "E1", "message": "Card number required" }] } | Form validation responses |
Writing if (data.error) { show(data.error) } works until you meet the second server. The robust approach is to normalise: pull the useful fields out of whichever shape arrived and produce one error object your UI can rely on.
How do you turn those shapes into one error object?
Collect the possible sources of a message, then take the first one that is a usable string. The optional chaining handles missing links in the chain, and the fallback keeps the status code in the message when the body says nothing useful.
// errors.js
export function normaliseError(status, payload) {
const candidates = [
payload?.message,
payload?.error?.message,
typeof payload?.error === 'string' ? payload.error : null,
payload?.errors?.[0]?.message,
payload?.detail,
];
const message = candidates.find((v) => typeof v === 'string' && v.length > 0)
?? `Request failed with status ${status}`;
const code = payload?.code
?? payload?.error?.code
?? payload?.errors?.[0]?.code
?? null;
return { status, code, message };
}
Run different bodies through the same function and the result always has the same three fields, which is exactly what a UI needs in order to branch on a status code or show a message.
// app.js
import { normaliseError } from './errors.js';
console.log(normaliseError(422, { error: { code: 'E42', message: 'Invalid card' } }));
// { status: 422, code: 'E42', message: 'Invalid card' }
console.log(normaliseError(404, { errors: [{ code: 'E1', message: 'Card not found' }] }));
// { status: 404, code: 'E1', message: 'Card not found' }
console.log(normaliseError(500, {}));
// { status: 500, code: null, message: 'Request failed with status 500' }
How do you turn a status code into a message a user can read?
Raw server messages are written for developers. They mention field names, database column names and occasionally internal identifiers, and pasting them into a toast for a customer is both unhelpful and a small information leak. Keep the technical detail in the console and map the status to a sentence written for the person using the app.
// messages.js
const BY_STATUS = {
400: 'That request did not look right. Please check the details.',
401: 'Please sign in and try again.',
403: 'Your account does not have access to this.',
404: 'We could not find what you were looking for.',
409: 'Someone else changed this first. Reload and try again.',
422: 'Some of the details you entered need fixing.',
429: 'Too many requests. Wait a moment and try again.',
500: 'Something went wrong on our side. We are looking into it.',
};
export function userMessage(error) {
return BY_STATUS[error.status] ?? 'Something went wrong. Please try again.';
}
One decision to make explicit: for a 422 from a validation endpoint, the server's per-field messages are usually more useful than a generic sentence, so you may want to show them next to the inputs rather than in one banner. For a 500, the opposite is true — show the friendly text and log the raw body.
What if the response is not JSON at all?
Proxies, load balancers and captive portals love to return an HTML page when something goes wrong. Calling response.json() on that page throws a syntax error, and the real cause — a 502 from a gateway — disappears behind it. Reading the body as text first and parsing it yourself keeps the diagnosis intact.
// http.js
export async function readJson(response) {
const text = await response.text();
if (text.trim() === '') return null;
try {
return JSON.parse(text);
} catch {
return { raw: text.slice(0, 200) };
}
}
Now a failed request always gives you something to show. The status code says what happened, and raw holds the first 200 characters of whatever the server sent, which is usually enough to identify a proxy error page.
How do you handle 204 and other empty responses?
A successful delete or update often answers with 204 No Content, which means the body is empty. Asking such a response for JSON throws, because there is nothing to parse.
// ✗ a 204 has no body, so asking for JSON throws a syntax error
try {
await fetch('/api/profile', { method: 'DELETE' }).then((r) => r.json());
} catch (error) {
console.log(error.constructor.name); // SyntaxError
}
// ✓ read the text and treat an empty string as no data
const reply = await fetch('/api/profile', { method: 'DELETE' });
console.log(reply.status); // 204
const body = await reply.text();
console.log(body === '' ? null : JSON.parse(body)); // null
Reading the body as text and parsing it only when it is not empty handles 204, 205 and any server that returns an empty 200, with one line of code and no special-casing of status numbers.
What should you do when you hit a rate limit?
A rate-limited request comes back as 429 Too Many Requests, usually with a Retry-After header saying how many seconds to wait. Retrying immediately makes things worse, and retrying without a cap can hammer the server forever. A short loop with a delay and a bounded number of attempts is the polite version.
// retry.js
import { readJson } from './http.js';
import { normaliseError } from './errors.js';
export async function fetchWithRetry(url, attempts = 3) {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const response = await fetch(url);
if (response.status === 429 && attempt < attempts) {
const seconds = Number(response.headers.get('retry-after') ?? 1);
console.log(`rate limited, waiting ${seconds}s`);
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
continue;
}
if (!response.ok) {
const payload = await readJson(response);
throw new Error(normaliseError(response.status, payload).message);
}
return response.json();
}
}
Two details make the difference between a useful retry and a harmful one. The delay should come from the server's Retry-After header rather than a guess, and the attempt count must be capped so a permanently broken endpoint stops after a few seconds instead of looping in the background.
What to remember about API error handling
- An HTTP error status does not reject the fetch promise. Check
response.okyourself. - Error bodies come in several shapes. Normalise them once, in one function, and let the rest of your code see a single error object.
- Show users a message mapped from the status code. Keep the raw server text in the console.
- Read the body as text before parsing it. A proxy error page should not turn into a syntax error.
- A 204 has no body. Treat an empty string as no data instead of asking for JSON.
- On a 429, wait for the number of seconds the
Retry-Afterheader gives you, and cap the number of attempts.
Try it yourself: Write a tiny server with three routes: one that returns { "message": "nope" } with a 403, one that returns a 500 with an HTML body, and one that returns 429 with Retry-After: 1 before switching to 200. Run normaliseError against the first two payloads by hand and check that you get the same three fields both times. Then call fetchWithRetry on the third route and confirm it waits once and then succeeds.