API Error Handling Best Practices — Complete Guide
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 Design — Rate Limiting — API 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