Contents

Chapter 1

Error Handling

A complete guide to designing consistent, developer-friendly error responses for your REST API. Covers RFC 7807 Problem Details, error catalogs, retry logic, and rate limiting.


Table of Contents

1. Why Error Design Matters

2. RFC 7807 Problem Details

3. Error Response Structure

4. Error Categories

5. Validation Errors

6. Rate Limiting

7. Retry-Friendly Design

8. Error Logging and Correlation

9. Security Considerations

10. Error Catalog Design


Why Error Design Matters

Your error responses are the most-read part of your API documentation. When things work, developers don't read docs. When things break, they read error messages.

A good error response:

  • Tells the developer what went wrong
  • Tells the developer why it went wrong
  • Tells the developer how to fix it
  • Includes enough context for debugging without leaking internal details
  • Uses a consistent format across all endpoints

A bad error response:

json
{"error": "Bad request"}

This tells the developer nothing. They'll file a support ticket. You'll spend 30 minutes asking "which endpoint? what request body?"

A good error response:

json
{
  "type": "https://api.example.com/errors/validation-error",
  "title": "Validation Error",
  "status": 422,
  "detail": "The 'email' field must be a valid email address.",
  "instance": "/users",
  "errors": [
    {
      "field": "email",
      "message": "Must be a valid email address",
      "value": "not-an-email"
    }
  ]
}

RFC 7807 Problem Details

RFC 7807 defines a standard format for error responses in HTTP APIs. It was updated by RFC 9457 in 2023.

Standard Fields

FieldTypeRequiredDescription
typeURIYesA URI that identifies the error type. Should point to documentation.
titlestringYesA short, human-readable summary of the error type.
statusintegerYesThe HTTP status code.
detailstringNoA human-readable explanation specific to this occurrence.
instancestringNoA URI that identifies the specific occurrence (e.g., the request path).

Extension Fields

You can add custom fields beyond the standard ones:

Custom FieldPurpose
errorsArray of field-level validation errors
request_idCorrelation ID for debugging
retry_afterWhen the client should retry (seconds or ISO 8601 timestamp)
help_urlLink to relevant documentation
error_codeMachine-readable error code (e.g., ORDER_NOT_FOUND)

Content Type

Error responses using RFC 7807 MUST use the content type:

Content-Type: application/problem+json

This tells clients to parse the response as a Problem Details object, not a regular JSON response.

Example

http
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/insufficient-permissions",
  "title": "Insufficient Permissions",
  "status": 403,
  "detail": "Your API key does not have permission to delete orders. Required scope: orders:delete",
  "instance": "/orders/ord_abc123",
  "error_code": "INSUFFICIENT_PERMISSIONS",
  "required_scope": "orders:delete",
  "current_scopes": ["orders:read", "orders:write"]
}

Error Response Structure

Use this structure for ALL error responses:

json
{
  "type": "https://api.example.com/errors/{error-type}",
  "title": "Human-Readable Error Title",
  "status": 400,
  "detail": "Specific explanation of what went wrong for this particular request.",
  "instance": "/the/endpoint/that/failed",
  "error_code": "MACHINE_READABLE_CODE",
  "request_id": "req_abc123xyz",
  "timestamp": "2026-06-15T14:30:00Z"
}

Field Guidelines

type -- Should be a stable URI. It doesn't need to resolve to a real page, but it's nice if it does. Use kebab-case for the error slug:

  • https://api.example.com/errors/validation-error
  • https://api.example.com/errors/rate-limit-exceeded
  • https://api.example.com/errors/resource-not-found

title -- Static per error type. Don't include request-specific details here:

  • Good: "Validation Error"
  • Bad: "The email field 'foo@' is invalid"

detail -- Dynamic and specific to this request:

  • Good: "The 'email' field value 'foo@' is not a valid email address"
  • Bad: "Validation Error" (that's the title, not the detail)

error_code -- Machine-readable code for clients to switch on:

  • VALIDATION_ERROR, RESOURCE_NOT_FOUND, RATE_LIMIT_EXCEEDED
  • Use SCREAMING_SNAKE_CASE
  • Document all possible codes in your API reference

Error Categories

400 Bad Request

For malformed requests: invalid JSON, wrong content type, missing required headers.

json
{
  "type": "https://api.example.com/errors/malformed-request",
  "title": "Malformed Request",
  "status": 400,
  "detail": "Request body is not valid JSON. Unexpected token '}' at position 42.",
  "error_code": "MALFORMED_JSON"
}

401 Unauthorized

Missing or invalid authentication.

json
{
  "type": "https://api.example.com/errors/authentication-required",
  "title": "Authentication Required",
  "status": 401,
  "detail": "The Authorization header is missing or contains an invalid token.",
  "error_code": "AUTH_REQUIRED",
  "help_url": "https://api.example.com/docs/authentication"
}

Always include WWW-Authenticate header:

http
WWW-Authenticate: Bearer realm="api.example.com", error="invalid_token"

403 Forbidden

Authenticated but not authorized.

json
{
  "type": "https://api.example.com/errors/forbidden",
  "title": "Forbidden",
  "status": 403,
  "detail": "Your API key does not have the 'orders:delete' scope.",
  "error_code": "FORBIDDEN",
  "required_scope": "orders:delete"
}

404 Not Found

Resource doesn't exist.

json
{
  "type": "https://api.example.com/errors/not-found",
  "title": "Resource Not Found",
  "status": 404,
  "detail": "No order found with ID 'ord_nonexistent'.",
  "error_code": "NOT_FOUND",
  "resource_type": "order",
  "resource_id": "ord_nonexistent"
}

409 Conflict

State conflicts: duplicates, version mismatches, invalid state transitions.

json
{
  "type": "https://api.example.com/errors/conflict",
  "title": "Resource Conflict",
  "status": 409,
  "detail": "Order ord_abc123 is already shipped and cannot be cancelled.",
  "error_code": "INVALID_STATE_TRANSITION",
  "current_state": "shipped",
  "attempted_transition": "cancelled",
  "allowed_transitions": ["delivered", "returned"]
}

422 Unprocessable Entity

Business rule violations and validation errors.

json
{
  "type": "https://api.example.com/errors/validation-error",
  "title": "Validation Error",
  "status": 422,
  "detail": "2 validation errors in request body.",
  "error_code": "VALIDATION_ERROR",
  "errors": [
    {
      "field": "email",
      "message": "Must be a valid email address",
      "code": "INVALID_FORMAT",
      "value": "not-an-email"
    },
    {
      "field": "quantity",
      "message": "Must be between 1 and 999",
      "code": "OUT_OF_RANGE",
      "value": 0,
      "constraints": {"min": 1, "max": 999}
    }
  ]
}

500 Internal Server Error

Something broke on the server. Never expose stack traces or internal details.

json
{
  "type": "https://api.example.com/errors/internal-error",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "An unexpected error occurred. Please try again or contact support.",
  "error_code": "INTERNAL_ERROR",
  "request_id": "req_abc123xyz"
}

The request_id lets the user contact support with a reference. You look up the full stack trace in your logs using this ID.


Validation Errors

Per-Field Error Array

Always return ALL validation errors at once, not just the first one. Nobody wants to play whack-a-mole:

json
{
  "type": "https://api.example.com/errors/validation-error",
  "title": "Validation Error",
  "status": 422,
  "detail": "3 validation errors in request body.",
  "errors": [
    {
      "field": "email",
      "message": "Must be a valid email address",
      "code": "INVALID_FORMAT"
    },
    {
      "field": "shipping_address.zip_code",
      "message": "Must be a 5-digit US zip code",
      "code": "INVALID_FORMAT"
    },
    {
      "field": "items[0].quantity",
      "message": "Must be a positive integer",
      "code": "INVALID_TYPE"
    }
  ]
}

Field Path Convention

Use dot notation for nested fields and bracket notation for array indices:

Field PathMeaning
emailTop-level field
shipping_address.cityNested object field
items[0].quantityFirst item's quantity
items[2].product_idThird item's product ID
metadata.tags[1]Second tag in metadata

Standard Validation Codes

CodeMeaningExample
REQUIREDField is missingemail is required
INVALID_TYPEWrong data typeExpected integer, got string
INVALID_FORMATWrong formatNot a valid email/URL/date
TOO_SHORTBelow minimum lengthMust be at least 8 characters
TOO_LONGAbove maximum lengthMust be at most 255 characters
OUT_OF_RANGENumeric value out of boundsMust be between 1 and 999
NOT_UNIQUEDuplicate valueEmail already registered
INVALID_ENUMNot an allowed valueMust be one of: pending, shipped, delivered
PATTERN_MISMATCHDoesn't match regexMust match pattern: ^[A-Z]{2}[0-9]{6}$

Rate Limiting

Response Headers

Include these headers on EVERY response (not just 429 errors):

http
X-RateLimit-Limit: 1000           # Max requests per window
X-RateLimit-Remaining: 742        # Requests left in current window
X-RateLimit-Reset: 1718460000     # Unix timestamp when window resets

429 Error Response

json
{
  "type": "https://api.example.com/errors/rate-limit-exceeded",
  "title": "Rate Limit Exceeded",
  "status": 429,
  "detail": "You have exceeded 1000 requests per hour. Try again in 258 seconds.",
  "error_code": "RATE_LIMIT_EXCEEDED",
  "retry_after": 258,
  "rate_limit": {
    "limit": 1000,
    "window": "1 hour",
    "reset_at": "2026-06-15T15:00:00Z"
  }
}

Always include Retry-After header:

http
Retry-After: 258

Rate Limit Strategies

StrategyDescriptionBest For
Fixed windowN requests per hour/minuteSimple, predictable
Sliding windowN requests in the last 60 secondsSmoother, prevents burst at window boundaries
Token bucketTokens refill at a steady rateAllows short bursts while limiting sustained rate
Leaky bucketRequests processed at fixed rate, excess queuedSmoothest output rate

Retry-Friendly Design

Retryable vs. Non-Retryable Errors

Status CodeRetryable?Why
400NoClient needs to fix the request
401NoClient needs new credentials
403NoPermission issue -- retrying won't help
404NoResource doesn't exist
409MaybeIf it's a race condition, retry might work
422NoBusiness rule violation -- retrying won't help
429YesRate limit -- wait and retry
500YesTransient server error -- retry might work
502YesUpstream failure -- retry might work
503YesTemporary overload -- retry after backoff

Including Retry Guidance

For retryable errors, include a retry_after field:

json
{
  "type": "https://api.example.com/errors/service-unavailable",
  "title": "Service Temporarily Unavailable",
  "status": 503,
  "detail": "The order processing service is temporarily overloaded.",
  "error_code": "SERVICE_UNAVAILABLE",
  "retryable": true,
  "retry_after": 30,
  "retry_strategy": "exponential_backoff"
}

Client-Side Retry Pattern

Recommend this pattern in your API documentation:

python
import time
import random

def retry_with_backoff(request_func, max_retries=3, base_delay=1.0):
    """Retry a request with exponential backoff and jitter."""
    for attempt in range(max_retries + 1):
        response = request_func()
        
        if response.status_code < 500 and response.status_code != 429:
            return response  # Non-retryable -- return immediately
        
        if attempt == max_retries:
            return response  # Final attempt -- return whatever we got
        
        # Calculate delay with exponential backoff + jitter
        delay = base_delay * (2 ** attempt)
        jitter = random.uniform(0, delay * 0.1)
        
        # Respect Retry-After header if present
        retry_after = response.headers.get("Retry-After")
        if retry_after:
            delay = max(delay, float(retry_after))
        
        time.sleep(delay + jitter)
    
    return response

Error Logging and Correlation

Request ID Pattern

Generate a unique request ID for every API call:

http
# Response header
X-Request-Id: req_f47ac10b58cc4372

# Include in error responses
{
  "request_id": "req_f47ac10b58cc4372"
}

Client-Provided Correlation ID

Accept a client-provided trace ID and echo it back:

http
# Request
X-Correlation-Id: trace_from_client_abc

# Response (echo the client's ID + add your own)
X-Correlation-Id: trace_from_client_abc
X-Request-Id: req_f47ac10b58cc4372

What to Log (Server Side)

For every error, log:

  • Request ID
  • Timestamp
  • HTTP method and path
  • Status code
  • Error code
  • Client IP (hashed for privacy)
  • User agent
  • Authenticated user/API key (redacted)
  • Full stack trace (for 500s only)

Do NOT log:

  • Full request/response bodies (PII risk)
  • Authorization tokens
  • Passwords or secrets

Security Considerations

Don't Leak Internal Details

json
// BAD: exposes stack trace, database info, file paths
{
  "error": "NullPointerException at OrderService.java:142",
  "sql": "SELECT * FROM orders WHERE id = 'ord_abc'; -- injected",
  "stack": ["at com.acmecorp.api.OrderService.getOrder(OrderService.java:142)", "..."]
}

// GOOD: generic message with request ID for internal lookup
{
  "type": "https://api.example.com/errors/internal-error",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "An unexpected error occurred.",
  "request_id": "req_f47ac10b"
}

404 vs. 403 for Hidden Resources

If a user requests a resource they don't have permission to see, should you return 403 (forbidden) or 404 (not found)?

ResponseImplication
403Confirms the resource exists (information disclosure)
404Hides the resource's existence

Recommendation: Use 404 unless the user knowing the resource exists is not a security concern. Most APIs use 404 for resources the user can't access.

Consistent Error Format for Auth Errors

Don't return different error shapes for different auth failures. This helps attackers enumerate valid credentials:

json
// BAD: different messages reveal which part is wrong
{"error": "User not found"}        // Reveals that the username is wrong
{"error": "Invalid password"}      // Reveals that the username is correct

// GOOD: same message for all auth failures
{"error": "Invalid credentials"}   // Doesn't reveal which part is wrong

Error Catalog Design

Structure

Maintain a central error catalog that maps error codes to HTTP status, titles, and detail templates:

python
ERROR_CATALOG = {
    "VALIDATION_ERROR": {
        "status": 422,
        "title": "Validation Error",
        "type_slug": "validation-error",
    },
    "NOT_FOUND": {
        "status": 404,
        "title": "Resource Not Found",
        "type_slug": "not-found",
    },
    "RATE_LIMIT_EXCEEDED": {
        "status": 429,
        "title": "Rate Limit Exceeded",
        "type_slug": "rate-limit-exceeded",
    },
    # ... more error codes
}

Benefits

1. Consistency: All endpoints return the same error format

2. Documentation: Auto-generate error reference from the catalog

3. Testing: Verify that error responses match the catalog

4. Localization: Map error codes to translated messages

See the src/error_envelope.py module for a complete Python implementation of this pattern.


*Previous: Pagination & Filtering | Next: Idempotency & Caching*

Chapter 2

Idempotency & Caching

A reference for building reliable, high-performance APIs using idempotency keys, conditional requests (ETags), cache control headers, and stale-while-revalidate patterns.


Table of Contents

1. Idempotency Fundamentals

2. Idempotency Keys

3. Conditional Requests with ETags

4. Cache-Control Strategies

5. Stale-While-Revalidate

6. Cache Invalidation Patterns

7. CDN Integration


Idempotency Fundamentals

An operation is idempotent if performing it multiple times produces the same result as performing it once.

HTTP Method Idempotency

MethodIdempotent?Why
GETYesReading data doesn't change it
PUTYesReplacing a resource with the same data = same result
DELETEYesDeleting an already-deleted resource = still deleted
PATCHNo*Relative updates like {"increment": 1} are not idempotent
POSTNoCreating a resource twice creates two resources

*PATCH can be made idempotent if you use absolute values ({"status": "shipped"}) instead of relative ones ({"increment_total": 100}).

Why Idempotency Matters

Network failures happen. Clients retry. Without idempotency:

Client → POST /orders → Server processes → Server responds
                                                ↑
                                           Network drops response
Client → POST /orders → Server processes again → DUPLICATE ORDER CREATED

With idempotency:

Client → POST /orders (Idempotency-Key: abc) → Server processes → Server responds
                                                                        ↑
                                                                   Network drops response
Client → POST /orders (Idempotency-Key: abc) → Server recognizes key → Returns cached response
                                                                        NO DUPLICATE

Idempotency Keys

How They Work

1. The client generates a unique key (UUID v4) and sends it with the request

2. The server processes the request and stores the result keyed by the idempotency key

3. If the server receives the same key again, it returns the stored result instead of processing again

Request Format

http
POST /orders
Content-Type: application/json
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{
  "customer_id": "cust_42",
  "items": [{"product_id": "prod_101", "quantity": 2}]
}

Server Behavior

Receive request with Idempotency-Key
├── Key not seen before
│   ├── Process the request
│   ├── Store: key → {status, headers, body}
│   └── Return the response
├── Key seen before, processing complete
│   └── Return the stored response (exact same status + body)
├── Key seen before, still processing
│   └── Return 409 Conflict ("Request is being processed")
└── Key seen before, different request body
    └── Return 422 Unprocessable Entity ("Idempotency key reused with different parameters")

Response Headers

http
HTTP/1.1 201 Created
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Idempotent-Replayed: false      # false on first call, true on replays

# On replay:
HTTP/1.1 201 Created
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Idempotent-Replayed: true       # Signals this is a cached replay

Storage and Expiry

AspectRecommendation
Storage backendRedis or database table
Key formatUUID v4 (client-generated)
TTL24-72 hours (long enough for retries, short enough to not fill storage)
What to storeStatus code, response headers, response body
ScopePer API key / per user (don't share idempotency keys across users)

Implementation Pattern

python
import uuid
from datetime import datetime, timedelta

# Server-side idempotency store (simplified)
IDEMPOTENCY_STORE: dict[str, dict] = {}
KEY_TTL = timedelta(hours=24)

def handle_idempotent_request(
    idempotency_key: str,
    request_hash: str,
    process_func,
) -> tuple[int, dict]:
    """Handle a request with idempotency.
    
    Args:
        idempotency_key: Client-provided UUID.
        request_hash: Hash of the request body (to detect reuse with different params).
        process_func: Callable that processes the request and returns (status, body).
    
    Returns:
        Tuple of (status_code, response_body).
    """
    if idempotency_key in IDEMPOTENCY_STORE:
        stored = IDEMPOTENCY_STORE[idempotency_key]
        
        # Check if the key was used with different request parameters
        if stored["request_hash"] != request_hash:
            return 422, {
                "error": "Idempotency key reused with different parameters"
            }
        
        # Check if the stored result has expired
        if datetime.utcnow() - stored["created_at"] > KEY_TTL:
            del IDEMPOTENCY_STORE[idempotency_key]
            # Fall through to process the request
        else:
            # Return the cached result
            stored["replayed"] = True
            return stored["status"], stored["body"]
    
    # Process the request
    status, body = process_func()
    
    # Store the result
    IDEMPOTENCY_STORE[idempotency_key] = {
        "status": status,
        "body": body,
        "request_hash": request_hash,
        "created_at": datetime.utcnow(),
        "replayed": False,
    }
    
    return status, body

Which Endpoints Need Idempotency Keys?

Endpoint TypeNeeds Idempotency?Why
POST (create)YesPrevents duplicate creation
POST (action, e.g., /cancel)YesPrevents double-cancellation side effects
PUT (replace)NoAlready idempotent by definition
PATCH (update)MaybeIf it has side effects (emails, webhooks)
DELETENoAlready idempotent
GETNoAlready safe and idempotent

Conditional Requests with ETags

What's an ETag?

An Entity Tag is a fingerprint of a resource's current state. It's a hash of the response body (or a version number) that the server includes in the response.

http
# Server response with ETag
HTTP/1.1 200 OK
ETag: "a3f2b8c1d4e56789"
Content-Type: application/json

{"id": "ord_abc123", "status": "pending", "total": 5999}

Strong vs. Weak ETags

TypeFormatMeaningUse For
Strong"a3f2b8c1"Byte-for-byte identicalConditional updates, cache validation
WeakW/"a3f2b8c1"Semantically equivalentCaching where minor formatting changes don't matter

Conditional GET (Cache Validation)

The client caches the response and the ETag. On the next request, it sends If-None-Match:

http
# Client sends cached ETag
GET /orders/ord_abc123
If-None-Match: "a3f2b8c1d4e56789"

# If nothing changed: 304 Not Modified (no body -- saves bandwidth)
HTTP/1.1 304 Not Modified
ETag: "a3f2b8c1d4e56789"

# If something changed: 200 OK with new data and new ETag
HTTP/1.1 200 OK
ETag: "new_etag_value"
Content-Type: application/json

{"id": "ord_abc123", "status": "shipped", "total": 5999}

Conditional PUT/PATCH (Optimistic Locking)

Prevent lost updates when two clients modify the same resource simultaneously:

http
# Client A reads the order (gets ETag)
GET /orders/ord_abc123
→ ETag: "version_1"

# Client A updates the order (sends If-Match with the ETag)
PUT /orders/ord_abc123
If-Match: "version_1"
Content-Type: application/json

{"status": "shipped"}

# Scenario 1: No one else modified it → 200 OK
HTTP/1.1 200 OK
ETag: "version_2"

# Scenario 2: Client B modified it first → 412 Precondition Failed
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/precondition-failed",
  "title": "Precondition Failed",
  "status": 412,
  "detail": "The resource has been modified since you last retrieved it. Fetch the latest version and retry.",
  "current_etag": "version_2_by_client_b"
}

ETag Generation

python
import hashlib
import json

def generate_etag(data: dict | str) -> str:
    """Generate a strong ETag from data.
    
    Uses SHA-256 hash of the JSON-serialized data.
    """
    if isinstance(data, dict):
        content = json.dumps(data, sort_keys=True, separators=(",", ":"))
    else:
        content = str(data)
    
    hash_value = hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
    return f'"{hash_value}"'

See the src/etag.py module for a complete implementation.


Cache-Control Strategies

Cache-Control Header

The Cache-Control header tells clients and intermediaries how to cache the response.

Common Directives

DirectiveMeaningExample
publicAny cache can store thisCDN-cacheable content
privateOnly the end-user's browser can cache thisUser-specific data
no-cacheMust revalidate with server before using cached copyAlways fresh, but allows caching
no-storeDon't cache at allSensitive data, PII
max-age=NCache for N secondsmax-age=3600 = 1 hour
s-maxage=NCache for N seconds (shared caches / CDN only)CDN-specific TTL
must-revalidateWhen stale, must revalidate before useCritical data
stale-while-revalidate=NServe stale content for N seconds while revalidating in backgroundPerformance optimization
immutableContent will never changeVersioned assets

Cache Profiles by Endpoint Type

Endpoint TypeCache-ControlRationale
User profileprivate, max-age=60User-specific, changes occasionally
Product catalogpublic, max-age=300, s-maxage=600Shared data, CDN-friendly
Order detailsprivate, no-cacheUser-specific, needs freshness
Static reference datapublic, max-age=86400, immutableCountry codes, currencies -- rarely change
Search resultsprivate, max-age=30User-specific, changes often
Authenticated endpointprivate, no-storeNever cache auth-dependent data in shared caches
Health checkno-storeMust always be fresh

Example Responses

http
# Product listing (cacheable by CDN for 10 minutes)
HTTP/1.1 200 OK
Cache-Control: public, max-age=300, s-maxage=600
ETag: "product_list_v42"
Vary: Accept, Accept-Encoding

# User's order history (only browser cache, revalidate always)
HTTP/1.1 200 OK
Cache-Control: private, no-cache
ETag: "user_orders_v15"

# Auth token response (never cache)
HTTP/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache

The Vary Header

Vary tells caches that different versions of the response exist based on request headers:

http
# This response varies by Accept header and Authorization
Vary: Accept, Accept-Encoding, Authorization

Without Vary, a CDN might serve a JSON response to a client requesting XML, or serve user A's data to user B.


Stale-While-Revalidate

How It Works

stale-while-revalidate lets clients use a stale cached response immediately while fetching a fresh copy in the background:

http
Cache-Control: public, max-age=60, stale-while-revalidate=300

This means:

  • For the first 60 seconds: serve from cache (fresh)
  • From 60-360 seconds: serve from cache (stale) but fetch a new copy in the background
  • After 360 seconds: cache is truly expired, must fetch before responding

Timeline Visualization

0s              60s                   360s
|--- fresh ------|--- stale-but-usable ---|--- expired ---|
                 ↑
                 Background revalidation starts here
                 Client gets instant (stale) response

When to Use

ScenarioSWR ValueRationale
Product catalogstale-while-revalidate=600Slightly stale prices are OK for 10 min
User dashboardstale-while-revalidate=30Short staleness OK, feels instant
Stock pricesDon't use SWRStaleness is never acceptable
Blog postsstale-while-revalidate=3600Posts rarely change, staleness is fine

Cache Invalidation Patterns

Event-Based Invalidation

When a resource changes, invalidate its cache:

python
def update_order(order_id: str, updates: dict) -> dict:
    """Update an order and invalidate its cache."""
    order = database.update(order_id, updates)
    
    # Invalidate the specific order cache
    cache.delete(f"order:{order_id}")
    
    # Invalidate the order list cache (since the list content changed)
    cache.delete_pattern("order_list:*")
    
    return order

Surrogate Keys (CDN-Level)

Use Surrogate-Key headers to group cached responses for bulk invalidation:

http
# Response for GET /orders/ord_abc123
Surrogate-Key: order-ord_abc123 customer-cust_42 orders

# When order changes, invalidate by surrogate key:
# PURGE all responses tagged with "order-ord_abc123"

Cache-Control: no-cache + ETag (Revalidation Pattern)

The safest caching strategy: always revalidate, but avoid re-downloading if nothing changed.

http
# Server response
Cache-Control: no-cache
ETag: "v42"

# Client's next request
GET /orders/ord_abc123
If-None-Match: "v42"

# Server response (nothing changed)
HTTP/1.1 304 Not Modified
# No body transferred -- saves bandwidth

This gives you freshness guarantees with bandwidth savings. The trade-off is a round-trip to the server on every request (but no data transfer if nothing changed).


CDN Integration

CDN-Friendly Response Headers

http
# Cacheable by CDN (different TTL for CDN vs. browser)
Cache-Control: public, max-age=60, s-maxage=3600
Surrogate-Control: max-age=3600      # Fastly/Varnish-specific
Vary: Accept, Accept-Encoding         # Important: tells CDN to cache variants

Cache Segmentation

Configure your CDN to cache different variants based on:

HeaderSegment ByExample
AcceptResponse formatJSON vs. XML
Accept-LanguageLanguageEnglish vs. Finnish
AuthorizationUserNever cache across users
Accept-EncodingCompressiongzip vs. brotli

CDN Cache Keys

Your CDN cache key should include:

  • URL path
  • Relevant query parameters (filter and sort params, not analytics params)
  • Vary header values

Exclude from cache key:

  • Analytics parameters (utm_source, ref)
  • Timestamp parameters (_t=123456)
  • Debug parameters (debug=true)

Quick Reference

PatternHeaderUse Case
Prevent duplicate creationIdempotency-Key: POST requests that create resources
Cache validationETag + If-None-MatchBandwidth-efficient freshness checks
Optimistic lockingETag + If-MatchPrevent lost updates
Browser cachingCache-Control: private, max-age=60User-specific data
CDN cachingCache-Control: public, s-maxage=3600Shared, public data
Instant but freshstale-while-revalidate=300Performance-critical endpoints
Never cacheCache-Control: no-storeSensitive / PII data

*Previous: Error Handling | Back to README*

Chapter 3
🔒 Available in full product

Pagination & Filtering

Chapter 4
🔒 Available in full product

REST API Design Principles

Chapter 5
🔒 Available in full product

API Versioning Strategies

You’ve reached the end of the free preview

Get the full REST API Design Guide and unlock everything.

All Chapters

Get the complete guide with every chapter unlocked, including code samples, diagrams, and best practices.

Full Tool Suite

Access all interactive tools with complete data, all workload profiles, and the full scenario library.

Source Files

Downloadable source code, configuration files, and working examples from every chapter.

Lifetime Updates

Free updates for life. Every new chapter, tool, and improvement included.

Buy Now — $29 →
📦 Free sample included — download another copy for the full product.
REST API Design Guide v1.0.0 — Free Preview