REST API Error Handling Best Practices: Status Codes, RFC 9457, and Retryable Errors

Master API error handling for REST: pick the right status codes, return RFC 9457 problem details, mark retryable errors, and test every failure with Apidog.

INEZA Felin-Michel

INEZA Felin-Michel

31 August 2026

REST API Error Handling Best Practices: Status Codes, RFC 9457, and Retryable Errors

Apidog for Enterprise

On-Premises Deploy

SSO & RBAC

SOC 2 Compliant

Explore Apidog Enterprise

Your API’s error responses are part of its contract. Clients parse them, retry logic branches on them, and support engineers grep for them at 2 a.m. Yet most teams design the happy path in detail and let errors fall out of whatever the framework does by default. That’s how you end up with three different error shapes in one API, a 200 response wrapping "success": false, and a stack trace leaking your database schema to the public internet.

This guide covers API error handling best practices for REST services end to end: choosing the right status code, standardizing on one error body with RFC 9457 Problem Details, separating machine-readable codes from human messages, marking errors retryable, and keeping secrets out of responses. It builds on our breakdown of which HTTP status codes REST APIs should use and adds the contract-level decisions that guide leaves open. You’ll also see how to test every failure path in Apidog, because an error contract you never test is a contract you don’t have.

Start with the status code, not the body

HTTP already gives you a first layer of error semantics for free. RFC 9110 defines the status code families: 4xx means the client did something wrong and repeating the same request will fail again; 5xx means the server failed and the client’s request may have been fine. Get this split right before you write a single line of error body, because generic clients, proxies, caches, and retry libraries all branch on it without ever reading your JSON.

The most common mistakes cluster around a handful of lookalike pairs. Keep MDN’s HTTP status code reference open while you design, and use this decision table for the codes that trip teams up.

Situation Use Not Why
Malformed request: broken JSON, wrong content type, missing required field 400 Bad Request 422 The server can’t parse or understand the request at all
Well-formed request that violates semantic rules: amount is negative, currency unsupported 422 Unprocessable Content 400 Syntax is fine; the values are not
No credentials, or expired/invalid token 401 Unauthorized 403 The client hasn’t proven who it is. Send WWW-Authenticate
Valid credentials, insufficient permissions 403 Forbidden 401 Identity is known; access is denied. Re-authenticating won’t help
Resource never existed, or you won’t confirm it exists 404 Not Found 410 Safe default; also hides resources from unauthorized probing
Resource existed and was deliberately, permanently removed 410 Gone 404 Tells clients and crawlers to delete their references
State conflict: duplicate key, stale version, edit collision 409 Conflict 400 The request is valid but clashes with current resource state
Client exceeded a rate limit 429 Too Many Requests 503 Always include Retry-After so clients back off correctly
Unhandled exception in your code 500 Internal Server Error 502 Your server broke
Upstream service returned garbage to your gateway 502 Bad Gateway 500 The failure is downstream of the edge, not in it
Server is overloaded or in maintenance 503 Service Unavailable 500 Temporary by definition; add Retry-After when you can
Upstream service timed out 504 Gateway Timeout 500 Distinguishes “slow dependency” from “broken code”

Two of these deserve extra emphasis. First, 401 vs 403 is a security boundary, not a style choice: returning 403 to an unauthenticated caller leaks the fact that the resource exists. Second, 429 without Retry-After trains clients to hammer you in tight loops. If you rate limit, and you should, pair the status with a concrete backoff signal; our guide on API rate limiting covers the header math and the algorithms behind it.

One error body shape: RFC 9457 Problem Details

Once the status code is right, every error your API returns should share one media type and one schema. The standard answer is RFC 9457 Problem Details, served as application/problem+json. It defines five core members: type (a URI identifying the error category), title (a short human summary), status (the HTTP code, repeated for convenience), detail (what went wrong in this occurrence), and instance (a URI for this specific failure). Anything else goes in extension members you define yourself.

We won’t re-derive the spec here; our RFC 9457 explainer walks through every member, the registry rules, and how it supersedes RFC 7807. What matters for your contract is the pattern: standard envelope, custom extensions. Here’s a validation failure on a payments endpoint.

POST /v1/payments HTTP/1.1
Content-Type: application/json

{ "amount": -1400, "currency": "USD", "source": "card_8xKt2" }
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Request validation failed",
  "status": 422,
  "detail": "One or more fields failed validation.",
  "instance": "/v1/payments/requests/req_9f3c1a7b",
  "code": "PAYMENT_VALIDATION_FAILED",
  "errors": [
    {
      "field": "amount",
      "code": "AMOUNT_NOT_POSITIVE",
      "message": "amount must be a positive integer in minor units"
    }
  ],
  "request_id": "req_9f3c1a7b"
}

The errors[] array is an extension member, and it’s the one clients love most: it lets a frontend map each failure to the exact form field instead of showing one vague banner. Keep field paths in a stable format (JSON Pointer or dotted paths, pick one) so client code can bind them programmatically.

One rule saves you the most pain: return this shape for every error, including the ones your framework or gateway generates. A client that gets Problem Details from your handlers but HTML from your load balancer’s 502 page still has to write two parsers.

Machine-readable codes vs human messages

Notice the example carries both code and message fields. That’s deliberate. They serve different audiences and should never be collapsed into one string.

Machine-readable codes (AMOUNT_NOT_POSITIVE, CURRENCY_UNSUPPORTED, IDEMPOTENCY_KEY_REUSED) are contract. Clients branch on them, so they must be stable, documented, and enumerable. Never make clients parse prose; the moment someone writes if (message.includes("positive")), your copy edit becomes a breaking change.

Human messages are the opposite: free to improve at any time, written for a developer reading logs, and never load-bearing. State what failed and what fixing it looks like: “amount must be a positive integer in minor units” beats “invalid amount”. If you localize, localize the message and leave the code alone.

This split matters even more now that API consumers include autonomous agents. LLM-based clients recover far better from structured, self-describing errors; we cover that angle in API error design for AI agents.

What never goes in an error response

Error responses are a favorite reconnaissance channel for attackers, because unhandled failures tend to be verbose. Your error middleware should guarantee that none of the following ever reaches a client:

The pattern is simple: catch everything at the boundary, log the full exception server-side with a request ID, and return a generic Problem Details body with that same ID. The client gets "detail": "An internal error occurred", "request_id": "req_51ad0", your logs get the truth, and support can join the two.

Mark errors retryable or terminal

Every error you return answers a question the client is about to ask: should I try this again? Bake the answer into the contract instead of leaving each client team to guess.

Status codes carry the default semantics. 429, 502, 503, and 504 are retryable with exponential backoff and jitter. 500 is ambiguous but usually worth one cautious retry. Nearly all other 4xx codes are terminal: retrying a 401, 403, 404, or 422 with the same request wastes quota and pollutes logs. Timeouts deserve their own care, since the request may have succeeded after the client gave up; that’s the classic 408 request timeout problem, and it’s why mutating endpoints should accept idempotency keys so a retried payment can’t charge twice.

You can also make retryability explicit with an extension member:

{
  "type": "https://api.example.com/problems/rate-limited",
  "title": "Too many requests",
  "status": 429,
  "code": "RATE_LIMITED",
  "retryable": true,
  "retry_after_seconds": 30
}

An explicit retryable flag lets you override the defaults when you need to, like flagging a specific 500 subcode as terminal because retrying it corrupts state. Document the flag once and every client SDK you ship gets uniform backoff behavior.

Correlation IDs and error contract versioning

Two smaller decisions round out the contract, and both are cheap now, expensive later.

Give every request an ID. Accept an inbound X-Request-Id header (or generate one), stamp it on every log line, and echo it in every error body as request_id. When a customer pastes an error into a support ticket, that one field turns an hour of log spelunking into a single query. In distributed setups, propagate a W3C traceparent alongside it so the ID follows the request across services.

Version your error contract like the API itself. Adding a new extension member or a new error code is safe. Renaming errors[].field, changing a code’s meaning, or moving from an ad-hoc shape to Problem Details is breaking, and it breaks the code paths teams test least. The type URI gives you a clean mechanism: keep old type URIs stable forever, introduce new ones for new semantics, and state in your docs that unknown extension members and unknown codes must be ignored, not treated as failures. That forward-compatibility clause is what lets you evolve without a v2.

Test every error path in Apidog

Here’s the uncomfortable truth: error contracts rot because nothing exercises them. The happy path runs in every demo; the 422 branch runs when a customer hits it. The fix is making failure cases first-class citizens in your test suite, and this is where Apidog earns its place in the workflow.

Two features map directly onto this problem.

Test scenarios for the server side. For each endpoint, build a scenario per failure case: missing auth expects 401, insufficient role expects 403, negative amount expects 422 with errors[0].code equal to AMOUNT_NOT_POSITIVE, burst traffic expects 429 with a Retry-After header. Apidog’s visual assertions check status, headers, and body fields without scripting, and you can validate the whole payload against your Problem Details JSON Schema so any drift in the error shape fails CI, not production. Our API assertions guide shows the assertion patterns in detail.

Mock servers for the client side. Your frontend and SDK teams need to build against 4xx and 5xx responses before the backend can produce them on demand. Apidog mock servers return the exact Problem Details bodies from your API spec, so you can simulate a 503 with Retry-After: 120, a 409 on double submission, or a full errors[] validation payload, then watch how the client renders and retries. No hand-rolled Express stub, no commenting out backend code to force a failure.

Design the error contract, encode it as scenarios and mocks, and wire both into CI. Download Apidog and try it free; importing an existing OpenAPI spec gets you mockable error responses in a few minutes.

button

FAQ

Should I use 400 or 422 for validation errors?

Use 400 when the request is malformed and the server can’t understand it: invalid JSON, wrong content type, a missing required field. Use 422 when the request parses cleanly but the values break your domain rules, like a negative payment amount or an unsupported currency. The practical payoff is diagnostic: a 422 tells the client “fix your data,” while a 400 says “fix your request format.” Whichever split you choose, apply it consistently across every endpoint.

What is application/problem+json?

It’s the media type defined by RFC 9457 for Problem Details, the standard JSON error format for HTTP APIs. A response with this content type carries type, title, status, detail, and instance members, plus any extensions you define, such as an errors[] array for field-level validation failures. Using the registered media type lets generic clients and middleware recognize your errors without custom configuration. Our RFC 9457 explainer covers the full spec.

Which HTTP errors should clients retry automatically?

Retry 429, 502, 503, and 504 with exponential backoff plus jitter, honoring Retry-After when present. Treat 500 as worth one careful retry. Don’t retry other 4xx responses; the request will fail the same way every time. For mutating endpoints, pair retries with idempotency keys so a replayed request can’t double-charge or double-create.

How do I test API error responses without breaking my backend?

Simulate them. Point your client at an Apidog mock server that returns the exact 4xx and 5xx bodies from your spec, then verify rendering and retry behavior against each one. On the server side, write test scenarios that send invalid payloads, missing auth, and burst traffic, then assert on status codes, headers, and the error body schema. Both halves run in CI, so the error contract stays honest without anyone manually forcing failures.

Explore more

API Caching with ETag and Cache-Control: How Conditional Requests Cut Your Payloads

API Caching with ETag and Cache-Control: How Conditional Requests Cut Your Payloads

Learn how the Cache-Control header and ETag validation turn repeat API calls into 304 responses, prevent lost updates with If-Match, and cut payload size.

31 August 2026

REST API Naming Conventions: A Practical Style Guide

REST API Naming Conventions: A Practical Style Guide

Master REST API naming conventions with 10 concrete rules: plural nouns, kebab-case paths, JSON casing, versioning, and IDs. Do and don't examples included.

31 August 2026

How to Test OAuth 2.0 APIs in Apidog (Authorization Code, Client Credentials, and Token Refresh)

How to Test OAuth 2.0 APIs in Apidog (Authorization Code, Client Credentials, and Token Refresh)

Learn how to test OAuth 2.0 APIs in Apidog: authorization code flow with PKCE, client credentials, automatic token refresh, and 401/403 failure-path tests.

31 August 2026

Practice API Design-first in Apidog

Discover an easier way to build and use APIs

REST API Error Handling Best Practices: Status Codes, RFC 9457, and Retryable Errors