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 to3.delay(float) — Delay in seconds between retries. Defaults to0.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 toprint.
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 to10.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 withinperiod. Defaults to10.period(float) — Time window in seconds. Defaults to1.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]
)