Bad error handling wastes everyone’s time. A cryptic “Error 500” sends developers on a debugging odyssey. A well-designed error response tells them exactly what went wrong and how to fix it. Here’s how to build the latter.
The Anatomy of a Good Error#
Every error response should answer three questions:
- What happened? (error code/type)
- Why? (human-readable message)
- How do I fix it? (actionable guidance)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| {
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"message": "Invalid email format",
"received": "not-an-email"
},
{
"field": "age",
"message": "Must be a positive integer",
"received": "-5"
}
],
"documentation_url": "https://api.example.com/docs/errors#VALIDATION_ERROR"
},
"request_id": "req_abc123"
}
|
Always include:
- A machine-readable error code (for programmatic handling)
- A human-readable message (for debugging)
- A request ID (for log correlation)
- Relevant context (what value failed, what was expected)
HTTP Status Codes: Use Them Correctly#
Status codes exist for a reason. Use them:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # 4xx: Client errors (the request was wrong)
400 # Bad Request - malformed syntax, invalid JSON
401 # Unauthorized - no/invalid authentication
403 # Forbidden - authenticated but not allowed
404 # Not Found - resource doesn't exist
409 # Conflict - resource state conflict (duplicate email)
422 # Unprocessable Entity - valid syntax but semantic errors
429 # Too Many Requests - rate limited
# 5xx: Server errors (we messed up)
500 # Internal Server Error - unexpected failure
502 # Bad Gateway - upstream service failed
503 # Service Unavailable - temporarily down
504 # Gateway Timeout - upstream timeout
|
Common mistakes:
- Using 200 with
{"success": false} — use proper status codes - Using 500 for validation errors — that’s a 400 or 422
- Using 404 for authorization failures — that’s a 403 (don’t leak existence)
Structured Error Responses#
Consistency matters. Define a schema and stick to it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
| from dataclasses import dataclass, asdict
from typing import Optional, List
from enum import Enum
class ErrorCode(Enum):
VALIDATION_ERROR = "VALIDATION_ERROR"
AUTHENTICATION_REQUIRED = "AUTHENTICATION_REQUIRED"
PERMISSION_DENIED = "PERMISSION_DENIED"
RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND"
RATE_LIMITED = "RATE_LIMITED"
INTERNAL_ERROR = "INTERNAL_ERROR"
@dataclass
class FieldError:
field: str
message: str
received: Optional[str] = None
@dataclass
class APIError:
code: ErrorCode
message: str
details: Optional[List[FieldError]] = None
documentation_url: Optional[str] = None
request_id: Optional[str] = None
def to_response(self, status_code: int):
return JSONResponse(
status_code=status_code,
content={"error": asdict(self)}
)
# Usage
def validate_user(data: dict) -> List[FieldError]:
errors = []
if not data.get('email') or '@' not in data.get('email', ''):
errors.append(FieldError(
field='email',
message='Valid email address required',
received=data.get('email')
))
if not data.get('password') or len(data.get('password', '')) < 8:
errors.append(FieldError(
field='password',
message='Password must be at least 8 characters'
))
return errors
@app.post("/users")
def create_user(request: Request, data: dict):
errors = validate_user(data)
if errors:
return APIError(
code=ErrorCode.VALIDATION_ERROR,
message="Invalid user data",
details=errors,
request_id=request.state.request_id
).to_response(422)
# ... create user
|
Errors can leak security-sensitive details:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| # BAD: Leaks internal details
{
"error": "psycopg2.OperationalError: FATAL: password authentication failed for user 'dbadmin'"
}
# BAD: Confirms user existence (security issue)
{
"error": "Invalid password for user admin@example.com"
}
# GOOD: Generic message, details in logs
{
"error": {
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred",
"request_id": "req_abc123"
}
}
# GOOD: Doesn't leak existence
{
"error": {
"code": "AUTHENTICATION_FAILED",
"message": "Invalid email or password"
}
}
|
Rule: Log the full stack trace internally. Return a sanitized message externally.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| @app.exception_handler(Exception)
async def handle_exception(request: Request, exc: Exception):
request_id = request.state.request_id
# Log full details internally
logger.exception(
f"Unhandled exception",
extra={
"request_id": request_id,
"path": request.url.path,
"method": request.method,
}
)
# Return sanitized response
return APIError(
code=ErrorCode.INTERNAL_ERROR,
message="An unexpected error occurred. Please try again.",
request_id=request_id
).to_response(500)
|
Request IDs: The Debugging Lifeline#
Every request gets a unique ID. Include it everywhere:
1
2
3
4
5
6
7
8
9
10
11
12
| import uuid
from starlette.middleware.base import BaseHTTPMiddleware
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
request_id = request.headers.get('X-Request-ID') or str(uuid.uuid4())
request.state.request_id = request_id
response = await call_next(request)
response.headers['X-Request-ID'] = request_id
return response
|
Now when a user reports “I got an error,” they can provide the request ID, and you can find exactly what happened:
1
2
3
| $ grep "req_abc123" /var/log/app/errors.log
2024-03-01 07:30:00 ERROR [req_abc123] Unhandled exception in create_user
2024-03-01 07:30:00 ERROR [req_abc123] Traceback: ...
|
Retry Guidance#
For transient errors, tell clients when and how to retry:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| @dataclass
class RetryableError(APIError):
retry_after: Optional[int] = None # Seconds
def to_response(self, status_code: int):
response = super().to_response(status_code)
if self.retry_after:
response.headers['Retry-After'] = str(self.retry_after)
return response
# Rate limit hit
return RetryableError(
code=ErrorCode.RATE_LIMITED,
message="Rate limit exceeded. Please slow down.",
retry_after=60,
request_id=request_id
).to_response(429)
# Temporary outage
return RetryableError(
code=ErrorCode.SERVICE_UNAVAILABLE,
message="Service temporarily unavailable",
retry_after=30,
request_id=request_id
).to_response(503)
|
Validation Errors: Be Specific#
Return all validation errors at once, not one at a time:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| # BAD: User has to fix and retry repeatedly
{"error": "email is required"}
# ... fix and retry ...
{"error": "password is too short"}
# ... fix and retry ...
{"error": "username contains invalid characters"}
# GOOD: All errors at once
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Multiple validation errors",
"details": [
{"field": "email", "message": "Required field"},
{"field": "password", "message": "Must be at least 8 characters"},
{"field": "username", "message": "Only letters, numbers, and underscores allowed"}
]
}
}
|
Client-Side Error Handling#
Good API errors enable good client handling:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| async function createUser(data) {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
switch (error.error.code) {
case 'VALIDATION_ERROR':
// Show field-specific errors
error.error.details.forEach(detail => {
showFieldError(detail.field, detail.message);
});
break;
case 'RATE_LIMITED':
const retryAfter = response.headers.get('Retry-After');
showToast(`Please wait ${retryAfter} seconds`);
break;
case 'AUTHENTICATION_REQUIRED':
redirectToLogin();
break;
default:
showToast(`Error: ${error.error.message}`);
console.error('Request ID:', error.error.request_id);
}
throw new APIError(error);
}
return response.json();
}
|
The Test: Can Someone Fix It?#
For every error your API returns, ask: “If a developer sees this at 2 AM, can they fix it?”
- “Error 500” — No. What happened? Where? Why?
- “Database connection failed” — Maybe. Which database? Is it config or network?
- “Failed to connect to PostgreSQL at db.internal:5432. Connection refused. Check if the database is running and accessible.” — Yes. Clear next steps.
Errors are documentation. Write them for the person debugging at 2 AM. That person might be you.