JSON API Error Response Format: RFC 7807, Status Codes & Validation
Last updated:
A JSON API error response should always include status (HTTP status code), error (machine-readable code), and message (human-readable description) — never return an empty body or HTML for API errors, even for 500 errors. RFC 7807 (Problem Details for HTTP APIs) standardizes the error format: Content-Type: application/problem+json with fields type (URI identifying the error type), title, status, detail, and optional instance (URI of the specific occurrence). Express.js error middleware, FastAPI, and Spring Boot all support RFC 7807 natively or via libraries.
This guide covers the minimum error envelope, HTTP status code semantics (400 vs 422, 401 vs 403, 404 vs 410), RFC 7807 Problem Details implementation, validation error arrays (errors[]), GraphQL error format (errors[].message, locations, path), and client-side error parsing patterns.
The Minimum JSON Error Envelope
Every JSON API error response needs a minimum envelope that clients can rely on unconditionally — even before they know which error standard the API follows. The minimum is three fields: status (the HTTP status code as an integer, mirroring the response status line), error (a machine-readable string code like NOT_FOUND or VALIDATION_FAILED), and message (a human-readable description suitable for logging and debugging). Never return an empty body, an HTML error page, or plain text for API error responses — clients that call response.json() will throw a parse error, masking the real problem.
// Minimum JSON error envelope — always include these three fields
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"status": 404,
"error": "NOT_FOUND",
"message": "User with id 42 was not found."
}
// 422 validation error — minimum envelope
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"status": 422,
"error": "VALIDATION_FAILED",
"message": "The request body failed validation."
}
// 500 internal error — never expose internal details
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
{
"status": 500,
"error": "INTERNAL_ERROR",
"message": "An unexpected error occurred. Reference: err-a1b2c3"
}
// BAD: never return empty bodies, HTML, or plain text for API errors
HTTP/1.1 500 Internal Server Error
Content-Type: text/html
<html><body>Internal Server Error</body></html>
// This causes JSON.parse() to throw in any API clientThe error code should be UPPER_SNAKE_CASE and stable across releases — clients may switch behavior based on it. The message field is for developers and support engineers, not end users; translate it into user-facing language at the presentation layer. Adding a requestId or traceId field to every error response — server-generated and logged — enables support teams to look up the full request context from a single ID the client can report. For a broader look at request and response conventions, see our guide on JSON API design.
HTTP Status Codes: Which Code for Which Error
Correct HTTP status codes allow clients to implement generic error handling without parsing the body. The most frequently misused codes are 400 vs 422 for validation, 401 vs 403 for access control, and 404 vs 410 for absent resources. Choosing the wrong code forces clients to inspect the body for information that should be in the status line.
// HTTP status code guide for JSON APIs
// 400 Bad Request — structurally invalid request
// Use when: JSON cannot be parsed, Content-Type is wrong, required headers missing
// Do NOT use for validation errors on successfully parsed fields
// 401 Unauthorized — authentication required or credentials invalid
// Use when: no Authorization header, expired JWT, invalid API key
// Always include: WWW-Authenticate: Bearer
// Client action: prompt for login or refresh token
// 403 Forbidden — authenticated but not authorized
// Use when: user is identified but lacks permission for this resource
// Never use 403 to hide a resource's existence — use 404 instead
// 404 Not Found — resource does not exist
// Use when: /users/999 and user 999 does not exist
// Security: also use 404 when an authenticated user should not know a resource exists
// 409 Conflict — request conflicts with current resource state
// Use when: email already registered, ETag mismatch, order already shipped
// 410 Gone — resource existed but has been permanently deleted
// Use when: you know the resource existed and was intentionally removed
// Prefer 404 when you do not track deletion history
// 422 Unprocessable Entity — valid JSON, business rule violation
// Use when: email format invalid, required field empty, end_date before start_date
// This is the correct code for all field-level validation errors
// 429 Too Many Requests — rate limit exceeded
// Always include: Retry-After: <seconds>
// 500 Internal Server Error — unexpected server failure
// 503 Service Unavailable — temporary outage, safe to retry
// Always include on 503: Retry-After: 30The 404 vs 410 distinction matters for SEO and client caching: 410 tells clients and search engines the resource is permanently gone and they should remove it from caches and indexes. Use 410 only when you have deletion records; otherwise default to 404. The 409 Conflict code is underused — it is the correct response for duplicate-key violations (a user tries to register an email already in use) and optimistic concurrency failures (an ETag mismatch on a PUT request). For security implications of status code leakage and error response hardening, see our guide on JSON security.
RFC 7807 Problem Details for HTTP APIs
RFC 7807 (Problem Details for HTTP APIs) defines a standard JSON error format that any HTTP client can recognize by the Content-Type: application/problem+json header. When a client sees this content type, it knows the body follows the Problem Details schema and can parse it reliably without inspecting the specific API documentation. RFC 7807 has been updated by RFC 9457, but RFC 7807 remains the most widely cited and implemented version across server frameworks.
// RFC 7807 Problem Details — five core fields
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/validation-failed",
"title": "Validation Failed",
"status": 422,
"detail": "The email field must be a valid email address.",
"instance": "/errors/a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
// Field semantics:
// type — URI identifying the problem type (SHOULD resolve to documentation)
// title — short, human-readable, static description of the problem type
// same string for every occurrence of this type
// status — numeric HTTP status code (mirrors the response status line)
// detail — human-readable explanation specific to THIS occurrence
// instance — URI identifying this specific occurrence (use as correlation ID)
// log full context server-side under this ID
// RFC 7807 with extension fields for domain-specific context
{
"type": "https://api.example.com/errors/out-of-stock",
"title": "Product Out of Stock",
"status": 422,
"detail": "Product SKU-9920 has 0 units available.",
"instance": "/errors/ord-772-err-1",
"productId": "SKU-9920",
"availableUnits": 0,
"restockDate": "2026-06-01"
}
// Extension fields are fully compliant with RFC 7807 —
// add any key-value pairs needed for machine-readable contextThe type field is a URI, not a plain string — it should point to documentation that describes the error, its causes, and remediation steps. If you have no documentation URL yet, use "about:blank" as a placeholder and rely on title for description. Never use a bare code string like "VALIDATION_FAILED" as the type value — that violates the RFC. The instance field is your correlation ID: store the full stack trace and request context in your structured logs under this ID, and return only the ID to the client. Support teams can then look up the full diagnostics from a single reference the client can report.
Validation Error Arrays: field, message, code
Returning all validation errors in a single response — rather than stopping at the first failure — is one of the most impactful improvements you can make to an API's usability. Clients that must make repeated requests to discover each new validation error one by one face a frustrating loop. The standard pattern is a top-level errors array where each element describes one field failure with at minimum field, message, and code.
// Validation error array — full example with three field failures
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"status": 422,
"error": "VALIDATION_FAILED",
"message": "3 field(s) failed validation.",
"errors": [
{
"field": "email",
"message": "Must be a valid email address (RFC 5322).",
"code": "EMAIL_INVALID"
},
{
"field": "name",
"message": "Name is required and cannot be empty.",
"code": "REQUIRED"
},
{
"field": "age",
"message": "Age must be a positive integer.",
"code": "INVALID_TYPE"
}
]
}// Nested field errors — use dot notation or JSON Pointer for path
{
"status": 422,
"error": "VALIDATION_FAILED",
"message": "Nested field validation failed.",
"errors": [
{
"field": "address.city",
"message": "City is required.",
"code": "REQUIRED"
},
{
"field": "items[0].price",
"message": "Price must be a positive number.",
"code": "INVALID_VALUE"
}
]
}
// With Zod in TypeScript — map all issues at once
import { z } from 'zod'
const schema = z.object({
email: z.string().email('Must be a valid email address'),
name: z.string().min(1, 'Name is required'),
age: z.number().int().positive('Age must be a positive integer'),
})
const result = schema.safeParse(requestBody)
if (!result.success) {
const errors = result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code.toUpperCase(),
}))
return res.status(422).json({ status: 422, error: 'VALIDATION_FAILED',
message: `${errors.length} field(s) failed validation.`, errors })
}The code field in each error object is the machine-readable counterpart to message — it lets clients show localized error messages without parsing English strings. Define a finite set of stable codes: REQUIRED, INVALID_FORMAT, OUT_OF_RANGE, TOO_LONG, ALREADY_EXISTS, and so on. Clients can map these to UI strings in any language. For cross-field validation failures (end date before start date), add an error object with field set to the primary offending field or a synthetic field name like "dateRange".
GraphQL Error Format
GraphQL APIs always return HTTP 200 OK — even when there are errors. Error information is carried in the response body in a top-level errors array alongside the data field. This means clients cannot rely on HTTP status codes for error detection in GraphQL; they must inspect the response body. Partial success is possible: the data field may contain partial results while errors lists the fields that failed.
// GraphQL error response — spec-compliant format
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": null,
"errors": [
{
"message": "User not found.",
"locations": [{ "line": 2, "column": 3 }],
"path": ["user"],
"extensions": {
"code": "NOT_FOUND",
"status": 404
}
}
]
}
// Partial success — some fields resolved, some errored
{
"data": {
"user": {
"id": "42",
"name": "Alice",
"orders": null
}
},
"errors": [
{
"message": "You do not have permission to view orders.",
"locations": [{ "line": 5, "column": 5 }],
"path": ["user", "orders"],
"extensions": {
"code": "FORBIDDEN",
"status": 403
}
}
]
}// GraphQL error fields:
// message — required, human-readable description of the error
// locations — array of { line, column } pointing to the query location that errored
// path — array of field names and indexes from root to the errored field
// e.g. ["user", "orders", 0, "total"]
// extensions — object for machine-readable metadata (not in spec, but universal)
// use extensions.code for error classification
// use extensions.status for the equivalent HTTP status
// Client-side GraphQL error handling in TypeScript
interface GraphQLError {
message: string
locations?: Array<{ line: number; column: number }>
path?: Array<string | number>
extensions?: {
code?: string
status?: number
[key: string]: unknown
}
}
async function gqlFetch<T>(query: string, variables?: object): Promise<T> {
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
})
const json = await response.json()
if (json.errors?.length) {
// Inspect first error code for classification
const code = json.errors[0]?.extensions?.code
if (code === 'UNAUTHENTICATED') throw new AuthError()
throw new GraphQLError(json.errors[0].message, json.errors)
}
return json.data as T
}The extensions field is the standard place for machine-readable context in GraphQL errors — the spec reserves it explicitly for this purpose. Put your error classification code in extensions.code and the equivalent HTTP status in extensions.status. This allows generic client code to handle GraphQL errors the same way it handles REST errors. When data is null and errors is non-empty, the entire operation failed; when data is an object with some null fields and errors is non-empty, it is a partial success — handle both cases in your client. For deeper GraphQL patterns including schema design and variables, see our guide on GraphQL JSON.
Express and FastAPI Error Middleware
Centralizing error serialization in a single middleware function — rather than scattering res.json() error calls across route handlers — is the key architectural decision for consistent JSON error responses. Both Express.js and FastAPI provide first-class mechanisms for this. Express uses a four-parameter middleware function; FastAPI uses exception handler decorators. Both produce RFC 7807-compliant responses when configured correctly.
// Express.js — centralized RFC 7807 error middleware
import { Request, Response, NextFunction } from 'express'
import { randomUUID } from 'crypto'
// Custom error class — attach RFC 7807 fields
export class ApiError extends Error {
constructor(
public status: number,
public type: string,
public title: string,
message: string
) { super(message) }
}
// Four-parameter signature — Express detects error middleware by arity
export function errorHandler(err: unknown, req: Request, res: Response, next: NextFunction) {
const instance = '/errors/' + randomUUID()
if (err instanceof ApiError) {
return res.status(err.status)
.setHeader('Content-Type', 'application/problem+json')
.json({ type: err.type, title: err.title, status: err.status,
detail: err.message, instance })
}
// Unknown error — never expose internals in production
const isProd = process.env.NODE_ENV === 'production'
return res.status(500)
.setHeader('Content-Type', 'application/problem+json')
.json({
type: 'https://api.example.com/errors/internal',
title: 'Internal Server Error',
status: 500,
detail: isProd ? 'An unexpected error occurred.' : String(err),
instance,
})
}
// Register after all routes:
// app.use(router)
// app.use(errorHandler) ← last
// Async route handler wrapper — propagates rejections to errorHandler
const asyncHandler = (fn: Function) =>
(req: Request, res: Response, next: NextFunction) =>
Promise.resolve(fn(req, res, next)).catch(next)# FastAPI — RFC 7807 exception handlers
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
import uuid
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
instance_id = str(uuid.uuid4())[:8]
errors = [
{
"field": ".".join(str(loc) for loc in err["loc"][1:]),
"message": err["msg"],
"code": err["type"].upper(),
}
for err in exc.errors()
]
return JSONResponse(
status_code=422,
headers={"Content-Type": "application/problem+json"},
content={
"type": "https://api.example.com/errors/validation-failed",
"title": "Validation Failed",
"status": 422,
"detail": f"{len(errors)} field(s) failed validation.",
"instance": f"/errors/{instance_id}",
"errors": errors,
},
)
@app.exception_handler(HTTPException)
async def http_handler(request: Request, exc: HTTPException):
instance_id = str(uuid.uuid4())[:8]
return JSONResponse(
status_code=exc.status_code,
headers={"Content-Type": "application/problem+json"},
content={
"type": f"https://api.example.com/errors/{exc.status_code}",
"title": str(exc.detail),
"status": exc.status_code,
"detail": str(exc.detail),
"instance": f"/errors/{instance_id}",
},
)In Express, always use the asyncHandler wrapper for route handlers that return Promises — Express does not automatically catch promise rejections before Express 5. In FastAPI, Pydantic V2 error location tuples start with "body"; slice from index 1 (err["loc"][1:]) when building field paths. For Spring Boot, the @ControllerAdvice with @ExceptionHandler methods provides the equivalent centralized error handling; the spring-web library includes ResponseEntityExceptionHandler with built-in RFC 7807 support via ProblemDetail. For a full walkthrough of building JSON APIs in Express including middleware patterns, see our guide on Express.js JSON API.
Client-Side Error Parsing Patterns
Robust client-side error parsing requires three things: a wrapper around fetch that never throws on HTTP errors, a TypeScript discriminated union that forces both success and error paths to be handled, and a mapping from machine-readable error codes to user-facing messages. Built on these three primitives, you can implement consistent error UI across every API call without repeating parsing logic in each component.
// types/api.ts
export interface ProblemDetails {
type: string
title: string
status: number
detail: string
instance: string
errors?: Array<{ field: string; message: string; code: string }>
}
export type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: ProblemDetails }
// lib/api-client.ts
export async function apiFetch<T>(url: string, options?: RequestInit): Promise<ApiResult<T>> {
let response: Response
try {
response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, application/problem+json',
...options?.headers,
},
})
} catch {
// Network failure — no HTTP response received
return { success: false, error: {
type: 'https://api.example.com/errors/network',
title: 'Network Error', status: 0,
detail: 'Could not reach the server.', instance: '',
}}
}
if (response.ok) {
return { success: true, data: await response.json() as T }
}
// Non-2xx — attempt to parse error body
const ct = response.headers.get('content-type') ?? ''
if (ct.includes('application/problem+json') || ct.includes('application/json')) {
try {
const error = await response.json() as ProblemDetails
return { success: false, error }
} catch { /* body was not valid JSON */ }
}
// Fallback for plain-text or empty error bodies
return { success: false, error: {
type: 'about:blank', title: response.statusText,
status: response.status, detail: `HTTP ${response.status}`, instance: url,
}}
}// lib/error-messages.ts — map error codes to user-facing strings
const MESSAGES: Record<string, string> = {
NOT_FOUND: 'The requested item does not exist.',
UNAUTHORIZED: 'Please sign in to continue.',
FORBIDDEN: 'You do not have permission to do this.',
VALIDATION_FAILED: 'Please correct the highlighted fields.',
INTERNAL_ERROR: 'Something went wrong on our end. Please try again.',
'https://api.example.com/errors/not-found': 'The requested item does not exist.',
}
export const getUserMessage = (error: ProblemDetails): string =>
MESSAGES[error.type] ?? MESSAGES[error.title] ?? error.detail ?? 'An unexpected error occurred.'
// Retry logic — only retry safe status codes
export async function fetchWithRetry<T>(
url: string, options?: RequestInit, maxRetries = 3
): Promise<ApiResult<T>> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const result = await apiFetch<T>(url, options)
if (result.success) return result
const { status } = result.error
// Only retry 429 (rate limit) and 503 (temporary unavailability)
// 400, 401, 403, 404, 422 will produce the same result on every retry
if ((status !== 429 && status !== 503 && status !== 0) || attempt === maxRetries - 1) {
return result
}
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)))
}
return { success: false, error: { type: 'about:blank', title: 'Max retries exceeded',
status: 0, detail: 'Request failed after maximum retries.', instance: url }}
}
// Usage in a React component
const result = await apiFetch<User>('/api/users/42')
if (!result.success) {
const fieldErrors = result.error.errors ?? []
// Map field errors back to form fields
fieldErrors.forEach(e => form.setError(e.field, { message: e.message }))
toast.error(getUserMessage(result.error))
return
}
setUser(result.data)The discriminated union pattern ({ success: true; data: T } | { success: false; error: ProblemDetails }) is more ergonomic than throwing exceptions for expected API errors — it keeps error handling in the same scope as the call, avoids try/catch wrapping throughout the codebase, and makes TypeScript enforce that both paths are handled. For form validation errors (422 with errors array), map each error's field directly to the form library's setError method. For retry logic, always respect the Retry-After header on 429 responses rather than using a fixed backoff interval.
Key Terms
- RFC 7807
- An IETF standard (Request for Comments 7807) titled "Problem Details for HTTP APIs" that defines a JSON format for communicating structured error information from HTTP APIs. It specifies five core fields —
type,title,status,detail, andinstance— and theContent-Type: application/problem+jsonmedia type. Extension fields may be added for domain-specific context. RFC 7807 has been updated by RFC 9457, which adds clarifications and additional guidance, but the core format is unchanged and RFC 7807 remains widely referenced. - Problem Details
- The JSON error response object defined by RFC 7807 and RFC 9457. A Problem Details object has a
Content-Typeofapplication/problem+jsonand five core fields:type(URI identifying the error type),title(short static description),status(HTTP status integer),detail(explanation of this specific occurrence), andinstance(URI identifying this specific error, used as a correlation ID). Clients that recognize theapplication/problem+jsoncontent type can parse any Problem Details response without API-specific knowledge. - application/problem+json
- The IANA-registered media type for RFC 7807 Problem Details responses. When a server sets
Content-Type: application/problem+json, it signals to the client that the response body is a Problem Details object with a known schema. Generic HTTP clients, API gateways, and monitoring tools that understand this media type can extract structured error information without any API-specific configuration. Always set this content type on error responses that follow the RFC 7807 format. - 422 Unprocessable Entity
- An HTTP status code indicating that the server understood the request and could parse the body, but cannot process it because the data violates semantic rules. 422 is the correct status for field-level validation errors — an invalid email format, a missing required field, a value outside the allowed range. It is distinct from 400 Bad Request (which means the server could not parse the request at all) and 409 Conflict (which means the request conflicts with the current state of the resource). Use 422 whenever you have successfully parsed the JSON body and run validation against its fields.
- validation error array
- A top-level
errorsarray in a 422 response where each element describes one field-level validation failure. Each element typically containsfield(the field name or JSON Pointer path),message(human-readable description), andcode(machine-readable error code). The array pattern allows all validation failures to be returned in a single response rather than one at a time, enabling clients to display all form errors simultaneously. Standardized by JSON:API and supported as an extension field in RFC 7807. - GraphQL errors array
- The top-level
errorsfield in a GraphQL response body that contains an array of error objects. GraphQL always returns HTTP 200 OK; theerrorsarray is the sole mechanism for communicating failures. Each error object must have amessagefield and may includelocations(pointing to the query position),path(tracing the field path from root to the error), andextensions(an object for machine-readable metadata like error codes and equivalent HTTP status codes). A response may contain bothdata(partial results) anderrorssimultaneously, representing a partial success.
FAQ
What should a JSON API error response look like?
A JSON API error response must include at minimum: status (HTTP status code as an integer), error (machine-readable string code like NOT_FOUND or VALIDATION_FAILED), and message (human-readable description). Set Content-Type: application/json on every error response — never return an empty body or HTML page. For richer error contracts, use RFC 7807 Problem Details with Content-Type: application/problem+json and fields type, title, status, detail, and instance. For validation errors, add an errors array listing all field failures simultaneously.
What is RFC 7807 Problem Details?
RFC 7807 (Problem Details for HTTP APIs) is the IETF standard for structured JSON error responses. It defines five core fields: type (a URI identifying the error type, should resolve to documentation), title (short static human-readable summary), status (numeric HTTP status code), detail (explanation of this specific occurrence), and instance (URI for this error occurrence, used as a correlation ID). The media type is Content-Type: application/problem+json. Extension fields may be added for domain-specific context. RFC 7807 is superseded by RFC 9457, but the core format is identical and both are widely implemented.
When should I use 400 vs 422 for validation errors?
Use 422 Unprocessable Entity for validation errors — the JSON was parsed successfully but the field values violate schema or business rules. Use 400 Bad Request when the JSON itself cannot be parsed (syntax error, malformed structure), when the Content-Type header is wrong, or when required HTTP-level constraints are missing. The practical rule: if you called JSON.parse() (or equivalent) successfully and then validated the resulting fields, use 422. If parsing itself failed, use 400.
What is the difference between 401 and 403?
401 Unauthorized means the request is not authenticated — the server does not know who is making the request. The client should prompt for credentials or refresh the token. Always include WWW-Authenticate: Bearerwith a 401 response. 403 Forbidden means the request is authenticated — the server knows who the user is — but that identity lacks permission for the requested action. Never swap these two: 401 implies "authenticate and retry"; 403 implies "your credentials are valid but insufficient." Use 404 instead of 403 when you want to hide a resource's existence from unauthorized users to prevent enumeration attacks.
How do I return multiple validation errors in JSON?
Return a 422 response with an errors array — each element representing one field failure with field, message, and code fields. Never stop at the first validation error; run the full validation and collect all failures before responding. With Zod: call schema.safeParse(body) and map result.error.issues to your error array format. With class-validator: use validate() (not validateOrReject()) and collect all ValidationError objects. Returning all errors at once lets clients display every form error simultaneously, avoiding a frustrating one-error-at-a-time correction loop.
How do GraphQL errors work?
GraphQL responses always have HTTP status 200, even for errors. Errors appear in a top-level errors array alongside data. Each error object requires a message field and optionally includes locations (query position), path (field path from root), and extensions (machine-readable metadata — put your error code in extensions.code). A response with both data and errors represents a partial success — some fields resolved, some did not. Client-side, check for json.errors?.length before checking json.data.
How do I implement RFC 7807 in Express?
Create a four-parameter error middleware (err, req, res, next) and register it after all routes with app.use(errorHandler). Inside it, set Content-Type: application/problem+json and serialize to the RFC 7807 structure with a generated instance UUID. Create a custom ApiError class that carries status, type, and title fields so the middleware can read them. Wrap async route handlers with an asyncHandler utility that calls .catch(next) on the returned Promise — Express does not automatically propagate async rejections to error middleware before Express 5.
How do I parse JSON error responses in the browser?
Wrap fetch in a function that returns a discriminated union: { success: true; data: T } | { success: false; error: ProblemDetails }. Check response.ok before calling response.json(). Inspect the Content-Type header to detect application/problem+json vs plain JSON. Map error.type URIs and error.error codes to user-facing messages in a lookup table. Only retry on 429 (with Retry-After) and 503 — never auto-retry 4xx status codes, which will return the same error on every attempt.
Further reading and primary sources
- RFC 7807 — Problem Details for HTTP APIs — The original IETF standard defining the application/problem+json format with type, title, status, detail, and instance fields
- RFC 9457 — Problem Details for HTTP APIs (update) — The updated IETF standard superseding RFC 7807 with clarifications and additional guidance for Problem Details responses
- MDN Web Docs — HTTP Status Codes — Complete reference for HTTP status codes: 400 vs 422, 401 vs 403, 409 Conflict, 429 Rate Limit, 503 Service Unavailable
- GraphQL Specification — Errors — GraphQL specification for the errors array: message, locations, path, and extensions fields in error responses
- Zod Documentation — Error Handling — How to use safeParse(), ZodError.issues, and error.format() to build validation error arrays from Zod schema results