HTTP status codes that actually matter when consuming APIs
You do not need all sixty. You need to know which codes mean retry, which mean fix your request, and which mean stop. Here is the working subset.
The HTTP specification defines around sixty status codes. Consuming APIs requires roughly a dozen. The rest are rare, protocol-specific, or handled by the browser before your code ever sees them.
Short answer
Group them by what you do next. 2xx: continue. 3xx: your client usually handles it. 4xx: fix the request, do not retry. 5xx: the server failed, retry with backoff. The three exceptions worth memorising are 408, 429 and 304, which are all about timing or caching rather than correctness.
The ones you will actually meet
200 OK
The request succeeded and there is a body. The default case.
201 Created
A POST or PUT created something. Well-built APIs include a Location header pointing at the new resource, which saves you constructing the URL yourself.
204 No Content
Success, deliberately empty body. Common for DELETE and for PUT updates that return nothing.
// This throws: JSON.parse('') is invalid.
const data = await response.json();
// Check first.
const data = response.status === 204 ? null : await response.json();304 Not Modified
Your cached copy is still current, so the server sent no body. This only appears if you sent If-None-Match or If-Modified-Since, and it is how conditional requests save bandwidth.
const response = await fetch(url, {
headers: { 'If-None-Match': storedEtag },
});
if (response.status === 304) {
return cachedValue; // nothing changed
}Note that response.ok is false for a 304, because ok only covers 200 to 299. If you use conditional requests, handle 304 before your error check or you will treat a cache hit as a failure.
400 Bad Request
Your request was malformed. A missing required field, a badly formatted date, a value out of range. Retrying changes nothing. Read the response body, which usually names the offending field.
401 Unauthorized
Authentication failed or was not supplied. Despite the name, this is about identity rather than permission. Check the WWW-Authenticate header, which normally states the reason.
403 Forbidden
Authenticated but not permitted. Wrong scope, insufficient plan, or a resource that is not yours. Regenerating your key will not help, because your key is not the problem.
404 Not Found
The resource does not exist. Sometimes it means the URL is wrong; sometimes it is deliberately used instead of 403 so that merely probing cannot confirm a resource exists.
408 Request Timeout
The server waited for your request and gave up. Unlike other 4xx codes, this is worth retrying.
409 Conflict
The request conflicts with current state. A duplicate unique field, or an edit against a version that has since changed. Requires a decision, not a retry.
422 Unprocessable Content
Well-formed syntax, semantically invalid. An email field containing something that is not an email. Treat it like a 400.
429 Too Many Requests
Rate limited. Read Retry-After and wait exactly that long.
500, 502, 503, 504
The server failed. Retry idempotent requests with exponential backoff and a small cap.
The grouping that matters
| Range | Meaning | Your move |
|---|---|---|
| 2xx | Success | Continue |
| 3xx | Redirect or cache | Usually handled for you |
| 4xx | You sent something wrong | Fix it; do not retry |
| 5xx | The server failed | Retry with backoff |
The two exceptions to the 4xx rule are 408 and 429. Both describe timing rather than a defect in your request, and both are retryable.
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
function shouldRetry(status: number): boolean {
return RETRYABLE.has(status);
}Encoding it as an explicit set is better than a range check, because status >= 500 misses 429 and status >= 400 retries things that will never succeed.
Codes that are commonly misused
Standards describe intent; real APIs vary. Expect these.
500 for bad input. Some APIs return 500 where a 400 belongs, because an unhandled exception escaped. If a 500 is perfectly reproducible with your request but a simpler request works, you found the input that breaks them.
200 with an error body.
{ "success": false, "error": "Invalid parameters" }The status says success and the body says otherwise. You have to check both:
const data = await response.json();
if (!response.ok || data.success === false) {
throw new Error(data.error ?? `HTTP ${response.status}`);
}403 for an invalid key. Technically should be 401. Common enough that if a 403 appears on an endpoint you expect access to, test the key against a known-public endpoint before assuming it is a permissions problem.
302 to a login page. Instead of returning 401, the server redirects. fetch follows redirects by default, so you end up parsing HTML and getting a JSON syntax error. Sending Accept: application/json often persuades the server to return a proper status.
Checking status without over-checking
A compact helper covering the cases above:
export async function apiRequest<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: { Accept: 'application/json', ...init?.headers },
signal: AbortSignal.timeout(8000),
});
if (response.status === 204) return null as T;
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('json')) {
const preview = (await response.text()).slice(0, 200);
throw new ApiError(response.status, `Expected JSON, got ${contentType}: ${preview}`);
}
const body = await response.json();
if (!response.ok) {
throw new ApiError(response.status, body?.message ?? body?.error ?? response.statusText);
}
return body as T;
}
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(`HTTP ${status}: ${message}`);
this.name = 'ApiError';
}
}Carrying the status on the error is what lets calling code decide policy:
try {
const data = await apiRequest('/items');
} catch (error) {
if (error instanceof ApiError && shouldRetry(error.status)) {
// retry path
} else {
// surface to the user
}
}Codes that get misused, and how to cope
Status codes are a shared vocabulary that not everyone speaks correctly. A handful of misuses are common enough that your client should expect them.
200 with an error inside. The response says success and the body says {"error": true}. This is widespread in older APIs and in anything that grew out of a form-handling backend. It means response.ok is not sufficient on its own, and the only defence is knowing the specific API's convention — which is a good reason to read one real error response before writing the handler.
404 for "empty". A search with no matches should return 200 with an empty array, because the endpoint exists and the answer is "none". Plenty of APIs return 404 instead, which forces your client to treat "no results" and "wrong URL" identically. Where you meet it, translate at the boundary so the rest of your code sees an empty list.
403 where 401 belongs, and the reverse. The distinction is real and frequently muddled, which matters because the correct response differs: 401 means re-authenticate, 403 means stop. When an API is inconsistent here, the body usually disambiguates even when the code does not. The distinction in full.
200 for a rate limit. Rare and genuinely unpleasant — the request is rejected and the response looks successful, with the refusal in the body. Only found by reading responses under load.
500 for a client mistake. An unhandled exception in the provider's validation produces a 500 for what should have been a 400. The signal is a 500 that reproduces reliably with the same input: real server errors are usually intermittent, so a deterministic one points at your payload rather than at their infrastructure.
The general lesson is to trust the status code as the primary signal and verify against the body where the stakes justify it. Writing a client that handles only the correct usages will work with well-built APIs and fail confusingly with the rest.
Which codes justify a retry
The most consequential decision your error handling makes, and it follows directly from the classes.
Retry on 429, 502, 503 and 504. Each of these is explicitly or effectively temporary: too fast, a bad gateway, an unavailable service, a timeout upstream. All will plausibly succeed on a second attempt, and 429 and 503 often carry Retry-After telling you when.
Do not retry on the 400s other than 429. A malformed request will be malformed again. Retrying a 400 or a 422 achieves nothing and wastes quota; retrying a 401 repeatedly can trigger account lockout.
500 is the judgement call. It can be a transient fault or a deterministic bug triggered by your input. One or two retries with backoff is reasonable; a long retry chain against a consistent 500 is just load. If the same request produces a 500 every time, stop and report it.
404 deserves a thought. Normally permanent, so no retry. The exception is a resource you have just created — some systems are eventually consistent, and a read immediately after a write can genuinely 404 for a moment. That is the one case where a short retry is correct.
Whatever the policy, retries need backoff with jitter and a cap. That reasoning is covered in HTTP 429 and backoff and handling timeouts and retries, and the summary is that retries without those two properties make outages worse rather than better.
Mapping codes to something a user can act on
The last step, and the one that determines whether your error handling is useful or merely present.
A status code is for your code. A person needs to know what happened and what to do, and the two rarely correspond directly. Showing "Error 403" helps nobody; neither does "Something went wrong", which is technically true and entirely useless.
The translation worth making is from code to the user's next action. A 401 means their session expired and they should sign in again. A 403 means they do not have access and should ask whoever administers it. A 404 means the thing is gone and they should go back. A 429 means wait a moment. A 5xx means it is not their fault and they should try shortly.
Two things make these messages genuinely useful rather than decorative. Include a way forward — a sign-in link, a retry button, a contact route — because a message with no action is a dead end. And where you can, include a correlation identifier that support can look up, which turns "it broke" into something traceable.
Keep the raw code in your logs, always. The user does not need it and you will, and the pairing of a human message on screen with a precise code in the log is what makes an error report answerable.
What we see in practice
This is why our listings record the status alongside the redirect chain. A 403 from a working API and a 403 from a domain that has been parked look identical in a log and mean completely different things.
Common questions
How many HTTP status codes do I actually need to know?
About a dozen for consuming APIs. The rest are either rare, handled by the browser before your code sees them, or specific to protocols like WebDAV that you will not encounter in a normal REST integration.
What is the difference between 401 and 403?
401 means the API does not know who you are, because credentials were missing or invalid. 403 means it knows and is refusing anyway, because of permissions, scope or plan.
Should I retry a 404?
No. A 404 means the resource does not exist, and it will not exist on the second attempt either. The only codes normally worth retrying are 408, 429 and the 5xx family.
What does a 204 response contain?
Nothing. 204 No Content means the request succeeded and there is deliberately no body. Calling response.json() on it throws, because parsing an empty string is invalid JSON.
Why do some APIs return 200 for errors?
Usually legacy design, or a framework that wraps everything in a success envelope with an error field inside. It is poor practice because it defeats standard tooling, but you have to handle it: check the body as well as the status.
Sources
Written by
SandyI build and run this site on my own: the crawler that assembles the catalogue, the checker that probes every listing, and the writing. Before this I built SaveFromInternet and GrabReels, which meant living with other people’s APIs full time — parsers breaking when a platform shipped a change, rate limits arriving without warning, endpoints disappearing overnight. This directory exists because I got tired of free API lists that had never been checked.