Middleware

Middleware

Middlewares are functions that wrap your core request handler, forming a chain. Each middleware can inspect or modify the Request before it is sent, and the Response before it is returned.

client.get(url)
    │
    ▼
┌─────────────────┐
│ logging         │  ← outermost: sees every attempt
│  ┌────────────┐ │
│  │ retry      │ │  ← wraps the actual dispatch
│  │  ┌───────┐ │ │
│  │  │adapter│ │ │  ← innermost: performs the HTTP request
│  │  └───────┘ │ │
│  └────────────┘ │
└─────────────────┘
    │
    ▼
Response

Middlewares are executed in the order they are passed to HttpClient. The first item in the list is the outermost wrapper; the last item is closest to the adapter.


Built-in Middlewares

retry

Automatically retries requests that fail with specified status codes or connection errors.

from src.middleware import retry

middleware=[retry(retries=3, delay=0.1)]

Parameters:

  • retries (int) — Number of retry attempts. Defaults to 3.

  • delay (float) — Delay in seconds between retries. Defaults to 0.1.

  • retry_on_status_code (set) — HTTP status codes to retry on. Defaults to {500, 502, 503, 504}.

Note

429 Too Many Requests is not included in the default retry set. This is intentional — retrying a rate-limited request immediately will likely result in another 429. If you want to handle it, pass it explicitly and combine with a longer delay:

retry(retries=3, delay=2.0, retry_on_status_code={429, 500, 502, 503, 504})

logging

Logs the HTTP method, URL, response status code, and elapsed time for each request.

from src.middleware import logging

middleware=[logging()]

Parameters:

  • logger (callable) — Function used for logging. Defaults to print.

By default this uses print, which is convenient for development. For production, plug in Python’s standard logging module instead:

import logging as stdlib_logging
from src.middleware import logging as log_middleware

logger = stdlib_logging.getLogger(__name__)

middleware=[log_middleware(logger=logger.info)]

timeout

Enforces a maximum duration for the entire request processing. Works with any adapter.

from src.middleware import timeout

middleware=[timeout(seconds=10.0)]

Parameters:

  • seconds (float) — Timeout duration in seconds. Defaults to 10.0.

Raises: TimeoutError if the request does not complete within the specified duration.

Warning

lightreq automatically suppresses the adapter’s own timeout when this middleware is present, to prevent a double-timer conflict. For example, when using the requests adapter, its built-in timeout parameter is disabled in favour of this middleware’s thread-based timer. Do not set both simultaneously.

rate_limit

Enforces a maximum number of requests within a rolling time window. Uses a thread-safe sliding-window counter — requests are never silently dropped, but delayed until they can proceed.

from src.middleware import rate_limit

middleware=[rate_limit(calls=10, period=1.0)]

Parameters:

  • calls (int) — Maximum number of calls allowed within period. Defaults to 10.

  • period (float) — Time window in seconds. Defaults to 1.0.

Raises: ValueError if calls is less than 1, or period is not positive.

Note

rate_limit is thread-safe. A single client instance can be safely shared across threads and the sliding window will be shared consistently across all of them.


Ordering Guide

The order in which middlewares are defined has significant effects on behavior. Below are the most important cases to understand.

logging + retry

# logging wraps retry — only logs once per client.get() call,
# regardless of how many retries happen internally
middleware=[logging(), retry(retries=3)]

# retry wraps logging — logs every individual attempt, including retries
middleware=[retry(retries=3), logging()]

Place retry inside logging (i.e. logging first) if you only want one log line per logical request. Place logging inside retry (i.e. retry first) if you want to trace every retry attempt individually.

timeout + retry

# timeout wraps retry — all retry attempts share a single timer.
# If the total time across retries exceeds the timeout, a TimeoutError is raised.
middleware=[timeout(seconds=5.0), retry(retries=3)]

# retry wraps timeout — each attempt gets its own independent timer.
# A single slow attempt won't abort the remaining retries.
middleware=[retry(retries=3), timeout(seconds=5.0)]

In most cases, placing retry before timeout (each attempt gets its own timer) is the safer choice.

retry + rate_limit

Warning

Retried requests count against the rate limit. If you have a strict rate limit and a high retry count, retries may be delayed significantly by the rate limiter. Design your calls and period values accordingly.


Middleware Cookbook

The following are ready-to-use combinations for common scenarios.

Resilient production client

Retries transient server errors, enforces a per-attempt timeout, and rate-limits outgoing traffic. Logging uses the standard library for structured output.

import logging as stdlib_logging
from src.client import HttpClient
from src.middleware import logging as log_middleware, retry, timeout, rate_limit

logger = stdlib_logging.getLogger(__name__)

client = HttpClient(
    adapter="requests",
    middleware=[
        log_middleware(logger=logger.info),
        rate_limit(calls=10, period=1.0),
        retry(retries=3, delay=0.5),
        timeout(seconds=5.0),
    ]
)

Verbose development client

Logs every retry attempt individually. Useful when debugging flaky endpoints.

from src.client import HttpClient
from src.middleware import logging, retry, timeout

client = HttpClient(
    adapter="urllib",
    middleware=[
        retry(retries=3, delay=0.2),
        logging(),
        timeout(seconds=10.0),
    ]
)

Writing Custom Middleware

Custom middlewares are closures that accept a next_handler and return a handler function.

def my_custom_middleware(next_handler):
    def handler(request):
        # modify or inspect the request before sending
        print("Sending request to:", request.url)

        response = next_handler(request)

        # modify or inspect the response before returning
        print("Received status:", response.status_code)

        return response
    return handler

Pass it to HttpClient like any built-in middleware:

from src.client import HttpClient

client = HttpClient(
    adapter="requests",
    middleware=[my_custom_middleware]
)