Skip to content

Middleware

Middleware lets you customize request handling without changing the code that makes HTTP requests.

OxideHTTP supports middleware configured globally through RequestConfig or individually for a specific request.

Registering Middleware

Register middleware globally to apply it to requests made by a client:

from oxidehttp import OxideHTTP
from oxidehttp.config import OxideHTTPConfig, RequestConfig

config = OxideHTTPConfig(
    request=RequestConfig(
        middlewares=(
            # Middleware instances
        )
    )
)

async with OxideHTTP(config) as client:
    response = await client.get('/users')

You can also pass middleware to an individual request:

response = await client.get(
    '/users',
    middlewares=(
        # Middleware instances
    ),
)

This is useful when middleware should only affect selected requests.

Middleware Interface

Middleware implementations expose an asynchronous run method that receives the request and the next middleware in the chain.

Conceptually, the method has this interface:

async def run(self, request: Request, next_req: Next) -> Response:
    response = await next_req.run(request)
    return response

A middleware can inspect or modify a request before forwarding it, and inspect or modify the response afterward.

The Request, Response, and Next types come from the underlying pyreqwest middleware interface.

RetryMiddleware

RetryMiddleware retries requests when certain transport errors or HTTP status codes occur.

Basic Usage

from oxidehttp.middlewares.retry.middleware import RetryMiddleware

retry = RetryMiddleware()

config = OxideHTTPConfig(request=RequestConfig(middlewares=(retry,)))

Options

Option Type Default Description
max_attempts int 3 Maximum total number of attempts, including the initial request.
backoff_factor float 0.5 Base delay used for exponential backoff.
max_delay float 30.0 Maximum exponential backoff delay, in seconds.
respect_retry_after bool True Whether to respect the response's Retry-After header.
max_retry_after float \| None None Optional maximum delay from Retry-After, in seconds.
jitter bool True Randomizes exponential backoff delays.
status_codes Iterable[int] See below HTTP status codes that trigger retries.
retry_methods Iterable[str] See below HTTP methods eligible for retries.
logger Logger \| None None Logger used for retry messages.

The default retry status codes are:

(408, 429, 500, 502, 503, 504)

The default retry methods are:

('GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE')

Method names are compared directly, so use uppercase HTTP method names.

Maximum Attempts

max_attempts includes the initial request.

For example:

retry = RetryMiddleware(max_attempts=3)

The request can be executed up to three times: one initial attempt and up to two retries.

max_attempts must be at least 1.

Exponential Backoff

The exponential backoff delay is calculated using:

\[ \text{delay} = \min(\text{backoff\_factor} \times 2^{(\text{attempt}-1)}, \text{max\_delay}) \]

For example:

retry = RetryMiddleware(
    max_attempts=5, backoff_factor=0.5, max_delay=10.0, jitter=False
)

Without jitter, the delays before subsequent attempts are:

Attempt Delay
2 0.5 seconds
3 1 second
4 2 seconds
5 4 seconds

The maximum exponential delay is capped by max_delay.

Jitter

Jitter is enabled by default.

Instead of always waiting for the full exponential backoff delay, OxideHTTP chooses a random delay between zero and the calculated delay, with millisecond resolution.

This helps prevent multiple clients from retrying simultaneously.

Disable it when deterministic delays are preferred:

retry = RetryMiddleware(jitter=False)

Retry-After

When respect_retry_after is enabled, the middleware checks the response's Retry-After header.

It supports:

  • Integer delay values, representing seconds.
  • HTTP-date values, representing the time when the client should retry.

For example:

Retry-After: 10

Or:

Retry-After: Wed, 21 Oct 2026 07:28:00 GMT

By default, the middleware does not impose a maximum delay on a valid Retry-After value.

Use max_retry_after to cap it:

retry = RetryMiddleware(respect_retry_after=True, max_retry_after=60.0)

This option is independent of max_delay, which applies to exponential backoff.

Retry Conditions

A request is retried when:

  1. The maximum number of attempts has not been reached.
  2. Its HTTP method is included in retry_methods.
  3. The response status is included in status_codes.

Transport errors raised as PyreqwestError are also retried, provided another attempt is available.

Other exceptions are not caught by the retry middleware.

Important Considerations

Retrying requests can cause duplicate operations if the server processes a request but the client fails to receive its response.

The default retry methods avoid automatically retrying POST and PATCH. Add other methods only when their operations are safe to retry or protected by an idempotency mechanism.

RequestIDMiddleware

RequestIDMiddleware generates or propagates a request identifier.

It adds the identifier to the outgoing request headers and, after receiving a response, adds the same identifier to the response headers.

Basic Usage

from oxidehttp.middlewares.requestid.middleware import RequestIDMiddleware

request_id = RequestIDMiddleware()

config = OxideHTTPConfig(request=RequestConfig(middlewares=(request_id,)))

By default, identifiers are generated using UUID4.

The default header name is:

X-Request-ID

Options

Option Type Default Description
request_id_header str \| None None Header used to propagate the request ID. Defaults to X-Request-ID.
request_id_factory Callable[[], str] \| None None Function used to generate request IDs. Defaults to UUID4 generation.
logger Logger \| None None Logger used for generated request IDs.

Custom Header

Change the header name when integrating with an API that uses a different request ID convention:

request_id = RequestIDMiddleware(request_id_header='X-Correlation-ID')

Custom Request ID Factory

Provide your own function to generate request IDs:

from uuid import uuid4

request_id = RequestIDMiddleware(request_id_factory=lambda: f'app-{uuid4()}')

The factory must be a synchronous callable that returns a string.

Providing an Existing Request ID

You can provide a request ID through request extensions using the request_id key.

response = await client.get(
    '/users', extensions={'request_id': 'my-request-123'}
)

The middleware uses the supplied ID instead of generating a new one.

The request ID is added using setdefault, so an existing request header is preserved. The response header is also only added when it does not already exist.

Request ID Extensions Type

The middleware also provides an Extensions type that extends the base OxideHTTP extensions schema with an optional request ID:

from oxidehttp.schema import Extensions as BaseExtensions


class Extensions(BaseExtensions):
    request_id: str | None

This allows request IDs to be represented in the typed extensions schema.

Rate Limiting

RateLimitMiddleware limits how frequently requests are allowed to proceed.

It uses a RateLimitStore implementation to track acquisitions. OxideHTTP includes an in-memory store and a Redis-backed store.

Basic Usage

from oxidehttp.config import OxideHTTPConfig, RequestConfig
from oxidehttp.middlewares.ratelimit.middleware import RateLimitMiddleware
from oxidehttp.middlewares.ratelimit.stores.memory import MemoryRateLimitStore

rate_limit = RateLimitMiddleware(MemoryRateLimitStore(), limit=10, period=60.0)

config = OxideHTTPConfig(request=RequestConfig(middlewares=(rate_limit,)))

This configuration allows up to 10 acquisitions per 60-second window for each rate-limit key.

Options

Argument Type Default Description
store RateLimitStore Required Backend used to track rate limits.
limit int Required Maximum number of acquisitions per period.
period float Required Rate-limit window duration in seconds.

Validation rules:

  • limit must be at least 1.
  • period must be greater than 0.

Rate-Limit Keys

The middleware constructs a key using the request hostname and an optional rateLimitKey request extension.

The resulting key follows this format:

hostname:rateLimitKey

If no extension key is provided, only the hostname is used.

For example, requests to:

https://api.example.com/users
https://api.example.com/posts

share the same rate-limit bucket by default because they use the same hostname.

Custom Rate-Limit Keys

Use the rateLimitKey request extension to separate rate-limit buckets:

response = await client.get('/users', extensions={'rateLimitKey': 'users'})

Another request can use a different bucket:

response = await client.get('/posts', extensions={'rateLimitKey': 'posts'})

For the same hostname, these requests use different keys:

api.example.com:users
api.example.com:posts

This is useful when different API endpoints have independent rate limits.

The extension key is optional. If omitted, requests to the same hostname share a bucket.

MemoryRateLimitStore

MemoryRateLimitStore stores rate-limit windows in process memory:

from oxidehttp.middlewares.ratelimit.stores.memory import MemoryRateLimitStore

store = MemoryRateLimitStore()

Characteristics:

  • State is local to the current process.
  • State is lost when the process restarts.
  • Separate application processes do not share rate-limit state.
  • Concurrent acquisitions are coordinated using an asynchronous lock.
  • Requests wait asynchronously when the limit is reached.

Use this backend when rate limiting only needs to apply within a single process.

RedisRateLimitStore

RedisRateLimitStore stores rate-limit counters in Redis, allowing multiple application instances to share rate-limit state.

Install the Redis dependency:

uv add "oxidehttp[redis]"

Create the store using an asynchronous Redis client:

from redis.asyncio import Redis

from oxidehttp.middlewares.ratelimit.stores.redis import RedisRateLimitStore

redis = Redis.from_url('redis://localhost:6379')

store = RedisRateLimitStore(redis)

Options

Argument Type Default Description
redis Redis Required Asynchronous Redis client.
prefix str "oxidehttp:ratelimit:" Prefix used for Redis keys.

You can customize the prefix:

store = RedisRateLimitStore(redis, prefix='myapp:ratelimit:')

The Redis implementation uses a Lua script to atomically increment counters and initialize their expiration.

When the configured limit is exceeded, the store waits for the remaining key expiration time before attempting to acquire again.

Choosing a Store

Store Best suited for
MemoryRateLimitStore Single-process applications.
RedisRateLimitStore Multiple processes or application instances sharing a Redis server.

Both stores implement the RateLimitStore protocol and can be used with RateLimitMiddleware.

RateLimitStore Protocol

Custom storage backends can implement the RateLimitStore protocol:

class RateLimitStore(Protocol):
    async def acquire(
        self, key: str, *, limit: int, period: float
    ) -> float: ...

The method receives the rate-limit key, maximum number of acquisitions, and window duration.

It returns the amount of time waited, in seconds. RateLimitMiddleware awaits this method before forwarding the request.

Rate-Limit Extensions

The middleware provides an Extensions type for its request extension:

class Extensions(TypedDict):
    rateLimitKey: str | None

Use rateLimitKey when you need independent rate-limit buckets for requests sharing the same hostname.