StackPractices
beginner By Mathias Paulenko

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

CodeHTTP StatusDescription
INVALID_PARAMETER400Field validation failed
MISSING_REQUIRED_FIELD400Required field not provided
AUTHENTICATION_FAILED401Invalid or expired credentials
PERMISSION_DENIED403Insufficient permissions
RESOURCE_NOT_FOUND404Requested resource missing
DUPLICATE_RESOURCE409Resource already exists
RATE_LIMIT_EXCEEDED429Too many requests
INTERNAL_ERROR500Unexpected server error

5. Retry Behavior

Error CodeRetry StrategyMax RetriesBackoff
RATE_LIMIT_EXCEEDEDWait for Retry-After header3Exponential
INTERNAL_ERRORRetry with jitter3Exponential 1s, 2s, 4s
SERVICE_UNAVAILABLERetry with jitter5Exponential
INVALID_PARAMETERDo not retry0N/A
AUTHENTICATION_FAILEDDo not retry0N/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:

  1. 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.
  2. 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.
  3. 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.
  4. 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: true flag and a replaced_by pointer; 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:

LayerExamplesShape
Gateway / proxy401 without token, 429 rate limit, 502/503 upstreamSame envelope, but source: "gateway" field
Applicationvalidation, business rules, not foundFull 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 4xx and one 5xx response per endpoint in your test suite — a field rename breaks clients the same as a bug.
  • Assert error.code stays stable while message is 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 429 in staging: clients that don’t honor Retry-After find 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 402 or 403 with code QUOTA_EXCEEDED and a documentation_url pointing 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:

EndpointCodes it can returnWhy
POST /v1/usersINVALID_PARAMETER, DUPLICATE_RESOURCEvalidation + unique email
GET /v1/users/{id}RESOURCE_NOT_FOUNDid doesn’t exist
POST /v1/ordersINVALID_PARAMETER, RATE_LIMIT_EXCEEDED, INSUFFICIENT_FUNDSbusiness 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:

  1. Is it the client’s fault? → 4xx. Pick the most specific: 400 malformed → 422 business rule → 404 missing → 409 state conflict → 429 too many.
  2. Is it yours? → 5xx. 500 unexpected, 503 temporary outage (always with Retry-After).
  3. 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 error object shape — id, code, message, retryable. Consumers reuse one parser for both paths.
  • Polling the job endpoint: GET /v1/jobs/{id} returns status: "failed" plus the same error object embedded under a result.error key.
  • 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:

FieldResponseInternal log
errorIdyes — consumers quote it in ticketsyes — correlation key
code / messageyesyes
detailsyes (sanitized)yes (full)
Stack tracenever5xx only
Request/user IDsrequest ID onlyall of them
SQL, internals, env valuesneveryes, 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:

  1. Mark it in the registry: "deprecated": true, "sunset": "2026-12-01", "replaced_by": "NEW_CODE".
  2. Keep accepting (and emitting) it until the sunset date — consumers built integrations around the old contract.
  3. Announce the change in the changelog and, if you emit Deprecation/Sunset response headers, set them on responses that still carry the old code.
  4. 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

ContextApproachNotes
Public APIFull format with docs URLsConsumers need self-service debugging
Internal APILightweight formatSmaller payload, simpler consumers
MicroservicesInclude request ID for tracingEssential for distributed debugging
gRPCUse status codes with details protogRPC has its own error model

What Works

  1. Never return stack traces or SQL in error responses — log them internally
  2. Use a central error registry to prevent teams from inventing new codes
  3. Always include a request ID for cross-service tracing
  4. Document every error code in a public error reference page
  5. Return 404 for missing resources without distinguishing “exists but forbidden”
  6. Use a custom error class in your codebase to standardize error creation
  7. Log 4xx at warn level, 5xx at error level — different severity for different audiences

Common Mistakes

  1. Returning 200 OK for errors — breaks client error detection
  2. Inconsistent field names (error_message vs message vs detail)
  3. Not distinguishing retryable vs non-retryable errors
  4. Exposing internal implementation details in error messages
  5. Using 500 for validation errors — 500 means server bug, not user error
  6. Different error formats per endpoint — each team invents their own structure
  7. Not logging 4xx errors — client errors reveal API misuse patterns worth tracking
  8. Returning the error ID only in logs — consumers need it in the response to reference it
Mermaid flowchart LR diagram

Further Reading

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).