Middleware¶
Middleware wraps the request/response cycle — it runs before a handler sees the request and after it produces a response. Use it for cross-cutting concerns: CORS, compression, security headers, logging.
Adding middleware¶
app.add_middleware() accepts middleware in two forms — a configured
instance, or a class together with its keyword options:
from veloce import CORSMiddleware, Veloce
app = Veloce()
# Instance form — build the middleware, then add it.
app.add_middleware(
CORSMiddleware(
allow_origins=["*"],
allow_methods=["GET", "POST", "PUT", "DELETE"],
)
)
# Class form — pass the class and its options; Veloce constructs it.
app.add_middleware(CORSMiddleware, allow_origins=["*"])
Middleware can also be passed when constructing the app, via the
middleware=[...] argument to Veloce(...).
Veloce middleware vs ASGI middleware¶
add_middleware accepts two distinct shapes, and it tells them apart by
what the class subclasses:
- A
Middlewaresubclass (or instance) is Veloce-native middleware. It definesprocess_request(request)and/orprocess_response(request, response)and runs inside Veloce's own pipeline, with access to the parsedRequestand per-route exclusion. Every built-in in the table below is this shape. - Any other class is treated as a standard ASGI middleware: Veloce
constructs it as
MiddlewareClass(app, **options)when the ASGI stack is assembled, so it wraps the whole application at the scope/receive/send level. This is the seam for third-party ASGI middleware — tracing, profiling, observability — that expects to wrap an ASGI app.
from veloce import Middleware, Request, Response, Veloce
app = Veloce()
# Veloce-native: split request/response hooks.
class StampMiddleware(Middleware):
async def process_response(self, request: Request, response: Response) -> Response:
response.headers["X-Stamped"] = "1"
return response
app.add_middleware(StampMiddleware)
# ASGI: a class taking (app, **options); Veloce passes the wrapped app in.
# `SomeTracingMiddleware` here stands in for any third-party ASGI middleware.
app.add_middleware(SomeTracingMiddleware, service_name="api")
Note
Veloce-native middleware runs against the parsed Request/Response,
so it is the right place for almost everything. Reach for an ASGI
middleware class only when you are plugging in a third-party component
that is already written to the ASGI interface.
BaseHTTPMiddleware goes through add_http_middleware
A BaseHTTPMiddleware subclass is a
dispatch-shape middleware, not an ASGI app. Passing one to
add_middleware raises TypeError — register it with
add_http_middleware instead.
CORS preflight and Private Network Access¶
CORSMiddleware answers a preflight (OPTIONS with an Origin) with a
204. A preflight whose Origin is not in the allow-list, or whose
Access-Control-Request-Method is not in allow_methods, gets a
diagnostic 400 instead of a silently-blocked 204 so the rejection is
visible to developers.
Set allow_private_network=True to participate in
Private Network Access:
when a preflight carries Access-Control-Request-Private-Network: true,
the response echoes Access-Control-Allow-Private-Network: true. The grant
is opt-in and never emitted unless configured.
app.add_middleware(
CORSMiddleware(
allow_origins=["https://app.example.com"],
allow_private_network=True,
)
)
For the full parameter table — allow_origins, allow_origin_regex,
allow_methods, allow_headers, allow_credentials, expose_headers,
max_age — and the credentials/wildcard rule, see CORS.
Built-in middleware¶
Veloce ships the following middleware, all importable from the top-level
veloce package:
| Middleware | Purpose |
|---|---|
CORSMiddleware |
Cross-Origin Resource Sharing |
GZipMiddleware |
Response compression |
CSRFMiddleware |
Double-submit-cookie CSRF protection |
SessionMiddleware |
Signed, timestamped session cookies |
ServerSessionMiddleware |
Server-side sessions; the cookie carries only an opaque id |
TrustedHostMiddleware |
Host-header allow-list |
HTTPSRedirectMiddleware |
Redirect plain HTTP to HTTPS |
SecurityHeadersMiddleware |
Attach common hardening response headers to every response |
CSPMiddleware |
Content-Security-Policy with a per-request nonce and report-only support |
ConditionalGetMiddleware |
Emit 304 Not Modified for satisfied GET/HEAD preconditions |
RateLimitMiddleware |
Per-client rate limiter with a selectable algorithm and backend |
WebSocketOriginMiddleware |
Reject cross-site WebSocket handshakes (CSWSH) |
LoggingMiddleware |
Structured request/response access logging |
RequestIDMiddleware |
Assign a unique request ID and echo it in the response |
ProxyFix |
Honour X-Forwarded-* from trusted proxies |
The base classes Middleware and BaseHTTPMiddleware
are also exported, along with the rotate_csrf_token helper used with
CSRFMiddleware.
SessionMiddleware and ServerSessionMiddleware have a dedicated guide —
see Sessions. For configuring cookie attributes through
app.config, see Configuration.
Trusted hosts¶
TrustedHostMiddleware validates the Host header against an allow-list and
rejects anything else with a 400 — defence against Host-header injection. It
takes a single positional allowed_hosts list and supports literal names, the
catch-all *, and subdomain wildcards like *.example.com (which match
api.example.com but never the bare example.com):
from veloce import TrustedHostMiddleware, Veloce
app = Veloce()
app.add_middleware(
TrustedHostMiddleware(allowed_hosts=["example.com", "*.example.com"])
)
No www_redirect
Unlike some other frameworks, TrustedHostMiddleware does not redirect a
bare apex host to its www. form — there is no www_redirect option. The
middleware only allows or rejects; to canonicalise a host, add an explicit
redirect in a handler or a before_request hook.
Content-Security-Policy with a nonce¶
CSPMiddleware emits a Content-Security-Policy (and/or
Content-Security-Policy-Report-Only) header, optionally with a fresh
per-request nonce. Pass policy as a string template containing the
literal {nonce} placeholder, or as a directive mapping where the
'nonce' source is substituted with the generated nonce:
from veloce import CSPMiddleware
app.add_middleware(
CSPMiddleware(
policy={"default-src": "'self'", "script-src": ["'self'", "'nonce'"]},
report_only_policy="default-src 'self'",
)
)
Read the nonce inside a handler or template with
csp_nonce(request) and place it on the
matching <script>/<style> tags as nonce="...". The nonce is
materialised lazily on first read, so a request that never embeds one pays
no extra cost. A static, nonce-free policy can stay on
SecurityHeadersMiddleware; use CSPMiddleware when you need a nonce or a
report-only policy.
Conditional GET¶
ConditionalGetMiddleware evaluates If-None-Match / If-Modified-Since
against a buffered GET/HEAD response and downgrades a matching request
to 304 Not Modified with an empty body
(RFC 9110 §13). With
auto_etag (the default) it also synthesises a weak ETag for a buffered,
non-empty 200 that lacks one. Register it after GZipMiddleware so a
synthesised ETag reflects the compressed bytes:
from veloce import ConditionalGetMiddleware, GZipMiddleware
app.add_middleware(GZipMiddleware())
app.add_middleware(ConditionalGetMiddleware())
StreamingResponse bodies are not buffered for ETag synthesis.
Streaming compression¶
GZipMiddleware also compresses streaming responses chunk-by-chunk
through a single deflate stream, so a long-running streamed body no longer
has to be buffered to be compressed. Chunks at or above
min_stream_chunk_offload bytes (32 KiB by default) are offloaded to the
thread pool; latency-sensitive types (text/event-stream by default, via
latency_sensitive_types) are passed through uncompressed so server-sent
events are never merged or delayed.
Rate limiting¶
RateLimitMiddleware limits requests per client. Used with no arguments it runs
a process-local sliding-log limiter — max_requests per window_seconds:
from veloce import RateLimitMiddleware
app.add_middleware(RateLimitMiddleware(max_requests=100, window_seconds=60))
Pass a strategy to choose the algorithm, and a backend to choose where the
per-client state lives:
from veloce import RateLimitMiddleware, TokenBucket
app.add_middleware(RateLimitMiddleware(strategy=TokenBucket(rate=100, per=60, burst=20)))
| Strategy | Behavior |
|---|---|
FixedWindow |
limit per fixed window; cheapest, but allows a burst at the boundary |
SlidingWindow |
limit per rolling window; smooths the boundary burst with two counters |
TokenBucket |
refills rate per per seconds, allowing a burst up to burst (default rate); burst=1 is a strict leaky bucket |
The default InMemoryRateLimitBackend counts per process. For one limit shared
across every worker and host, use RedisRateLimitBackend (see below).
To give a specific route its own limit, decorate its handler with rate_limit.
The limit lives on the handler, so there is no route string to mistype:
from veloce import RateLimitMiddleware, TokenBucket, rate_limit
app.add_middleware(RateLimitMiddleware(strategy=TokenBucket(rate=1000, per=60)))
@app.post("/login")
@rate_limit(TokenBucket(rate=5, per=60)) # stricter, just for this route
async def login(request):
...
Put @rate_limit below the route decorator so the route registers the tagged
handler. A decorated route gets its own per-client counter, independent of the
default budget.
For handlers you cannot decorate, the overrides map is the central alternative.
Its key is the route's full path template as matched at runtime (the value of
request.url_rule), so a route on a blueprint with url_prefix="/api" uses
"/api/login", not "/login":
from veloce import RateLimitMiddleware, TokenBucket
app.add_middleware(
RateLimitMiddleware(
strategy=TokenBucket(rate=1000, per=60),
overrides={"/api/login": TokenBucket(rate=5, per=60)},
)
)
An override key that matches no registered route raises on the first request, so
a wrong prefix fails loudly rather than silently doing nothing. An explicit
overrides entry wins over a @rate_limit tag on the same route.
Per-route limits resolve against the entry route
Like exclude_middleware, a per-route limit is resolved against the route
matched when the request arrives. A before_request hook that rewrites the
path to a different route does not change which limit applies — rate limiting
runs before those hooks.
Added in version 0.4.0
Selectable strategy/backend, the rate_limit per-route decorator, and the
per-route overrides map on RateLimitMiddleware. The bare
max_requests/window_seconds form is unchanged.
Function middleware¶
For one-off logic, register a function with @app.middleware("http").
It receives the request and a call_next callable that runs the rest of
the stack:
@app.middleware("http")
async def add_timing_header(request, call_next):
response = await call_next(request)
response.headers["X-Powered-By"] = "veloce"
return response
Class-based middleware¶
For reusable middleware, subclass BaseHTTPMiddleware and implement
dispatch:
from veloce import BaseHTTPMiddleware
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
response.headers["X-Request-ID"] = new_id()
return response
app.add_http_middleware(RequestIDMiddleware())
Ordering¶
Middleware runs in the order it is added on the way in, and in reverse on the way out — the first one added is the outermost layer.
Excluding middleware per route¶
A route can opt out of named middleware with exclude_middleware. Each entry
is matched against a middleware's name, which defaults to its class name; pass
name= to the middleware when two instances of the same class must be
addressed independently. The opt-out applies to both the request and response
phases, so a skipped middleware never runs for that route at all.
The exclusion set is keyed on the route matched at dispatch entry. The same set
of middleware that runs process_request runs process_response, so setup and
teardown stay balanced. A before_request hook that rewrites the request path
to a different route does not change which middleware run for that request - the
entry route's exclude_middleware is authoritative.
RateLimitMiddleware counts per process by default
The default InMemoryRateLimitBackend keeps its state in one process, so
under uvicorn --workers N the effective limit is roughly N x the
configured one. For a shared cross-worker limit pass a
RedisRateLimitBackend from
veloce.contrib.redis (pip install veloceframework[redis]), which keeps
the state in Redis:
from redis.asyncio import Redis
from veloce import RateLimitMiddleware, TokenBucket
from veloce.contrib.redis import RedisRateLimitBackend
client = Redis.from_url("redis://localhost:6379/0")
app.add_middleware(
RateLimitMiddleware(
strategy=TokenBucket(rate=100, per=60),
backend=RedisRateLimitBackend(client),
)
)
app.add_middleware(CSRFMiddleware(secret="..."))
app.add_middleware(RateLimitMiddleware(max_requests=100, window_seconds=60))
# Inbound webhooks can't carry a CSRF token, and the health probe should
# never be rate limited.
@app.post("/webhooks/stripe", exclude_middleware=["CSRFMiddleware"])
async def stripe_webhook(request):
...
@app.get("/health", exclude_middleware=["RateLimitMiddleware"])
async def health():
return {"status": "ok"}
This works on @app.route/@app.get/@app.post/… and the imperative
add_api_route, and on Blueprint and Router routes. Routes that declare no
exclusions run every registered middleware and pay no extra per-request cost.
See also¶
- CORS — the full
CORSMiddlewareparameter reference. - Sessions —
SessionMiddlewareandServerSessionMiddleware. - Configuration — the
SESSION_COOKIE_*keys. - Deployment
- Routing
- Dependency injection
- The API reference lists every middleware class.