Skip to main content
All Posts
2026AI API vs Traditional API: What's Actually DifferentBuilding LLM-Backed APIs: A Practical Architecture GuideEmbedding APIs Explained: How They Work and When to Use ThemCost Optimization for LLM APIs: Caching, Routing, and Model SelectionLLM Rate Limiting: Token Buckets and Concurrency ControlStreaming LLM Responses: Patterns and PitfallsOpenAI API vs Anthropic API vs Gemini API: Production Trade-offsOpen vs Proprietary LLM APIs: Trade-offs for ProductionRAG APIs: Architecting Retrieval-Augmented Generation EndpointsVertical AI API Strategy: Building Domain-Specific AI APIsSmarter AI Traffic with Jev: How WSO2 AI Gateway Mediates LLM and MCP CallsAPI Analytics: The Metrics That Actually Matter (2026 Guide)How to Build a CI/CD Pipeline for APIsAPI Design Tools: Choosing the Right Stack for API-First TeamsAPI Gateway vs Load Balancer vs Reverse Proxy: Clearing the ConfusionAPI Management Platforms: Cloud vs. Self-Hosted (2026)API Management Tools: A Complete Buyer's Guide for 2026Cloud Native API Gateway: Architecture Guide for 2026Envoy vs NGINX: Which Proxy Fits Your API Gateway?Internal Developer Portal: What It Is and How to Build OneKong vs Tyk: Open Source API Gateway ShowdownKubernetes API Gateway: The 2026 Guide to Gateway APIMuleSoft vs WSO2: Enterprise API Management ComparedOpen Source API Gateways Compared: Kong, Tyk, Gravitee, WSO2What Is an API Gateway? Functions, Architecture & Use CasesAPI Architecture: Patterns, Principles & Modern ApproachesAPI Caching Strategies: Edge, Gateway, and Application LayersAPI Federation: Patterns for Multi-Team API ProgramsAPI-First Architecture: Why Design Before Code WinsAPI Gateway Architecture: How API Gateways Actually WorkAPI Security Architecture: Layered Defense for Modern APIsCircuit Breakers and Resilience Patterns for APIsAPI Architecture Diagrams: From L0 to Ln (Complete Guide)Backend-for-Frontend (BFF) Pattern: When and WhyEvent-Driven APIs: Webhooks, WebSockets, and SSEMicroservices vs Monolithic: Trade-offs and When to ChooseService Mesh vs API Gateway: When You Need Each (or Both)API Authentication Best Practices: OAuth, Keys, mTLS, JWTAPI Error Handling: Best Practices for REST APIsAPI Documentation Best Practices: Beyond Reference to Developer ExperienceAPI Rate Limiting: How It Works and Best PracticesAPI Pagination: Design Patterns & Best PracticesAPI Versioning Best Practices: Strategies for Long-Lived APIsGraphQL vs REST: Key Differences & When to Use EachHTTP Status Codes for API Design: A Practical GuideIdempotency Keys for APIs: The Complete GuideJWT vs OAuth: What's the Difference and When to Use EachOAuth 2.1: What's New and How It Differs from OAuth 2.0REST API Design: A Complete Guide for 2026What Is a REST API? A Plain-English GuideAI Agent API Integration: Patterns & Best PracticesEnterprise MCP: The Control Plane for AI AgentsMCP Gateway vs MCP Proxy: What's the Difference?MCP Governance: Controlling Third-Party MCP ToolsMCP Monitoring: Observability for MCP ServersMCP Security: Risks, Threats and ControlsMCP Server Security: 8 Best PracticesMCP Server Catalog: Building an Internal RegistryMCP Tool Poisoning: How It Works & How to Stop ItWhat Is an MCP Gateway? Features & BenefitsWhat Is an AI Gateway? Features, Benefits and How It WorksAI Gateway Comparison: Top Solutions in 2026AI Gateway Security: Guardrails for LLM TrafficAI Gateway vs API Gateway: Key DifferencesBest LiteLLM Alternatives in 2026AI Gateway Observability: Key Metrics, Logging, and Tracing for LLM TrafficLLM Cost Control: Strategies to Cut AI SpendLLM Routing: How It Works, Strategies, and Why It MattersPrompt Injection Defense: Techniques That Actually Work7 Billion Calls a Day: One WSO2 GatewayLLM Fallback: How to Build Resilient AI ApplicationsREST API to MCP Server: A Practical GuideAzure API Management vs AWS API Gateway: Key Differences and FeaturesMuleSoft vs AWS API Gateway: Key Differences and FeaturesMuleSoft vs Azure API Management: Key Differences and FeaturesApigee vs AWS API Gateway: Key Differences and FeaturesApigee vs Azure API Management: Key Differences and FeaturesApigee vs MuleSoft: Key Differences and FeaturesGravitee vs AWS API Gateway: Key Differences and FeaturesGravitee vs Azure API Management: Key Differences and FeaturesGravitee vs MuleSoft: Key Differences and FeaturesGravitee vs Apigee: Key Differences and FeaturesKong vs AWS API Gateway: Key Differences and FeaturesKong vs Azure API Management: Key Differences and FeaturesKong vs MuleSoft: Key Differences and FeaturesKong vs Apigee: Key Differences and FeaturesKong vs Gravitee: Key Differences and FeaturesTop 6 AWS API Gateway Alternatives of 2026Top 6 Azure API Management Alternatives of 2026Top 6 MuleSoft Alternatives of 2026Top 10 Apigee Alternatives of 2026Top 4 Gravitee Alternatives of 2026Top 6 Kong Alternatives of 2026

API Error Handling: Best Practices for REST APIs

· 17 min read
Technical Writer, WSO2

A client integrates your API, receives a 500 with an empty body, and has no idea whether to retry, fix the request, or open a support ticket. Repeat that across every endpoint and every team that consumes them, and inconsistent errors quietly become one of the most expensive parts of an API to maintain. Good error handling isn't about catching every conceivable failure. It's about returning errors that are consistent and specific enough for the client developer to act on programmatically instead of guessing.

This guide covers how to design API errors that hold up in production: which HTTP status codes to use, how to structure an error response body, what changes across REST, GraphQL, and gRPC, and the client-side patterns that turn a single failed request into a recoverable one rather than the start of a cascading outage.

What Is API Error Handling?​

API error handling is the set of conventions an API uses to detect, report, and respond to failures, so a client can understand what went wrong and what to do next. A well-handled error comes back with a specific status code, a structured, machine-readable body, and enough detail for the calling application to recover, retry, or show a useful message to its own users.

Use the Right HTTP Status Codes​

The status code is the first thing a client reads, often before it parses the body at all. Generic HTTP clients, proxies, and caches base their behavior on the code alone, so choosing the wrong one breaks more than just your own client library. A 500 for a validation failure your API already knows how to detect, or a 200 wrapping a failure inside the body, both push the real work of error handling onto the client. It has to open the body and guess what happened instead of trusting the status line.

Choosing status codes deserves a guide of its own (the IANA HTTP Status Code Registry lists the full range), but for error handling, a few distinctions matter most:

  • 4xx means there's a problem with the request, though not always one the client can fix just by resending it. Use 400 for malformed requests, 401 for missing or invalid credentials, 404 for a resource that doesn't exist, 409 for a conflict with the current state, 422 for a request that's well-formed but semantically invalid, and 429 for rate limiting. 403 is worth singling out: it means the server understood the request but the caller isn't allowed to make it. Retrying or reshaping the request won't help; only a change to the policy or the credentials will.
  • 5xx means the server has to fix it. These should be rarer than most APIs make them. A 500 signals that something unexpected happened. It isn't a catch-all for "some error occurred."
  • Reserve 429 for rate limiting and pair it with Retry-After. RFC 9110 defines the Retry-After response header, and RFC 6585 allows it to accompany 429 Too Many Requests. It's optional in the standard, but making "always send it" a design rule for your own API is a sensible choice.

The status code carries the general meaning. The response body carries the specifics, and that's where most of the design work happens.

Design a Consistent Error Response Structure​

An error shape that varies from endpoint to endpoint is one of the most common reasons client-side error handling turns into a pile of special cases. If /users returns {"error": "not found"} and /orders returns {"message": "Not Found", "code": 404}, every consumer of your API has to write two parsers.

The IETF addressed this in RFC 9457, Problem Details for HTTP APIs, which obsoletes the earlier RFC 7807. It exists because a status code on its own often can't tell a non-human consumer enough about an error to be useful. It defines two equivalent formats, a JSON one served as application/problem+json and an XML one served as application/problem+xml, so APIs don't each have to invent their own.

Here's a Problem Details response for a validation failure:

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
"type": "https://api.example.com/problems/invalid-request",
"title": "Your request is not valid.",
"status": 422,
"detail": "The 'quantity' field must be a positive integer.",
"instance": "/orders/req-8f21",
"errors": [
{ "detail": "must be a positive integer", "pointer": "#/quantity" }
]
}

What should be in an error response body​

RFC 9457's core members line up with what most error-handling guides arrive at independently:

  • type identifies the kind of problem and already serves as a stable, machine-readable identifier on its own.
  • title and detail are for humans: a short summary of the problem type and an explanation specific to this occurrence.
  • status mirrors the actual HTTP status code.
  • instance is a URI reference for this particular occurrence, and it can include a correlation ID that support can search the logs for.

Beyond these, some teams add a separate application-specific error code for the client SDK to switch on. That's useful when you want an identifier that's more stable than the human-readable detail text without minting a new type URI for every variation. Whatever fields you choose, documenting your error responses clearly is what makes the structure usable. A well-designed error shape that nobody wrote down just turns into tribal knowledge.

Be just as deliberate about what you leave out. RFC 9457 says so directly: error generators should avoid exposing implementation details such as stack traces through the HTTP interface, because doing so can leak sensitive information about the server. OWASP's API Security Top 10 makes the same point from the security side, recommending a strict schema for error responses precisely so that exception traces and other internal details never reach a client that might be an attacker.

Single error vs. multiple errors in one response​

A single validation failure fits neatly into detail. A form submission with three invalid fields doesn't, and returning only the first one forces the client into a slow loop of fixing one field per retry. RFC 9457's extension mechanism solves this. Define an errors array, as in the example above, that lists every individual failure with its own pointer, while the top-level type and title describe the overall class of problem. When the failures don't share a single type, the spec recommends returning the most relevant one rather than bundling unrelated problem types into one generic envelope.

Error Handling for Different API Architectures​

Error handling isn't the same across API styles, and treating it as though it were is a common source of bugs when one platform team manages several architectures.

REST​

REST relies on HTTP status codes as the main error signal, with a structured body (ideally shaped like RFC 9457) carrying the details, as described above.

GraphQL​

GraphQL works differently by design, and it helps to separate two kinds of failure.

A request error is a malformed or invalid query that never begins executing, and it typically gets a 4xx. According to GraphQL's guidance on serving over HTTP, when a request fails before execution, the data key must be absent, and with the modern application/graphql-response+json media type the server should respond with a 4xx or 5xx.

An execution error is different. Once execution has started, GraphQL can return partial data and partial errors in the same response. The spec places those errors in a dedicated errors array next to data at the top level of the response, rather than relying on the transport-level status code alone. If the response has a non-null data key, the server should use a 2xx status even though the body also contains errors, because HTTP has no status code for "partial success." Each error entry can include a message, a locations field showing where in the query the error occurred, a path identifying which field failed, and an extensions map for implementation-specific metadata.

How closely a given server follows this depends on its implementation and on the media type it negotiates with the client. Some older servers, or servers that only speak application/json, still return 2xx even for requests that fail outright. That's why monitoring GraphQL traffic by status code alone isn't reliable: a client or gateway has to inspect the response body, not just the status line.

gRPC​

gRPC uses neither HTTP status codes nor a JSON body by default. Every RPC returns a status made up of an integer status code and a string description, which the server chooses for that call and which the client library exposes in every supported language. When you need richer, structured error information than a plain message string, gRPC's error model (built on Protocol Buffers) lets you attach typed error details to the status instead of encoding them into the message text.

A platform that fronts more than one of these architectures needs each error path to speak its own native format correctly. Don't force REST-style JSON responses onto gRPC calls, or squeeze GraphQL errors into status codes the protocol was designed to work around.

Client-Side Error Handling Best Practices​

Server-side design only gets you halfway. A well-structured error response is wasted if the client either ignores it or retries blindly.

Read the error before deciding what to do​

The status code tells the client which broad category it's dealing with, and the error code in the body tells it which specific case. A client that treats every 4xx the same way ends up retrying requests that can never succeed, such as a 400 caused by malformed input, which no amount of retrying will fix.

Retry only what's safe to retry, and back off​

Transient failures such as 429, 503, and network timeouts are worth retrying. Client errors caused by bad input generally aren't. When you do retry, use exponential backoff with jitter rather than a fixed interval. A client that retries instantly just piles more load onto a service that's already struggling, and a fleet of clients retrying on the same fixed schedule produces a synchronized wave of requests. Google Cloud's retry guidance describes truncated exponential backoff with randomized jitter as a standard strategy for exactly this reason: it spreads retries out and avoids that synchronized wave, often called the thundering-herd problem.

One detail is easy to miss: fetch() doesn't reject on an HTTP error status such as 429 or 503. It only rejects on a genuine network failure. A retry helper built on fetch therefore has to check response.status itself rather than expecting a catch block to see the failure.

The version below does that, defines which statuses are retryable, and honors Retry-After, which can be either a number of seconds or an HTTP date. It still assumes the request is safe to repeat, which is what idempotency (covered next) is for. A production version should also enforce a total time budget across attempts, not just a maximum attempt count.

const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);

async function requestWithBackoff(url, options = {}, maxAttempts = 5) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
let response;
try {
response = await fetch(url, options);
} catch (networkErr) {
// No response at all: a network-level failure, not an HTTP error status.
if (attempt === maxAttempts - 1) throw networkErr;
await sleep(backoffDelay(attempt));
continue;
}

if (response.ok) return response;
if (!RETRYABLE_STATUSES.has(response.status) || attempt === maxAttempts - 1) {
return response; // caller inspects response.status and the error body
}

const serverDelay = retryAfterMs(response.headers.get('Retry-After'));
await sleep(serverDelay ?? backoffDelay(attempt));
}
}

// Retry-After may be delay-seconds ("120") or an HTTP date.
function retryAfterMs(value) {
if (!value) return null;
const seconds = Number(value);
if (!Number.isNaN(seconds)) return Math.max(0, seconds * 1000);
const date = Date.parse(value);
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}

function backoffDelay(attempt) {
const base = Math.min(1000 * 2 ** attempt, 30000);
return base + Math.random() * base * 0.5; // jitter
}

function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}

Honor Retry-After when the server sends it​

When the server provides a Retry-After value, use it. It's more precise than any backoff schedule the client would otherwise work out on its own.

Make retries safe with idempotency​

Retrying a GET, PUT, or DELETE is generally safe on its own, because HTTP defines those methods as idempotent: sending one repeatedly has the same intended effect as sending it once. A retried POST isn't safe by default, because a network timeout doesn't tell the client whether the original request actually went through.

The IETF's Idempotency-Key header draft, which has since expired without becoming an RFC but still describes the widely used convention, covers the client side: the client generates a unique key for each logical operation and sends it with the request. The AWS Builders' Library article on making retries safe with idempotent APIs covers the server side: the service stores the key together with the response it produced the first time, and returns that same response on a retry instead of repeating the side effect. A retried POST with the same key then doesn't create the order or charge the card a second time.

Common API Error Handling Mistakes​

A handful of mistakes cause most of the pain in error handling that looks fine in a demo:

Don'tDo instead
Return 200 OK with the error described only in the bodyUse the status code that matches what actually happened, so generic HTTP tooling doesn't mistake the response for a success
Swallow exceptions and return an empty or generic 500Keep enough detail (through a documented error code and detail field) for the client to tell failure modes apart
Use a different error shape for each endpoint or teamStandardize on one structure, ideally shaped like RFC 9457, across the whole API surface
Return raw stack traces or database errors to the clientLog the full error server-side, and return a sanitized detail plus a request ID the client can give to support
Rate-limit without a Retry-After headerAlways pair 429 with Retry-After so clients know how long to wait
Treat every error as retryableSeparate client-fixable errors from transient server errors before deciding whether to retry

The 200-with-an-error-in-the-body pattern deserves special attention, because it's usually introduced with good intentions, typically to avoid tripping alerts on a monitoring system that watches HTTP status codes. All it really does is move the problem: now every client has to parse every response body to find out whether the call succeeded, which defeats the purpose of status codes altogether.

The 429 row is worth a closer look too. RFC 6585 defines the status code itself, but how you design the rate limit, and how consistently 429 and Retry-After are applied across your endpoints, is a topic of its own.

How WSO2 Standardizes API Error Handling​

Most of the practices above are things individual teams have to build and maintain themselves, consistently, across every backend service they own. The WSO2 API Gateway takes a narrower but useful slice of that work off their hands: when a gateway policy rejects a request itself, the response is structured by default rather than a bare connection failure.

  • The JWT Authentication policy returns 401 with a JSON error body, which can be configured to a plain or minimal format.
  • The Advanced Rate Limit policy returns 429 with a JSON body and a Retry-After header, and both the status code and the body are configurable.
  • When the gateway can't connect to a backend, the request fails with 503 once the connect timeout expires, instead of hanging.

These bodies aren't RFC 9457 problem+json by default, so teams standardizing on Problem Details should customize them to match.

This default covers faults the gateway generates itself. It doesn't automatically reshape whatever error body a backend service returns, and each protocol still handles its own application-level errors as described in the architectures section above. What it does remove is one specific, recurring problem: connection-level failures and policy rejections that happen before a request reaches a backend no longer come back as an unstructured blob the client has to guess at.

None of this replaces the design decisions covered in this guide. Choosing status codes, deciding what belongs in detail and what stays server-side, and building retry logic on the client are still the API team's job for their own services. You can see how the WSO2 API Platform approaches this as part of its broader API management capabilities, including how its federated gateway model keeps a single control plane in charge of gateways running in different environments.

Conclusion​

Consistent API error handling comes down to a small number of decisions made once and applied everywhere. Choose status codes that mean what they say. Standardize on a structured response body such as RFC 9457's Problem Details format. Handle REST, GraphQL, and gRPC according to their own error conventions rather than forcing one shape onto all three. And build client-side retry logic that backs off, respects Retry-After, and only retries what's genuinely safe to retry. Get those right, and a failed request becomes something a client can act on rather than something it has to guess about.

If you're standardizing error handling across several teams or backends, it's worth thinking about where that consistency gets enforced. The WSO2 API Platform returns structured, JSON-based error responses for gateway-generated rejections by default, with configurable status codes and bodies, which is one way to get that consistency without rebuilding it in every service you run.

Frequently Asked Questions​

How do you fix an API error?​

Start with the status code to find the general category, then read the error code and detail in the response body for specifics. A 4xx usually means the request needs fixing, while a 5xx means checking server logs, or retrying with backoff if the failure looks transient.

What causes an API to fail?​

Common causes include invalid or missing request data, expired or missing authentication, a resource that doesn't exist, going over a rate limit, and backend failures such as timeouts or unhandled exceptions.

How can a client detect a failed API request?​

Check the HTTP status code first. A 2xx with a non-null success payload generally means the call worked, and anything else signals a problem. For protocols like GraphQL, where a 2xx can still carry partial errors, the client also needs to inspect the errors field in the response body, not just the status line.

What is RFC 9457 Problem Details?​

RFC 9457 is the IETF standard that defines a common structure for API error responses. It's served as application/problem+json for JSON APIs or application/problem+xml for XML ones, so APIs don't each have to invent their own error format from scratch.

Does GraphQL use HTTP status codes for errors?​

Not in the same way REST does. Request errors that fail before execution begins, such as a malformed query, typically get a 4xx. Once execution has started, GraphQL can return a 2xx along with a dedicated errors array in the response body, even if some fields failed, because HTTP has no status code for partial success. Which of these a server does depends on its implementation and the media type it negotiates with the client.

WSO2 API PlatformWSO2 API Platform

The open, universal platform for managing every API and AI service at scale. 100% open source.

Explore

BlogTutorialsTopics
© WSO2 LLC. All rights reserved.
WSO2 LegalDo Not Sell My Personal InformationModern Slavery Statement