JSON API Error Handling: RFC 9457 Problem Details and Error Response Formats
Last updated:
Poorly designed JSON error responses are one of the most common API usability problems. When your API returns a cryptic {"error": true} or an HTML 500 page instead of structured JSON, every client developer has to guess what went wrong. RFC 9457 Problem Details provides a standard, machine-readable format that makes errors self-describing, consistent, and safe. This guide covers the standard, HTTP status codes, validation patterns, and security best practices for Node.js and Python APIs.
RFC 9457 Problem Details — The IETF Standard
RFC 9457 (published August 2023, replacing RFC 7807) defines a JSON format for HTTP error responses with a application/problem+json Content-Type. The format is extensible: add any fields your API needs beyond the base five.
{
"type": "https://api.example.com/errors/validation-failed",
"title": "Validation Failed",
"status": 422,
"detail": "The submitted data is invalid. See 'errors' for field-level details.",
"instance": "/requests/f3fc8e22-a8e5-4c1e-9e1a-83a4d5b3a456",
"errors": [
{
"field": "email",
"message": "Must be a valid email address",
"code": "invalid_format"
},
{
"field": "age",
"message": "Must be at least 18",
"code": "min_value",
"meta": { "minimum": 18, "actual": 15 }
}
]
}| Field | Required | Description |
|---|---|---|
type | Yes | URI identifying the error type |
title | Yes | Short human-readable summary |
status | Yes | HTTP status code (integer) |
detail | No | Longer explanation for this occurrence |
instance | No | URI of the specific occurrence |
HTTP Status Codes for JSON APIs
Use 400 for malformed syntax, 422 for valid JSON that fails business validation. Never return 200 with an error body — clients check the status code first.
| Code | Name | When to use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST creating a resource |
| 204 | No Content | Successful DELETE (no body) |
| 400 | Bad Request | Malformed JSON, missing required field |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Valid auth, but insufficient permissions |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Duplicate resource, version conflict |
| 422 | Unprocessable Entity | Semantically invalid data (validation errors) |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Server-side bug |
| 503 | Service Unavailable | Downstream dependency down |
Node.js / Express Error Response Patterns
// Problem Details factory
interface ProblemDetail {
type: string
title: string
status: number
detail?: string
instance?: string
[key: string]: unknown // extension members
}
function problemDetail(
status: number,
title: string,
detail?: string,
extensions?: Record<string, unknown>
): ProblemDetail {
return {
type: `https://api.example.com/errors/${title.toLowerCase().replace(/\s+/g, '-')}`,
title,
status,
...(detail ? { detail } : {}),
...extensions,
}
}
// Express error middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err)
if (err.name === 'ValidationError') {
return res.status(422)
.type('application/problem+json')
.json(problemDetail(422, 'Validation Failed', err.message, {
errors: (err as any).errors,
}))
}
if (err.name === 'NotFoundError') {
return res.status(404)
.type('application/problem+json')
.json(problemDetail(404, 'Not Found', err.message, {
instance: req.path,
}))
}
// Generic 500 — never expose internal details
res.status(500)
.type('application/problem+json')
.json(problemDetail(500, 'Internal Server Error', 'An unexpected error occurred.'))
})Validation Error Response Format
// Zod validation errors → RFC 9457
import { z } from 'zod'
const UserCreateSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email format'),
age: z.number().int().min(18, 'Must be at least 18'),
})
app.post('/users', (req, res) => {
const result = UserCreateSchema.safeParse(req.body)
if (!result.success) {
const errors = result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
}))
return res.status(422)
.type('application/problem+json')
.json({
type: 'https://api.example.com/errors/validation-failed',
title: 'Validation Failed',
status: 422,
detail: `${errors.length} validation error(s). See 'errors' for details.`,
errors,
})
}
// ... create user
})
// Example 422 response:
// {
// "type": "https://api.example.com/errors/validation-failed",
// "title": "Validation Failed",
// "status": 422,
// "detail": "2 validation error(s). See 'errors' for details.",
// "errors": [
// {"field": "email", "message": "Invalid email format", "code": "invalid_string"},
// {"field": "age", "message": "Must be at least 18", "code": "too_small"}
// ]
// }Python / FastAPI Error Patterns
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.exception_handlers import http_exception_handler
import traceback
app = FastAPI()
class ProblemDetail(BaseModel):
type: str
title: str
status: int
detail: str | None = None
instance: str | None = None
# Custom exception
class NotFoundError(Exception):
def __init__(self, resource: str, id: int):
self.resource = resource
self.id = id
@app.exception_handler(NotFoundError)
async def not_found_handler(request: Request, exc: NotFoundError):
return JSONResponse(
status_code=404,
media_type='application/problem+json',
content={
'type': 'https://api.example.com/errors/not-found',
'title': 'Not Found',
'status': 404,
'detail': f'{exc.resource} with id {exc.id} was not found.',
'instance': str(request.url),
}
)
# FastAPI validation errors (422) are already RFC 7807-style
# But you can customize with:
@app.exception_handler(RequestValidationError)
async def validation_error_handler(request: Request, exc: RequestValidationError):
errors = [
{'field': '.'.join(str(loc) for loc in error['loc'][1:]),
'message': error['msg'],
'code': error['type']}
for error in exc.errors()
]
return JSONResponse(
status_code=422,
media_type='application/problem+json',
content={
'type': 'https://api.example.com/errors/validation-failed',
'title': 'Validation Failed',
'status': 422,
'errors': errors,
}
)Client-Side Error Handling
// Typed error handling for API clients
interface ApiError {
type: string
title: string
status: number
detail?: string
errors?: Array<{ field: string; message: string; code: string }>
}
function isApiError(body: unknown): body is ApiError {
return typeof body === 'object' && body !== null && 'status' in body && 'title' in body
}
async function apiRequest<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...init?.headers },
...init,
})
const body = await res.json().catch(() => null)
if (!res.ok) {
if (isApiError(body)) {
// Show field-level errors to user
if (body.status === 422 && body.errors) {
throw new ValidationError(body.errors)
}
throw new ApiError(body.status, body.title, body.detail)
}
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
return body as T
}
// React error display
function FieldErrors({ errors }: { errors: Array<{ field: string; message: string }> }) {
return (
<ul>
{errors.map(e => <li key={e.field}><strong>{e.field}</strong>: {e.message}</li>)}
</ul>
)
}Security: What NOT to Include in JSON Errors
| Include | Never include | Why |
|---|---|---|
| Validation field names | Stack traces | Reveals code structure |
| Business rule messages | SQL query details | Reveals DB schema |
| Request correlation ID | Internal error codes | Aids attackers |
| Status code (integer) | Server file paths | Reveals server layout |
| Human-readable title | Database table names | Aids SQL injection |
// WRONG — exposes internal details
res.status(500).json({
error: 'Database query failed',
detail: 'SELECT * FROM users WHERE id = $1', // SQL exposed!
stack: err.stack, // stack trace exposed!
internalCode: 'DB_CONNECTION_POOL_EXHAUSTED',
})
// CORRECT — generic 500 with correlation ID for debugging
const correlationId = req.headers['x-request-id'] ?? crypto.randomUUID()
console.error({ correlationId, error: err.message, stack: err.stack }) // log internally
res.status(500)
.type('application/problem+json')
.json({
type: 'https://api.example.com/errors/internal-error',
title: 'Internal Server Error',
status: 500,
detail: 'An unexpected error occurred. Please try again.',
instance: `/errors/${correlationId}`, // correlation ID for support
})Definitions
- RFC 9457
- IETF standard defining Problem Details for HTTP APIs; specifies a machine-readable JSON format for error responses with type, title, status, detail, and instance fields.
- Problem Details
- The error response format from RFC 9457; Content-Type:
application/problem+json; provides consistent, self-describing error information that clients can parse programmatically. - 422 Unprocessable Entity
- HTTP status code for requests where the body is syntactically valid but semantically invalid; used for field validation errors in REST APIs.
- correlation ID
- A unique identifier (UUID) generated per request and included in both the error response and server logs; enables tracing a specific request through distributed systems for debugging.
- error extension member
- An additional field in a Problem Details JSON object beyond the RFC 9457 base fields; used for application-specific details like validation field errors, rate limit info, or resource identifiers.
FAQ
What is RFC 9457 Problem Details for HTTP APIs?
RFC 9457 is the IETF standard (published August 2023, replacing RFC 7807) that defines a JSON format for HTTP API error responses. It provides consistent, machine-readable error information with five core fields: type (a URI identifying the error category), title (a short human-readable string), status (the HTTP status code as an integer), detail (a longer explanation of this specific occurrence), and instance (a URI for this specific error occurrence). Custom extension members are allowed beyond these base fields. The Content-Type is application/problem+json. RFC 9457 is widely adopted by Spring Boot 6+, ASP.NET Core 7+, and is referenced in OpenAPI 3.x specifications.
What is the difference between 400 Bad Request and 422 Unprocessable Entity?
400 Bad Request means the request is syntactically malformed — invalid JSON, missing Content-Type, or an unparseable body. The server could not even understand the request. 422 Unprocessable Entity means the request is syntactically valid JSON that the server successfully parsed, but the data is semantically invalid — an email address in the wrong format, an age value below the minimum, or a username that is already taken. Many APIs use 400 for both cases, but RFC 9110 clarifies the distinction. Best practice: use 400 for parse failures, 422 for field validation errors where the body was parsed correctly.
How should I return validation errors in a JSON API?
Return all field errors at once in a single response — never make the user submit the form multiple times to discover each error. Include the field name, a human-readable message, and an optional machine-readable error code per error. Wrap them in an errors array extension member on the Problem Details object. Use HTTP status 422. Each error object should have at minimum a field and message property; an optional code property (like "invalid_format" or "min_value") helps client-side code handle errors programmatically without parsing the human-readable message string.
What JSON error format should I use for my REST API?
RFC 9457 Problem Details is the standard choice for new APIs: use application/problem+json and include type, title, and status at minimum. For simple internal APIs, a minimal {"error": "message", "code": "error_code"} is acceptable but less interoperable. JSON:API-compliant APIs should use the JSON:API error format with its errors array. GraphQL APIs conventionally use {"errors": [...]} inside a 200 OK response. Whatever format you choose, be completely consistent across all endpoints — mixed error formats are worse than any single imperfect format.
How do I handle JSON parsing errors in API middleware?
Catch SyntaxError thrown by JSON.parse and return 400 Bad Request — never 500. In Express with body-parser, the middleware emits a SyntaxError as a request error; catch it in your error-handling middleware by checking err instanceof SyntaxError && err.status === 400 && "body" in err. In FastAPI, Pydantic automatically catches body parse failures and raises RequestValidationError, which you can handle with a custom exception handler. Always return a clear, human-readable message like "Request body is not valid JSON" so API consumers can debug quickly.
Should I use 401 or 403 for authentication errors?
401 Unauthorized means no credentials were provided or the provided credentials are invalid or expired. The response should include a WWW-Authenticate header indicating the authentication scheme. 403 Forbidden means the credentials are valid and the user is authenticated, but they do not have permission to access the resource. Think of it as: 401 says "tell me who you are", 403 says "I know who you are but you cannot do this". A common mistake is returning 401 when a valid token lacks the required scope or role — that should be 403.
How do I log errors without exposing them in the JSON response?
Generate a unique correlation ID (UUID v4) per request and attach it to the request context. Log the full error including stack trace, SQL details, and internal codes to your structured logging system with the correlation ID as a field. In the JSON response, include only the correlation ID in the instance field — for example, /errors/f3fc8e22-a8e5-4c1e-9e1a-83a4d5b3a456. When users report errors, they provide the correlation ID; your support team looks it up in the logs to find the full details. This pattern gives you complete observability without leaking internal system information to potential attackers.
What HTTP status code should I return for rate limiting?
429 Too Many Requests is the correct status code for rate limiting. Always include a Retry-After header indicating when the client may try again — either as a number of seconds (Retry-After: 60) or an HTTP date. Also include rate limit metadata headers: X-RateLimit-Limit (total requests allowed per window), X-RateLimit-Remaining (requests remaining in the current window), and X-RateLimit-Reset (Unix timestamp when the window resets). The response body should explain the limit in the detail field: "Rate limit exceeded: 100 requests per minute. See Retry-After header for reset time."
Further reading and primary sources
- RFC 9457 Problem Details — The IETF standard defining Problem Details for HTTP APIs — the authoritative reference for the application/problem+json format
- HTTP Status Codes (MDN) — Complete reference for HTTP status codes with usage guidance and browser compatibility notes
- OpenAPI Error Responses — How to document error responses in OpenAPI 3.x specifications, including Problem Details schemas