API Error Handling Guideline
A guideline for standardizing error responses, status codes, and error payloads across REST and GraphQL APIs.
Overview
Inconsistent error responses confuse consumers and drag out integrations. One endpoint answers plain text, another nested JSON, a third HTML. This guideline standardizes how your APIs report failure, so consumers handle errors the same way everywhere without guessing formats. Related resources: Stream LLM Output with Server-Sent Events (SSE), How to Document an API with OpenAPI, Swagger UI and Redoc, and Implement API Logging and Audit Trails.
When to Use
Use this resource when:
- Designing a new API or versioning an existing one
- Onboarding a new team that will build API endpoints
- Reviewing an API for consistency before public release
- Consumers report that error handling is difficult or unpredictable
If the question is whether to fix errors on REST or move to GraphQL, see GraphQL vs REST — When to Choose and How to Migrate first.
Solution
# API Error Handling Standard
## 1. HTTP Status Codes
| Status | Meaning | When to Use |
|--------|---------|-------------|
| 400 | Bad Request | Validation errors, malformed JSON, missing required fields |
| 401 | Unauthorized | Missing or invalid authentication credentials |
| 403 | Forbidden | Authenticated but not authorized for this resource |
| 404 | Not Found | Resource doesn't exist (don't reveal if it exists but is forbidden) |
| 409 | Conflict | Resource already exists, duplicate entry, state conflict |
| 422 | Unprocessable Entity | Semantically correct but business rule violation |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server failure (avoid exposing details) |
| 503 | Service Unavailable | Temporary outage, retry after header provided |
## 2. Error Response Format (REST)
```json
{
"error": {
"id": "err_7f8a9b2c",
"code": "INVALID_PARAMETER",
"message": "The 'email' field must be a valid email address.",
"details": [
{
"field": "email",
"issue": "invalid_format",
"value": "not-an-email"
}
],
"timestamp": "2026-06-26T10:00:00Z",
"path": "/v1/users",
"retryable": false,
"documentation_url": "https://docs.example.com/errors/INVALID_PARAMETER"
}
}
3. Error Response Format (GraphQL)
{
"data": null,
"errors": [
{
"message": "The 'email' field must be a valid email address.",
"extensions": {
"code": "INVALID_PARAMETER",
"errorId": "err_7f8a9b2c",
"field": "email",
"retryable": false,
"documentationUrl": "https://docs.example.com/errors/INVALID_PARAMETER"
}
}
]
}
4. Error Code Registry
| Code | HTTP Status | Description |
|---|---|---|
| INVALID_PARAMETER | 400 | Field validation failed |
| MISSING_REQUIRED_FIELD | 400 | Required field not provided |
| AUTHENTICATION_FAILED | 401 | Invalid or expired credentials |
| PERMISSION_DENIED | 403 | Insufficient permissions |
| RESOURCE_NOT_FOUND | 404 | Requested resource missing |
| DUPLICATE_RESOURCE | 409 | Resource already exists |
| RATE_LIMIT_EXCEEDED | 429 | Too many requests |
| INTERNAL_ERROR | 500 | Unexpected server error |
5. Retry Behavior
| Error Code | Retry Strategy | Max Retries | Backoff |
|---|---|---|---|
| RATE_LIMIT_EXCEEDED | Wait for Retry-After header | 3 | Exponential |
| INTERNAL_ERROR | Retry with jitter | 3 | Exponential 1s, 2s, 4s |
| SERVICE_UNAVAILABLE | Retry with jitter | 5 | Exponential |
| INVALID_PARAMETER | Do not retry | 0 | N/A |
| AUTHENTICATION_FAILED | Do not retry | 0 | N/A |
6. Logging Requirements
Every error response must be logged with:
- Error ID (for correlation)
- Request ID (from incoming request)
- User ID (if authenticated)
- Endpoint path and method
- Full error payload
- Stack trace (500 errors only, internal logs)
- Timestamp
## Explanation
The guideline forces every error to include a machine-readable code (`INVALID_PARAMETER`) and a human-readable message. The `errorId` lets consumers reference the exact failure when contacting support. The `retryable` boolean tells client SDKs whether automatic retry is safe. Separating REST and GraphQL formats acknowledges that GraphQL returns 200 OK even for errors, so the error information lives in the `errors` array.
## Error Handler Implementation
### Express.js Central Error Handler
```javascript
function errorHandler(err, req, res, next) {
const errorId = `err_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const statusCode = err.statusCode || 500;
const code = err.code || "INTERNAL_ERROR";
const payload = {
error: {
id: errorId,
code: code,
message: statusCode >= 500
? "An internal error occurred. Please try again."
: err.message,
details: err.details || [],
timestamp: new Date().toISOString(),
path: req.path,
retryable: err.retryable || false,
documentation_url: `https://docs.example.com/errors/${code}`,
},
};
if (statusCode >= 500) {
logger.error({
errorId,
code,
message: err.message,
stack: err.stack,
requestId: req.id,
userId: req.user?.id,
path: req.path,
method: req.method,
});
} else {
logger.warn({
errorId,
code,
message: err.message,
requestId: req.id,
path: req.path,
});
}
res.status(statusCode).json(payload);
}
class ApiError extends Error {
constructor(code, message, statusCode, options = {}) {
super(message);
this.code = code;
this.statusCode = statusCode;
this.details = options.details || [];
this.retryable = options.retryable || false;
}
}
// Usage in route handlers
app.post("/v1/users", (req, res, next) => {
if (!req.body.email || !req.body.email.includes("@")) {
return next(new ApiError("INVALID_PARAMETER", "The 'email' field must be a valid email address.", 400, {
details: [{ field: "email", issue: "invalid_format", value: req.body.email }],
}));
}
res.status(201).json({ id: 1, email: req.body.email });
});
app.use(errorHandler);
Python Flask Error Handler
import uuid
import logging
from datetime import datetime, timezone
from flask import Flask, request, jsonify, g
logger = logging.getLogger(__name__)
class ApiError(Exception):
def __init__(self, code, message, status_code, details=None, retryable=False):
self.code = code
self.message = message
self.status_code = status_code
self.details = details or []
self.retryable = retryable
super().__init__(message)
@app.errorhandler(ApiError)
def handle_api_error(e):
error_id = f"err_{uuid.uuid4().hex[:12]}"
payload = {
"error": {
"id": error_id,
"code": e.code,
"message": e.message,
"details": e.details,
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"path": request.path,
"retryable": e.retryable,
"documentation_url": f"https://docs.example.com/errors/{e.code}",
}
}
if e.status_code >= 500:
logger.error(
"API error",
extra={
"errorId": error_id,
"code": e.code,
"requestId": getattr(g, "request_id", None),
"userId": getattr(g, "user_id", None),
},
)
return jsonify(payload), e.status_code
Structured Logging for Errors
Use structured JSON logging so errors are searchable and correlated across services:
{
"timestamp": "2026-06-26T10:00:00.123Z",
"level": "error",
"errorId": "err_7f8a9b2c",
"requestId": "req_abc123",
"userId": "usr_xyz789",
"code": "INTERNAL_ERROR",
"message": "Database connection timeout",
"stack": "Error: Database connection timeout\n at ...",
"path": "/v1/orders",
"method": "POST",
"duration_ms": 5023,
"service": "order-service",
"version": "2.1.0"
}
RFC 9457 Problem Details Format
For REST APIs that want to follow an IETF standard, use RFC 9457 (formerly RFC 7807):
{
"type": "https://docs.example.com/errors/INVALID_PARAMETER",
"title": "Invalid Parameter",
"status": 400,
"detail": "The 'email' field must be a valid email address.",
"instance": "/v1/users",
"traceId": "err_7f8a9b2c",
"errors": [
{
"field": "email",
"issue": "invalid_format",
"value": "not-an-email"
}
]
}
Set the Content-Type header to application/problem+json for RFC 9457 responses.
How to Adopt This Standard
Rolling a standard like this into an existing API works better in stages than in one big rewrite:
- Publish the registry first. Freeze the error codes in section 4 before touching code — every change after this is a migration, so the list has to be agreed early.
- Ship the central handler. Wire the Express or Flask handler below (or your framework’s equivalent) and route every new error through it. Existing endpoints migrate gradually; new endpoints never skip it.
- Convert by traffic, not by page. Take your top 5 endpoints by error volume first — they cover most consumers. The long tail can wait for the next maintenance window.
- Keep the old envelope for one version. If consumers already parse your errors, dual-emit or version the format change like any breaking change — silent schema edits break clients harder than bugs do.
Maintaining the Error Registry
The registry in section 4 is only useful while it’s the single source of truth:
- One file (or one table) owns the codes; code references it by constant, never by string literal.
- New codes go through review like API changes — anyone can propose, one owner approves.
- Codes are append-only. Retire with a
deprecated: trueflag and areplaced_bypointer; never delete. - The public docs page for codes is generated from the registry, not written by hand.
Gateway vs Application Errors
Two layers produce errors and they should stay distinguishable:
| Layer | Examples | Shape |
|---|---|---|
| Gateway / proxy | 401 without token, 429 rate limit, 502/503 upstream | Same envelope, but source: "gateway" field |
| Application | validation, business rules, not found | Full envelope with details |
Consumers retry gateway 5xx but not application 5xx — tagging the source in the payload saves them from guessing. Keep gateway messages terse; the app owns the explanatory ones.
Consuming Errors on the Client
A typed error on the client turns the envelope into behavior instead of string matching:
async function callApi(path) {
const res = await fetch(path);
if (res.ok) return res.json();
const { error } = await res.json().catch(() => ({}));
const apiError = new ApiClientError(
error?.code ?? 'UNKNOWN',
error?.message ?? `HTTP ${res.status}`,
{ id: error?.id, retryable: error?.retryable ?? res.status >= 500 },
);
if (apiError.retryable) throw apiError;
if (apiError.code === 'RATE_LIMIT_EXCEEDED') {
const wait = Number(res.headers.get('Retry-After') ?? 5) * 1000;
await new Promise(r => setTimeout(r, wait));
return callApi(path);
}
throw apiError;
}
The pattern that matters: branch on code, not on message — messages are for humans and can change any release.
Testing the Error Contract
The envelope is part of the contract, so test it like the success path:
- Snapshot one
4xxand one5xxresponse per endpoint in your test suite — a field rename breaks clients the same as a bug. - Assert
error.codestays stable whilemessageis allowed to evolve. - Negative-path contract tests belong next to the positive ones — see Test API Contracts with Consumer-Driven Contracts for the Pact setup.
- Replay a real
429in staging: clients that don’t honorRetry-Afterfind out on day one, not during an incident.
Rate Limits, Retry-After, and 429s
Rate limiting deserves its own block in the standard because two different errors live under “too much”:
- 429 Too Many Requests — the client is hitting the limit now. Always send
Retry-After(seconds or HTTP date) so honest clients back off automatically. - Quota exhausted — the plan ran out, waiting won’t help. Use
402or403with codeQUOTA_EXCEEDEDand adocumentation_urlpointing at upgrade options.
Pair 429 with the same X-RateLimit-* headers you already send on success responses, and include the reset timestamp inside the error details so clients don’t have to parse two places.
Documenting Errors per Endpoint
The registry covers shared codes, but each endpoint should still list its own failures in its docs:
| Endpoint | Codes it can return | Why |
|---|---|---|
POST /v1/users | INVALID_PARAMETER, DUPLICATE_RESOURCE | validation + unique email |
GET /v1/users/{id} | RESOURCE_NOT_FOUND | id doesn’t exist |
POST /v1/orders | INVALID_PARAMETER, RATE_LIMIT_EXCEEDED, INSUFFICIENT_FUNDS | business rule on top of shared ones |
If a code can come back from an endpoint and isn’t in the table, consumers treat it as a bug in your docs. Generate the table from the registry where possible — handwritten tables go stale the moment a new code ships.
Choosing a Status Code
When a new failure doesn’t fit an obvious status, walk this order before inventing anything:
- Is it the client’s fault? → 4xx. Pick the most specific: 400 malformed → 422 business rule → 404 missing → 409 state conflict → 429 too many.
- Is it yours? → 5xx. 500 unexpected, 503 temporary outage (always with
Retry-After). - Is it in-between? — e.g., upstream provider failed →
502 Bad Gateway, not 500; consumers treat upstream failures differently (they’re usually retryable).
Two traps: never use 200-with-error-flag (breaks every HTTP client), and don’t use 404 to hide forbidden resources unless the standard decides that deliberately — pick one policy and write it down.
Errors in Async Jobs and Webhooks
Failures that happen after the 202 has been sent can’t ride in the HTTP response — they need the same envelope delivered another way:
- Webhook callback: when the job finishes with error, POST the error payload to the consumer’s callback URL using the exact same
errorobject shape —id,code,message,retryable. Consumers reuse one parser for both paths. - Polling the job endpoint:
GET /v1/jobs/{id}returnsstatus: "failed"plus the sameerrorobject embedded under aresult.errorkey. - No channel at all: log it with the structured format and notify the job owner internally — silent job failures are the hardest bugs to trace.
Whichever channel applies, keep the code registry identical — a job that fails validation uses INVALID_PARAMETER, not a new JOB_FAILED catch-all. Catch-all codes teach consumers to give up on typed handling.
Logging vs Response: What Goes Where
The response payload and the log entry are two views of the same event with different audiences:
| Field | Response | Internal log |
|---|---|---|
errorId | yes — consumers quote it in tickets | yes — correlation key |
code / message | yes | yes |
details | yes (sanitized) | yes (full) |
| Stack trace | never | 5xx only |
| Request/user IDs | request ID only | all of them |
| SQL, internals, env values | never | yes, if needed for debugging |
The split rule is one line long: everything a consumer might legitimately need goes in the response; everything that helps your team goes in the log. Anything that helps an attacker goes nowhere.
Deprecating Error Codes
Codes die like endpoints do — slowly and in writing. When a code retires:
- Mark it in the registry:
"deprecated": true,"sunset": "2026-12-01","replaced_by": "NEW_CODE". - Keep accepting (and emitting) it until the sunset date — consumers built integrations around the old contract.
- Announce the change in the changelog and, if you emit
Deprecation/Sunsetresponse headers, set them on responses that still carry the old code. - On sunset day, remove it from the public error reference but keep the registry entry as
removed— old logs still need a lookup key.
Never reassign a retired code to a different meaning. A new concept gets a new code; reusing an old one makes historical logs and cached docs lie. If you’re unsure whether anyone still parses the code, grep your error analytics before the sunset date — quiet codes retire cheaply, busy ones deserve a longer overlap. Either way, write the decision down — future-you will ask why the code disappeared, and the changelog entry is the only answer that survives.
Variants
| Context | Approach | Notes |
|---|---|---|
| Public API | Full format with docs URLs | Consumers need self-service debugging |
| Internal API | Lightweight format | Smaller payload, simpler consumers |
| Microservices | Include request ID for tracing | Essential for distributed debugging |
| gRPC | Use status codes with details proto | gRPC has its own error model |
What Works
- Never return stack traces or SQL in error responses — log them internally
- Use a central error registry to prevent teams from inventing new codes
- Always include a request ID for cross-service tracing
- Document every error code in a public error reference page
- Return 404 for missing resources without distinguishing “exists but forbidden”
- Use a custom error class in your codebase to standardize error creation
- Log 4xx at warn level, 5xx at error level — different severity for different audiences
Common Mistakes
- Returning 200 OK for errors — breaks client error detection
- Inconsistent field names (
error_messagevsmessagevsdetail) - Not distinguishing retryable vs non-retryable errors
- Exposing internal implementation details in error messages
- Using 500 for validation errors — 500 means server bug, not user error
- Different error formats per endpoint — each team invents their own structure
- Not logging 4xx errors — client errors reveal API misuse patterns worth tracking
- Returning the error ID only in logs — consumers need it in the response to reference it
Further Reading
- RFC 9457 — Problem Details for HTTP APIs — the error format standard
- OWASP — API Security — error responses are a vulnerability vector
Frequently Asked Questions
Should GraphQL APIs return HTTP 200 for all requests?
200 for transport-level success — application errors ride in the errors array. 400 works for malformed GraphQL syntax; 401/403 for auth failures.
How do I handle validation errors with several fields?
Send back a details array — one entry per failed field: name, what failed, and the bad value.
What if a consumer sends an invalid API version?
400 Bad Request with code UNSUPPORTED_API_VERSION, and list the supported versions in the message. Never 404 — the endpoint exists; it's the version that doesn't.
Should I use RFC 9457 Problem Details or a custom format?
RFC 9457 buys you interoperability — application/problem+json is something some client libraries already know how to parse. A custom format buys you room to grow — extra fields like retryable or documentation_url. Don't need the room? Take the standard.
How do I handle errors in async/background jobs?
Same structured log format, then notify the job owner. With a webhook callback on the job, push an error event to the callback URL using the same payload structure.
Should I localize error messages?
Return machine-readable codes always. For the human-readable message field, use the consumer's Accept-Language header to return localized text. Keep the code in English so it's searchable regardless of locale.
How do I version error codes?
Codes stay stable across API versions. Need a different meaning? Mint a new code — don't redefine the old one. Deprecate retired codes with a sunset date and point to the replacement in the error reference.
What HTTP status should I use for rate limiting vs quota exceeded?
Use 429 Too Many Requests for rate limiting (temporary, retry after delay). Use 402 Payment Required or 403 Forbidden with code QUOTA_EXCEEDED for quota exhaustion (requires plan upgrade, not just waiting).
Related Resources
API Error Response Template
A reusable template for consistent, informative, and developer-friendly API error responses that reduce debugging time.
DocAPI Security Review Template (OWASP Checklist)
A fillable checklist template for reviewing API authentication, authorization, rate limiting, and OWASP API Security Top 10 coverage.
RecipeHandle API Errors with RFC 7807 and HTTP Status Codes
Patterns for consistent, predictable API error handling across multiple languages and frameworks.
GuideComplete Guide to API Versioning Strategies
Compare URI, header, query-param and content-negotiation versioning for REST, plus GraphQL schema evolution, deprecation and migration.
GuideREST API Design Guide
A thorough guide to designing clean, scalable, and maintainable REST APIs.
GuideGraphQL vs REST — When to Choose and How to Migrate
A decision guide comparing GraphQL and REST APIs: use cases, performance, caching, tooling, and migration strategies for engineering teams.