Skip to content

API Error Handling Best Practices — Complete Guide

DodaTech Updated 2026-06-24 5 min read

In this tutorial, you'll learn about API Error Handling Best Practices. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

API error handling requires consistent error response formats, appropriate HTTP status codes, clear messages, and retry strategies so clients can handle failures programmatically.

What You'll Learn

You will learn how to design a consistent error response format, choose the right status codes, handle validation errors, implement retry logic, and use idempotency keys.

Why Error Handling Matters

Poor error handling forces developers to guess what went wrong. A generic {"error": "Bad request"} tells the client nothing. Good error responses eliminate support tickets and make integrations self-diagnosing. DodaTech's partner API processes millions of requests daily — clear errors reduce integration time from days to hours.

Consistent Error Response Format

Every error should follow the same schema so clients parse errors generically.

from flask import Flask, jsonify, request
from werkzeug.exceptions import HTTPException

app = Flask(__name__)

class APIError(Exception):
    def __init__(self, message, status_code=400, code=None, details=None):
        self.message = message
        self.status_code = status_code
        self.code = code or "INTERNAL_ERROR"
        self.details = details or []

@app.errorhandler(APIError)
def handle_api_error(error):
    response = {
        "error": {
            "code": error.code,
            "message": error.message,
            "details": error.details,
            "request_id": request.headers.get("X-Request-ID", "unknown"),
        }
    }
    return jsonify(response), error.status_code

@app.errorhandler(HTTPException)
def handle_http_error(error):
    response = {
        "error": {
            "code": error.name.upper().replace(" ", "_"),
            "message": error.description,
            "details": [],
            "request_id": request.headers.get("X-Request-ID", "unknown"),
        }
    }
    return jsonify(response), error.code

Expected output:

curl -i http://localhost:5000/v2/threats/invalid
# HTTP/1.1 404 NOT FOUND
# {
#   "error": {
#     "code": "NOT_FOUND",
#     "message": "The requested resource was not found",
#     "details": [],
#     "request_id": "req_a1b2c3d4"
#   }
# }

Status Code Selection

@app.route("/v2/threats/<threat_id>")
def get_threat(threat_id):
    threat = fetch_threat(threat_id)
    if not threat:
        raise APIError(
            message=f"Threat {threat_id} not found",
            status_code=404,
            code="THREAT_NOT_FOUND",
        )
    return jsonify(threat)

@app.route("/v2/threats", methods=["POST"])
def create_threat():
    data = request.get_json()
    if not data:
        raise APIError(
            message="Request body is required",
            status_code=400,
            code="INVALID_REQUEST_BODY",
        )
    if "name" not in data:
        raise APIError(
            message="Threat name is required",
            status_code=422,
            code="VALIDATION_ERROR",
            details=[{"field": "name", "reason": "required"}],
        )
    threat_id = insert_threat(data)
    return jsonify({"id": threat_id}), 201

Expected output:

curl -X POST http://localhost:5000/v2/threats \
  -H "Content-Type: application/json" \
  -d '{"severity": "high"}'
# HTTP/1.1 422 UNPROCESSABLE ENTITY
# {
#   "error": {
#     "code": "VALIDATION_ERROR",
#     "message": "Threat name is required",
#     "details": [
#       {"field": "name", "reason": "required"}
#     ],
#     "request_id": "req_e5f6g7h8"
#   }
# }

Retry-After and Rate Limiting

import time

RATE_LIMITS = {}

@app.route("/v2/threats/search")
def search_threats():
    client_ip = request.remote_addr
    now = time.time()
    window = 60

    # Simple sliding window rate check
    timestamps = RATE_LIMITS.get(client_ip, [])
    timestamps = [t for t in timestamps if now - t < window]
    RATE_LIMITS[client_ip] = timestamps

    if len(timestamps) >= 30:
        retry_after = int(window - (now - timestamps[0]))
        response = jsonify({
            "error": {
                "code": "RATE_LIMIT_EXCEEDED",
                "message": "Too many requests. Please slow down.",
                "details": [
                    {"limit": 30, "window_seconds": window, "retry_after": retry_after}
                ],
            }
        })
        response.status_code = 429
        response.headers["Retry-After"] = str(retry_after)
        response.headers["X-RateLimit-Limit"] = "30"
        response.headers["X-RateLimit-Remaining"] = "0"
        return response

    timestamps.append(now)
    return jsonify({"results": search(request.args.get("q", ""))})

Expected output:

# After 30+ requests in 60 seconds
curl -i http://localhost:5000/v2/threats/search?q=emotet
# HTTP/1.1 429 TOO MANY REQUESTS
# Retry-After: 42
# X-RateLimit-Limit: 30
# X-RateLimit-Remaining: 0
# {
#   "error": {
#     "code": "RATE_LIMIT_EXCEEDED",
#     "message": "Too many requests. Please slow down.",
#     "details": [{"limit": 30, "window_seconds": 60, "retry_after": 42}]
#   }
# }

Idempotency for Safe Retries

import uuid

IDEMPOTENCY_STORE = {}

@app.route("/v2/threats/report", methods=["POST"])
def report_threat():
    idempotency_key = request.headers.get("Idempotency-Key")
    if not idempotency_key:
        raise APIError(
            message="Idempotency-Key header is required",
            status_code=400,
            code="MISSING_IDEMPOTENCY_KEY",
        )

    # Check if we've already processed this request
    if idempotency_key in IDEMPOTENCY_STORE:
        return jsonify(IDEMPOTENCY_STORE[idempotency_key])

    data = request.get_json()
    threat_id = insert_threat(data)

    result = {"id": threat_id, "status": "created"}
    IDEMPOTENCY_STORE[idempotency_key] = result
    return jsonify(result), 201

Expected output:

# First request
curl -X POST http://localhost:5000/v2/threats/report \
  -H "Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000" \
  -H "Content-Type: application/json" \
  -d '{"name": "New Threat", "hash": "abc123"}'
# HTTP/1.1 201 Created
# {"id": 42, "status": "created"}

# Retry with same key (network retry)
curl -X POST http://localhost:5000/v2/threats/report \
  -H "Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000" \
  -H "Content-Type: application/json" \
  -d '{"name": "New Threat", "hash": "abc123"}'
# HTTP/1.1 200 OK (or 201 Created)
# {"id": 42, "status": "created"}  ← Same result, no duplicate
flowchart TD
    A["Client Request"] --> B{"Has\nIdempotency-Key?"}
    B -->|No| C["Return 400\nMISSING_IDEMPOTENCY_KEY"]
    B -->|Yes| D{"Key exists\nin store?"}
    D -->|Yes| E["Return cached\nresponse"]
    D -->|No| F["Process request"]
    F --> G["Store result\nby key"]
    G --> H["Return response"]
    style D fill:#dbeafe,stroke:#2563eb
    style E fill:#bbf7d0,stroke:#16a34a
    style F fill:#fef3c7,stroke:#d97706

Common Errors

1. Returning 200 for Business Errors

A validation failure is not a 200. Use 400 for bad input, 422 for validation, 404 for missing resources, 409 for conflicts, and 429 for rate limits.

2. Inconsistent Error Format

One endpoint returns {"error": "msg"}, another returns {"message": "msg"}. Clients cannot parse errors generically. Use a single schema everywhere.

3. Leaking Stack Traces

Returning {"error": "ZeroDivisionError: division by zero"} exposes internals. Always return user-facing messages and log the full trace server-side.

4. No Request ID in Errors

Without a request ID, debugging an error requires guessing which request failed. Always include a unique request ID in every error response.

5. Missing Retry-After for 429

Clients do not know when to retry. Always include the Retry-After header with a number of seconds.

Practice Questions

1. What four fields should every API error response include?

Code (machine-readable), message (human-readable), details (validation errors), and request_id (for debugging).

2. When should you return 422 vs 400?

400 is for malformed syntax (invalid JSON, missing body). 422 is for valid syntax but invalid semantics (missing required fields, invalid enum values).

3. What is the purpose of the Idempotency-Key header?

It ensures retrying a request produces the same result as the first attempt, preventing duplicate processing. The client generates a unique key for each operation.

4. Challenge: Design an error response for a batch threat submission endpoint where some threats succeed and some fail.

{
  "data": {"succeeded": 45, "failed": 5},
  "errors": [
    {"index": 3, "code": "INVALID_HASH", "message": "Hash format is invalid"},
    {"index": 7, "code": "DUPLICATE", "message": "Threat already exists"}
  ]
}

Mini Project: Robust Error Handler

Build a Flask error handling middleware for DodaTech's threat API that catches all exceptions, returns consistent JSON errors, logs full traces server-side, and includes request IDs in every response.

Related Tutorials

RESTful API DesignRate LimitingAPI Gateway


Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. Updated 2026-06-24.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro