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:

  1. What happened? (error code/type)
  2. Why? (human-readable message)
  3. 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

Don’t Leak Sensitive Information

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.