Custom Middleware

Difficulty: Advanced

Custom middleware allows you to add functionality that runs on every request or response. This is perfect for cross-cutting concerns that do not belong in individual views. Common custom middleware includes CORS handling, rate limiting, request ID tracking, maintenance mode, and custom error pages.

CORS (Cross-Origin Resource Sharing) middleware adds headers that allow or deny requests from different domains. While the django-cors-headers package handles this in production, understanding CORS middleware helps you understand both the concept and the middleware pattern. CORS headers like Access-Control-Allow-Origin and Access-Control-Allow-Methods tell browsers whether to allow cross-origin requests.

Request ID middleware generates a unique identifier for each request and attaches it to the request object and response headers. This is invaluable for debugging - you can trace a specific request through your logs, from the initial HTTP request through middleware, views, database queries, and back to the response.

Maintenance mode middleware checks a flag (from settings, database, or file) and returns a maintenance page for all requests when enabled. It typically excludes admin URLs and health check endpoints so administrators can still access the site.

Custom error handling middleware catches exceptions and returns appropriate error responses. While Django has built-in error handling (handler404, handler500), middleware provides more flexibility - you can log errors to external services, add context information, or transform errors into JSON for API endpoints.

When writing middleware, keep it lightweight. Middleware runs on every request, so expensive operations (like database queries) should be avoided or cached. If your middleware only applies to certain URLs or views, consider using a view decorator instead.

Code examples

CORS Middleware

class CORSMiddleware:
    """Add CORS headers to responses."""

    ALLOWED_ORIGINS = [
        'http://localhost:3000',
        'https://myapp.com',
    ]

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        # Handle preflight requests
        if request.method == 'OPTIONS':
            response = HttpResponse()
            self._add_cors_headers(request, response)
            return response

        response = self.get_response(request)
        self._add_cors_headers(request, response)
        return response

    def _add_cors_headers(self, request, response):
        origin = request.META.get('HTTP_ORIGIN', '')
        if origin in self.ALLOWED_ORIGINS:
            response['Access-Control-Allow-Origin'] = origin
            response['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
            response['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
            response['Access-Control-Max-Age'] = '86400'

CORS middleware adds Access-Control headers to responses. Preflight (OPTIONS) requests are handled separately with an empty response plus headers. In production, use the django-cors-headers package instead of rolling your own.

Request ID Middleware

import uuid
import logging

logger = logging.getLogger(__name__)

class RequestIDMiddleware:
    """Attach a unique ID to each request for tracing."""

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        # Check for incoming request ID or generate one
        request_id = request.META.get(
            'HTTP_X_REQUEST_ID',
            str(uuid.uuid4())
        )
        request.request_id = request_id

        # Add to logging context
        logger.info(
            f'[{request_id}] {request.method} {request.path}'
        )

        response = self.get_response(request)

        # Add request ID to response headers
        response['X-Request-ID'] = request_id

        logger.info(
            f'[{request_id}] Response: {response.status_code}'
        )

        return response

Each request gets a unique UUID. If the client sends an X-Request-ID header, we reuse it (for tracing across microservices). The ID is attached to the request object, logged, and included in the response header.

Maintenance Mode Middleware

from django.conf import settings
from django.http import HttpResponse
from django.template.loader import render_to_string

class MaintenanceModeMiddleware:
    """Return a maintenance page when enabled."""

    EXEMPT_URLS = ['/admin/', '/health/']

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if getattr(settings, 'MAINTENANCE_MODE', False):
            # Allow admin and health check URLs
            for url in self.EXEMPT_URLS:
                if request.path.startswith(url):
                    return self.get_response(request)

            html = render_to_string('maintenance.html')
            return HttpResponse(html, status=503)

        return self.get_response(request)

# settings.py
# MAINTENANCE_MODE = True   # Set to True to enable

When MAINTENANCE_MODE is True, all non-exempt requests get a 503 Service Unavailable response. Admin and health check URLs are exempt so administrators can still manage the site.

JSON Error Handling Middleware

import json
import logging
import traceback
from django.http import JsonResponse

logger = logging.getLogger(__name__)

class JSONErrorMiddleware:
    """Convert exceptions to JSON responses for API endpoints."""

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        return response

    def process_exception(self, request, exception):
        # Only handle API requests
        if not request.path.startswith('/api/'):
            return None

        logger.error(
            f'API Error: {exception}',
            exc_info=True,
        )

        return JsonResponse(
            {
                'error': str(exception),
                'type': exception.__class__.__name__,
            },
            status=500,
        )

process_exception catches unhandled view exceptions. This middleware only handles API endpoints (paths starting with /api/) and returns JSON error responses. Non-API errors use Django's default handling.

Key points

Concepts covered

CORS, Rate Limiting, Request ID, Maintenance Mode, Error Handling