Back to Blog
API rate limiting
API development tools
API architecture
GraphQL

API Error Handling and Status Codes

8 min read
K
Kevin
API Security Specialist

API Error Handling and Status Codes

Introduction to HTTP Status Codes

  • 1xx (Informational): Request received, continuing process
  • 2xx (Success): Request successfully received, understood, and accepted
  • 3xx (Redirection): Further action needed to complete request
  • 4xx (Client Error): Request contains bad syntax or cannot be fulfilled
  • 5xx (Server Error): Server failed to fulfill valid request

Common Status Codes and Their Meanings

2xx Success Codes

200 OK: The standard success response for most GET, PUT, and PATCH requests. Indicates the request succeeded and the response body contains the requested data.

@app.get("/users/{user_id}")
def get_user(user_id: int):
    user = db.get_user(user_id)
    if user:
        return jsonify(user), 200
    return {"error": "User not found"}, 404

201 Created: Successful resource creation, typically used with POST requests. Should include a Location header pointing to the new resource.

@app.post("/users")
def create_user():
    user_data = request.get_json()
    new_user = User.create(user_data)
    return jsonify(new_user.to_dict()), 201, {
        'Location': f'/users/{new_user.id}'
    }

204 No Content: Success but no response body, commonly used for DELETE operations or updates where no data needs to be returned.

@app.delete("/users/{user_id}")
def delete_user(user_id: int):
    user = db.get_user(user_id)
    if user:
        db.delete_user(user_id)
        return '', 204
    return {"error": "User not found"}, 404

4xx Client Error Codes

400 Bad Request: Generic client error when the server cannot process the request due to malformed syntax, invalid parameters, or other client-side issues.

@app.post("/orders")
def create_order():
    try:
        order_data = request.get_json()
        validate_order_data(order_data)  # Raises ValidationError
        order = Order.create(order_data)
        return jsonify(order.to_dict()), 201
    except ValidationError as e:
        return {
            "error": "Invalid order data",
            "details": str(e)
        }, 400

401 Unauthorized: Authentication is required and has failed or not been provided. Should include a WWW-Authenticate header when appropriate.

@app.get("/protected")
def protected_resource():
    auth_header = request.headers.get('Authorization')
    if not auth_header or not validate_token(auth_header):
        return {
            "error": "Authentication required"
        }, 401, {
            'WWW-Authenticate': 'Bearer realm="api"'
        }
    # Process request...

403 Forbidden: The client is authenticated but doesn't have permission to access the resource.

@app.get("/admin/users")
def get_all_users():
    if not current_user.is_admin:
        return {
            "error": "Insufficient permissions"
        }, 403
    users = db.get_all_users()
    return jsonify(users), 200

404 Not Found: The requested resource doesn't exist at the specified URL.

@app.get("/products/{product_id}")
def get_product(product_id: int):
    product = db.get_product(product_id)
    if not product:
        return {
            "error": f"Product {product_id} not found"
        }, 404
    return jsonify(product.to_dict()), 200

429 Too Many Requests: The client has exceeded rate limits. Should include Retry-After header indicating when to retry.

def rate_limit_middleware():
    client_ip = request.remote_addr
    if rate_limiter.is_limited(client_ip):
        return {
            "error": "Rate limit exceeded"
        }, 429, {
            'Retry-After': str(rate_limiter.get_retry_after(client_ip))
        }

5xx Server Error Codes

500 Internal Server Error: Generic server error when no more specific error is appropriate.

@app.post("/process")
def process_data():
    try:
        result = complex_processing(request.get_json())
        return jsonify(result), 200
    except Exception as e:
        logger.error(f"Unexpected error: {e}")
        return {
            "error": "Internal server error"
        }, 500

503 Service Unavailable: The server is temporarily unavailable, often due to maintenance or overload.

@app.before_request
def check_maintenance():
    if maintenance_mode.is_active():
        return {
            "error": "Service temporarily unavailable",
            "estimated_recovery": maintenance_mode.estimated_recovery_time()
        }, 503, {
            'Retry-After': str(maintenance_mode.retry_after_seconds())
        }

Structured Error Responses

Consistent error response formatting improves developer experience and simplifies client-side error handling. A well-structured error response should include:

  • Machine-readable error code
  • Human-readable message
  • Additional context or details
  • Potential recovery instructions
class APIError(Exception):
    def __init__(self, message, status_code=400, error_code=None, details=None):
        self.message = message
        self.status_code = status_code
        self.error_code = error_code
        self.details = details

def error_handler(error):
    response = {
        "error": {
            "code": error.error_code or f"HTTP_{error.status_code}",
            "message": error.message,
            "timestamp": datetime.utcnow().isoformat()
        }
    }
    if error.details:
        response["error"]["details"] = error.details
    return jsonify(response), error.status_code

@app.errorhandler(APIError)
def handle_api_error(error):
    return error_handler(error)

@app.errorhandler(404)
def handle_not_found(error):
    return error_handler(APIError(
        "Resource not found",
        status_code=404,
        error_code="RESOURCE_NOT_FOUND"
    ))

Validation Error Handling

from pydantic import BaseModel, ValidationError, validator
from typing import List

class CreateUserRequest(BaseModel):
    email: str
    password: str
    roles: List[str] = []
    
    @validator('email')
    def validate_email(cls, v):
        if not re.match(r'[^@]+@[^@]+\.[^@]+', v):
            raise ValueError('Invalid email format')
        return v.lower()

@app.post("/users")
def create_user():
    try:
        user_data = CreateUserRequest.parse_obj(request.get_json())
        user = User.create(user_data.dict())
        return jsonify(user.to_dict()), 201
    except ValidationError as e:
        return {
            "error": "Validation failed",
            "details": e.errors()
        }, 400

Authentication and Authorization Errors

Implement comprehensive auth error handling with appropriate status codes and security headers.

def require_auth(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        auth_header = request.headers.get('Authorization')
        if not auth_header:
            raise APIError(
                "Authentication required",
                status_code=401,
                error_code="AUTH_REQUIRED"
            )
        
        try:
            token = auth_header.split(' ')[1]
            user = verify_token(token)
            if not user:
                raise APIError(
                    "Invalid token",
                    status_code=401,
                    error_code="INVALID_TOKEN"
                )
            request.current_user = user
        except IndexError:
            raise APIError(
                "Invalid authorization header format",
                status_code=401,
                error_code="INVALID_AUTH_HEADER"
            )
        return f(*args, **kwargs)
    return decorated

Rate Limiting Implementation

Implement comprehensive rate limiting with clear error responses and retry information.

class RateLimiter:
    def __init__(self, redis_client, limits):
        self.redis = redis_client
        self.limits = limits  # {'ip': 100, 'user': 1000} per hour
    
    def is_limited(self, identifier, limit_type):
        key = f"rate_limit:{limit_type}:{identifier}"
        current = self.redis.incr(key)
        if current == 1:
            self.redis.expire(key, 3600)  # 1 hour TTL
        
        limit = self.limits.get(limit_type, 100)
        return current > limit
    
    def get_retry_after(self, identifier, limit_type):
        key = f"rate_limit:{limit_type}:{identifier}"
        ttl = self.redis.ttl(key)
        return max(ttl, 1)

@app.before_request
def check_rate_limit():
    limiter = current_app.rate_limiter
    client_ip = request.remote_addr
    
    if limiter.is_limited(client_ip, 'ip'):
        raise APIError(
            "IP rate limit exceeded",
            status_code=429,
            error_code="RATE_LIMIT_EXCEEDED",
            details={
                "retry_after": limiter.get_retry_after(client_ip, 'ip'),
                "limit_type": "ip"
            }
        )
    
    if hasattr(request, 'current_user'):
        user_id = request.current_user.id
        if limiter.is_limited(user_id, 'user'):
            raise APIError(
                "User rate limit exceeded",
                status_code=429,
                error_code="RATE_LIMIT_EXCEEDED",
                details={
                    "retry_after": limiter.get_retry_after(user_id, 'user'),
                    "limit_type": "user"
                }
            )

Database and External Service Errors

Handle database and external service failures gracefully with appropriate status codes and logging.

@app.post("/orders")
def create_order():
    try:
        order_data = request.get_json()
        with db.transaction():
            order = Order.create(order_data)
            inventory_service.reserve_items(order.items)
            payment_service.process_payment(order.payment_info)
        
        return jsonify(order.to_dict()), 201
        
    except db.IntegrityError as e:
        logger.warning(f"Database integrity error: {e}")
        raise APIError(
            "Data conflict occurred",
            status_code=409,
            error_code="DATA_CONFLICT"
        )
        
    except inventory_service.OutOfStockError as e:
        raise APIError(
            "Items out of stock",
            status_code=422,
            error_code="OUT_OF_STOCK",
            details={"items": e.out_of_stock_items}
        )
        
    except payment_service.PaymentError as e:
        raise APIError(
            "Payment processing failed",
            status_code=402,
            error_code="PAYMENT_FAILED",
            details={"reason": e.reason}
        )
        
    except ExternalServiceError as e:
        logger.error(f"External service error: {e}")
        raise APIError(
            "Service temporarily unavailable",
            status_code=503,
            error_code="EXTERNAL_SERVICE_UNAVAILABLE"
        )

Content Negotiation and Media Type Errors

Handle unsupported media types and content negotiation errors appropriately.

@app.before_request
def check_content_type():
    if request.method in ['POST', 'PUT', 'PATCH']:
        content_type = request.content_type
        if not content_type or not content_type.startswith('application/json'):
            raise APIError(
                "Unsupported media type",
                status_code=415,
                error_code="UNSUPPORTED_MEDIA_TYPE",
                details={"supported_types": ["application/json"]}
            )

@app.after_request
def set_content_type(response):
    if response.status_code == 406:
        response.headers['Content-Type'] = 'application/json'
    return response

Custom Error Classes and Centralized Handling

Create a comprehensive error handling system with custom exception classes and centralized error handling.

class APIError(Exception):
    base_message = "An error occurred"
    
    def __init__(self, message=None, status_code=400, error_code=None,
                 details=None, headers=None):
        self.message = message or self.base_message
        self.status_code = status_code
        self.error_code = error_code or self.__class__.__name__
        self.details = details
        self.headers = headers or {}
        super().__init__(self.message)

class ValidationError(APIError):
    base_message = "Validation failed"
    status_code = 400

class AuthenticationError(APIError):
    base_message = "Authentication required"
    status_code = 401

class PermissionError(APIError):
    base_message = "Insufficient permissions"
    status_code = 403

class NotFoundError(APIError):
    base_message = "Resource not found"
    status_code = 404

class RateLimitError(APIError):
    base_message = "Rate limit exceeded"
    status_code = 429

def register_error_handlers(app):
    @app.errorhandler(APIError)
    def handle_api_error(error):
        response = {
            "error": {
                "code": error.error_code,
                "message": error.message,
                "timestamp": datetime.utcnow().isoformat()
            }
        }
        if error.details:
            response["error"]["details"] = error.details
        
        return jsonify(response), error.status_code, error.headers
    
    @app.errorhandler(404)
    def handle_not_found(e):
        return handle_api_error(NotFoundError())
    
    @app.errorhandler(500)
    def handle_internal_error(e):
        logger.error(f"Internal server error: {e}")
        return handle_api_error(APIError(
            "Internal server error",
            status_code=500,
            error_code="INTERNAL_ERROR"
        ))

Testing Error Responses

Comprehensive testing ensures error handling works correctly across all scenarios.

def test_authentication_error(client):
    response = client.get('/protected')
    assert response.status_code == 401
    assert response.json['error']['code'] == 'AUTHENTICATION_ERROR'
    assert 'WWW-Authenticate' in response.headers

def test_validation_error(client):
    response = client.post('/users', json={'email': 'invalid'})
    assert response.status_code == 400
    assert response.json['error']['code'] == 'VALIDATION_ERROR'
    assert 'details' in response.json['error']

def test_rate_limit(client, redis_mock):
    for _ in range(101):
        response = client.get('/api/data')
    assert response.status_code == 429
    assert 'Retry-After' in response.headers

def test_database_error(client, db_mock):
    db_mock.side_effect = DatabaseError("Connection failed")
    response = client.get('/users/1')
    assert response.status_code == 503
    assert response.json['error']['code'] == 'SERVICE_UNAVAILABLE'

Monitoring and Logging

Implement comprehensive monitoring and logging for error tracking and analysis.

class ErrorLogger:
    def __init__(self):
        self.logger = logging.getLogger('api.errors')
        self.metrics = MetricsClient()
    
    def log_error(self, error, request):
        error_data = {
            'timestamp': datetime.utcnow().isoformat(),
            'status_code': getattr(error, 'status_code', 500),
            'error_code': getattr(error, 'error_code', 'UNKNOWN'),
            'path': request.path,
            'method': request.method,
            'client_ip': request.remote_addr,
            'user_agent': request.headers.get('User-Agent'),
            'error_message': str(error)
        }
        
        self.logger.error(json.dumps(error_data))
        self.metrics.increment('api.errors', tags={
            'status_code': error_data['status_code'],
            'error_code': error_data['error_code'],
            'path': error_data['path']
        })

def error_middleware(app):
    error_logger = ErrorLogger()
    
    @app.errorhandler(Exception)
    def handle_all_errors(error):
        error_logger.log_error(error, request)
        
        if isinstance(error, APIError):
            response = error_handler(error)
        else:
            response = error_handler(APIError(
                "Internal server error",
                status_code=500,
                error_code="INTERNAL_ERROR"
            ))
        
        return response

Best Practices and Implementation Guidelines

Consistent Error Format

Maintain consistent error response structure across all endpoints. Use standardized field names and response formats.

Appropriate Status Codes

Choose the most specific status code for each error condition. Avoid overusing generic codes like 400 or 500 when more specific codes are available.

Security Considerations

Avoid exposing sensitive information in error responses. Sanitize error messages and avoid stack traces in production.

Client Guidance

Provide actionable error messages and, when appropriate, include guidance on how to resolve the issue or when to retry.

Documentation

Document all possible error responses in your API documentation, including status codes, error codes, and potential resolutions.

Monitoring and Alerting

Implement comprehensive monitoring of error rates and patterns. Set up alerts for unusual error patterns or increased error rates.

By following these practices and continuously refining your error handling approach, you'll build more reliable and maintainable APIs that provide excellent developer experiences.

Back to Blog