Middleware¶
Middleware wraps a handler with cross-cutting concerns — logging,
auth, body parsing, response shaping — without changing the
handler's signature. The shape is the same as Starlette /
Quart / ASGI 3.0 generally, with one BlackBull convenience
(@as_middleware) layered on top.
Writing a middleware¶
import time
async def logging_mw(conn, receive, send, call_next):
t0 = time.monotonic()
await call_next(conn, receive, send)
elapsed = (time.monotonic() - t0) * 1000
print(f"{conn.method} {conn.path} {elapsed:.1f} ms")
Signature: async def mw(conn, receive, send, call_next).
- Call
await call_next(conn, receive, send)to pass control to the next layer. - The legacy parameter name
inneris accepted as an alias forcall_next. - Sending a response without calling
call_nextshort-circuits all inner layers.
Middleware functions keep the full (conn, receive, send, call_next)
shape; the simplified handler form does not apply to them.
The first parameter's name picks the request form¶
BlackBull's own server threads a typed
Connection end-to-end — conn.method,
conn.path, conn.headers.get(b'accept'). A middleware written against
plain ASGI wants the scope dict instead, so @as_middleware reads the
name you gave the parameter:
| First parameter named | Receives | send wrapper observes |
|---|---|---|
anything but scope (conn, connection, …) |
Connection |
NativeResponse on the HTTP path |
scope |
a real ASGI scope dict |
ASGI event dicts |
@as_middleware
async def asgi_style_mw(scope, receive, send, call_next):
# A genuine scope dict: subscripting works.
print(scope['method'], scope['path'])
await call_next(scope, receive, send)
The word scope means a genuine ASGI scope dict everywhere in BlackBull
and never a Connection — which is why the name is enough to declare
your intent. The dict lives across your frame only: call_next always
passes the Connection down, so a middleware below yours is unaffected
by the one above it. The choice is made once, from the signature, when
the decorator runs — a native middleware pays nothing for the feature.
Undecorated middleware is not adapted either way and always receives the
Connection.
Typing the message channel¶
The events crossing receive and send follow the ASGI 3.0 wire contract
on the ASGI lanes — WebSocket and the external-host edge
(BlackBull(asgi=True) under uvicorn) — as plain ASGI dicts. On
BlackBull's own HTTP path (HTTP/1.1 and HTTP/2) the response side
carries NativeResponse objects instead: one message may hold any
combination of a header arm, body chunks, and trailers, and its presence
semantics are is not None (never truthiness — an empty body is a real
body).
BlackBull also ships a set of TypedDict declarations for the ASGI event
shapes, so a type checker can tell which keys are legal on which event:
from blackbull import (ASGIReceiveCallable, ASGISendCallable, ASGISendEvent,
as_middleware)
@as_middleware # required — it is what adapts the edges
async def add_header_mw(scope, receive: ASGIReceiveCallable,
send: ASGISendCallable, call_next):
async def wrapped(event: ASGISendEvent) -> None:
if event['type'] == 'http.response.start':
# Narrowed here: pyright/mypy know `status` and `headers` exist
# on this branch, and that `body` does not.
event = {**event,
'headers': list(event.get('headers', [])) + [(b'x-custom', b'1')]}
await send(event)
await call_next(scope, receive, wrapped)
These declarations describe the ASGI form, so they fit a middleware
that asked for one — decorated and with its first parameter named
scope. Name it conn and the events are NativeResponse objects, for
which ASGISendEvent is the wrong annotation.
ASGISendEvent and ASGIReceiveEvent are unions discriminated on the
type key, so comparing or match-ing against it narrows the union to a
single member. A checker then rejects the classic mistake this guards —
passing a Response object where an event dict belongs, which fails at
runtime on event['type'].
The individual shapes (HTTPResponseStartEvent, WebSocketReceiveEvent, …)
are importable from blackbull.asgi if you want to name one directly.
Everything here is annotation-only: nothing is validated or converted at
runtime, and unannotated middleware keeps working exactly as before.
Native path: the send channel carries NativeResponse
On BlackBull's own HTTP path (HTTP/1.1 and HTTP/2), the response events
a middleware's inner send wrapper observes are NativeResponse
objects — not dicts. A middleware that subscripts event['type']
must instead branch on the object's arms: event.header is not None
(header arm, with event.status / event.header.get(b'name')),
event.body is not None (body chunk, with event.more_body), and
event.trailers is not None. The @as_middleware decorator
guarantees this single native representation for the wrappers it
normalises; only on the WebSocket / external-host lanes do the same
wrappers see plain dicts. Use
blackbull.testing.native to exercise the native path.
The @as_middleware decorator¶
Route handlers can return Response / JSONResponse objects
instead of calling send(...) with raw ASGI events. A middleware
that wraps send would otherwise have to handle both forms.
Decorate the middleware with @as_middleware and the inner
wrapper sees a single native representation: NativeResponse on
the HTTP path (HTTP/1.1 and HTTP/2), plain ASGI dicts on the
WebSocket / external-host lanes —
Response objects are converted for you, never leaking through:
from blackbull import as_middleware
from blackbull.native import NativeResponse
@as_middleware
async def add_header_mw(conn, receive, send, call_next):
async def wrapped(event):
if isinstance(event, NativeResponse):
if event.header is not None:
# header arm — append zero-copy; visible to the sender
event.header.append(b'x-custom', b'1')
else:
# WebSocket / external-host lanes still carry ASGI dicts
if event['type'] == 'http.response.start':
event = {**event,
'headers': list(event.get('headers', []))
+ [(b'x-custom', b'1')]}
await send(event)
await call_next(conn, receive, wrapped)
On the native path a complete response is one object, one send
(header + body together), while a streamed response is a header object
followed by body-chunk objects. Remember the presence contract: test
event.header is not None / event.body is not None, never truthiness.
If you would rather not branch at all, name the first parameter scope
and every event is an ASGI dict — see
the parameter-name rule
above.
@as_middleware also works on classes (it wraps __call__). All
of BlackBull's built-in middleware uses the class form:
@as_middleware
class TimingMiddleware:
def __init__(self, threshold_ms: float = 100.0):
self._threshold = threshold_ms
async def __call__(self, conn, receive, send, call_next):
...
The name rule reads __call__'s first parameter after self, so a class
middleware opts into the ASGI form the same way a function does.
Omit the decorator when you need raw send arguments — e.g.
middleware used in a deployment that never registers simplified
handlers.
Observation vs. interception
Middleware in BlackBull is sugar over the
@app.intercept('before_handler') hook. For purely
observational concerns (logging, metrics, tracing) prefer
@app.on(...) instead — middleware forces every observer to
run on the request critical path, and one slow observer can
degrade every response. See Events.
Attaching middleware to a route¶
@app.route(path='/protected', middlewares=[auth_mw, logging_mw])
async def handler(scope, receive, send):
...
The list is outer-to-inner: the first entry runs first on the way in, last on the way out.
┌─ auth_mw ──────────────────────────────────┐
request → │ ┌─ logging_mw ─────────────────────────┐ │ → response
│ │ ┌─ handler ─┐ │ │
│ │ │ (runs) │ │ │
│ │ └───────────┘ │ │
│ └──────────────────────────────────────┘ │
└────────────────────────────────────────────┘
auth_mw runs first; it either short-circuits or delegates to
logging_mw, which then delegates to handler. Post-handler code
(after await call_next(...)) runs in reverse order: logging_mw
post → auth_mw post.
For routes that share a middleware prefix, use Route Groups.
Global middleware¶
app.use(mw) registers a middleware that wraps every route:
from blackbull import BlackBull
from blackbull.middleware.compression import Compression
app = BlackBull()
app.use(Compression()) # applies to all routes
Global middleware run outside per-route middleware. The effective order at request time is:
global mw → route-group mw → per-route mw → handler
Each layer can short-circuit (return without calling call_next)
to skip everything below it.
Built-in middleware¶
websocket¶
Consumes the initial websocket.connect event and sends
websocket.accept so the inner handler can skip the boilerplate:
from blackbull.middleware import websocket
from blackbull.utils import Scheme
@app.route(path='/chat', scheme=Scheme.websocket, middlewares=[websocket])
async def chat(scope, receive, send):
while True:
event = await receive()
if event['type'] == 'websocket.disconnect':
break
await send({'type': 'websocket.send', 'text': event.get('text', '')})
Works with the high-level WebSocket handler form too — it records the
completed handshake on the connection, so the object adopts it rather than
waiting for a websocket.connect that is already gone. See
WebSockets for the exact
semantics, including what happens if the handler also calls accept() or
closes the connection itself.
Compression / compress¶
Compresses HTTP response bodies using the codec the client prefers
(brotli > zstd > gzip, based on Accept-Encoding):
from blackbull.middleware import compress
from blackbull.middleware.compression import Compression
@app.route(path='/data', middlewares=[compress])
async def data_handler(scope, receive, send):
await send(Response(large_payload))
# Or with a higher size threshold:
@app.route(path='/large', middlewares=[Compression(min_size=4096)])
async def large(scope, receive, send):
...
Brotli and zstandard are optional extras:
pip install 'blackbull[compression]'
The default min_size is 100 bytes — responses smaller than that
pass through uncompressed.
Every compressed response carries Vary: Accept-Encoding (folded into
any existing Vary) so a shared cache never replays an encoded body to
a client that sent identity / no Accept-Encoding (RFC 9110 §12.5.5).
A response that is compressible but not compressed — below min_size,
or served straight from disk — carries it too, for the same reason.
Large files that StaticFiles hands to the sender as a
path (http.response.pathsend, so the kernel can sendfile them) are
served uncompressed: the middleware never sees those bytes. Pre-compress
them on disk instead — StaticFiles serves a .br / .gz sibling when
the client accepts it, which costs no CPU per request.
Sessions — moved to blackbull-session¶
Signed-cookie sessions live in the standalone
blackbull-session
package, following the init_app(app) extension
convention. The in-tree blackbull.middleware.Session was
deprecated in 0.38 and removed in 0.54.0.
Migration is a one-line swap:
# pip install blackbull-session
from blackbull_session import SessionExtension
SessionExtension(app) # reads BB_SESSION_SECRET
SessionExtension(app, secret=b'<long-random-bytes>') # explicit secret
@app.route(path='/whoami')
async def whoami(conn, receive, send):
await send(Response(conn.state['session'].get('user', 'anonymous')))
Handlers read and write the session through conn.state['session'];
before; see the blackbull-session README for cookie attributes,
secret resolution, and session-clearing semantics.
Cache¶
Per-worker in-memory response cache for GET and HEAD.
Captures the handler's response on the first hit, stores it under
(method, origin, path, query_string), and replays it directly on
subsequent matching requests until the entry expires.
The effective origin is the request's scheme, host and port, normalized
according to RFC 9110 §4.3.1.
HTTP and HTTPS, different hosts, and different non-default ports have separate
cache buckets even when path and query match; Vary: Host is not required.
Host/scheme case, leading zeros in a port and an explicit default port do not
create a different origin. GET and HEAD remain separate keys.
Native HTTP/1.1 and external ASGI use Host; the native HTTP/2 parser maps
:authority to that same header before dispatch. If Host is absent, the
connection's server tuple supplies the authority. When the origin cannot be
determined safely (including ambiguous Host fields), the request is handled
normally but neither reads nor writes the cache.
Register TrustedProxy or any intentional scheme/host rewrite before
Cache, so cache lookup and the application see the same identity. Cache does
not interpret Forwarded or X-Forwarded-* itself and does not expand the
proxy trust boundary. TrustedProxy applies its supported scheme rewrites;
it does not infer a host rewrite from Forwarded: host or X-Forwarded-Host.
from blackbull.middleware.cache import Cache
app.use(Cache(max_age=600)) # 10-minute TTL
@app.route(path='/feed')
async def feed(scope, receive, send):
items = await fetch_news() # expensive
body = render(items).encode()
await send({'type': 'http.response.start', 'status': 200,
'headers': [(b'content-type', b'text/html')]})
await send({'type': 'http.response.body', 'body': body})
A weak ETag (W/"<sha256-prefix>") is generated automatically when
the handler doesn't supply one. Subsequent requests with a
matching If-None-Match header receive 304 Not Modified with no
body.
Standard Cache-Control directives are honoured. Responses
carrying no-store, private, or no-cache pass through
unstored. Requests with Cache-Control: no-store bypass the
cache too.
The cache is variant-aware: when a stored response carries a
Vary header (e.g. Vary: Accept-Encoding behind the Compression
middleware), the varied request-header values are folded into the
cache key, so a brotli variant is never replayed to an identity
client. A response with Vary: * is passed through unstored
(RFC 9110 §12.5.5).
Constructor arguments:
| Argument | Default | Notes |
|---|---|---|
max_age |
300 |
TTL in seconds when the response does not specify its own. |
max_entries |
1024 |
Shared LRU cap on method/origin/path/query buckets across all origins; each bucket separately bounds its Vary variants. |
cacheable_methods |
{'GET', 'HEAD'} |
Methods eligible for caching. |
cacheable_statuses |
{200, 203, 300, 301, 308, 404, 410, 414, 451} |
Status codes eligible for caching. |
cache_authenticated |
False |
When False, requests with Authorization bypass the cache (RFC 9111 §3.5). |
generate_etag |
True |
Auto-generate ETag when the handler omits it. |
Limitations:
- Per-worker. Multi-worker deployments hold a separate cache in each process.
- No cross-restart persistence. In-memory only.
- No explicit invalidation API. Wait for TTL or restart the worker.
- Streaming responses (any
more_body=Truechunk) are forwarded straight through without caching.
CORS¶
Handles preflight OPTIONS requests and adds the required
Access-Control-* headers to actual cross-origin responses.
from blackbull import BlackBull, CORS
app = BlackBull()
app.use(CORS(
allow_origins=['https://myapp.example.com'],
allow_methods=['GET', 'POST', 'OPTIONS'],
allow_headers=['Authorization', 'Content-Type'],
allow_credentials=True,
max_age=3600,
))
| Parameter | Type | Default | Notes |
|---|---|---|---|
allow_origins |
list[str] \| str |
'*' |
Explicit origin strings, or '*' for wildcard |
allow_methods |
list[str] |
['GET','POST','HEAD','OPTIONS'] |
Methods allowed in preflight |
allow_headers |
list[str] \| str |
'*' |
Request headers allowed |
allow_credentials |
bool |
False |
Emit Access-Control-Allow-Credentials: true |
expose_headers |
list[str] |
[] |
Response headers the browser JS may read |
max_age |
int \| None |
600 |
Preflight cache seconds; None omits the header |
allow_credentials=True cannot be combined with
allow_origins=['*'] — the CORS spec forbids it. List explicit
origins instead.
Apply to specific route groups rather than globally when only some routes need CORS:
api = app.group(middlewares=[CORS(allow_origins=['https://myapp.example.com'])])
@api.route(path='/items')
async def list_items(): ...
StaticFiles¶
Serves files from a directory under a URL prefix. See Static files for the configuration surface (URL prefix, range requests, in-memory cache, PROD-mode passthrough).
Recipes¶
Request ID¶
Attach a unique ID to every request for distributed tracing:
import uuid
from blackbull import as_middleware
from blackbull.native import NativeResponse
@as_middleware
async def request_id_mw(conn, receive, send, call_next):
req_id = (conn.headers.get(b'x-request-id', b'')
or uuid.uuid4().hex.encode())
conn.state['request_id'] = req_id.decode()
async def tagged_send(event):
if isinstance(event, NativeResponse) and event.header is not None:
event.header.append(b'x-request-id', req_id)
await send(event)
await call_next(conn, receive, tagged_send)
Inner layers and the handler read it back as
conn.state['request_id'].
Rate limiting (token bucket, in-process)¶
import time
from collections import defaultdict
from http import HTTPStatus
from blackbull import JSONResponse
_buckets: dict[str, tuple[float, int]] = defaultdict(lambda: (time.monotonic(), 0))
RATE_LIMIT = 60 # requests per minute per IP
async def rate_limit_mw(conn, receive, send, call_next):
ip = (conn.client or ['unknown'])[0]
now = time.monotonic()
window_start, count = _buckets[ip]
if now - window_start > 60:
_buckets[ip] = (now, 1)
elif count >= RATE_LIMIT:
await send(JSONResponse({'error': 'rate limit exceeded'},
status=HTTPStatus.TOO_MANY_REQUESTS))
return
else:
_buckets[ip] = (window_start, count + 1)
await call_next(conn, receive, send)
Per-IP in-process limiting is fine for a single-worker deployment. For multi-worker or multi-host setups, use a shared store (Redis, Memcached) so the bucket survives across workers.
Post-response middleware (inspect / modify the response)¶
The middleware shape (conn, receive, send, call_next) runs
around the handler — code before await call_next(...) sees the
request, code after sees that the handler returned but not what
the handler sent. Response status, headers, and body all flow
through send, not through call_next's return value.
To inspect or modify the response, wrap send and forward each
event yourself:
from blackbull import as_middleware
from blackbull.native import NativeResponse
@as_middleware
async def log_status_mw(conn, receive, send, call_next):
captured_status = None
async def intercepting_send(event):
nonlocal captured_status
if isinstance(event, NativeResponse) and event.header is not None:
captured_status = event.status
await send(event)
await call_next(conn, receive, intercepting_send)
print(f'{conn.method} {conn.path} → {captured_status}')
The handler now sends to intercepting_send, which records the
status off the header arm and forwards every event to the outer
send unchanged. Once call_next returns, you have the captured
value.
The pattern generalises to any modification:
| Goal | Where to act in intercepting_send |
|---|---|
| Add a response header | event.header.append(...) on the header arm before forwarding |
| Compute a checksum / size | accumulate event.body while event.body is not None, finalise when more_body is false |
| Replace the body | buffer body parts; on the terminal body, emit your replacement and skip the original |
| Short-circuit a status code | on the header arm, decide whether to forward as-is or synthesise a different response |
Compression (blackbull/middleware/compression.py)
is the reference implementation: it buffers body parts, compresses
the joined payload, and emits replacement headers + body when the
handler finishes.
Streaming responses
intercepting_send receives every body event, including chunks
sent with more_body=True. If your goal is to inspect the
complete response, either buffer until more_body is false (as
Compression does for non-streaming payloads) or fall back to
pass-through when streaming is detected — buffering an unbounded
stream defeats the point.
Passing values to inner layers¶
conn.state is the per-request scratch dict every layer shares:
@as_middleware
async def auth_mw(conn, receive, send, call_next):
auth = conn.headers.get(b'authorization', b'')
token = auth[7:].decode() if auth.startswith(b'Bearer ') else ''
user = SESSIONS.get(token)
if not user:
await send(JSONResponse({'error': 'Unauthorized'},
status=HTTPStatus.UNAUTHORIZED))
return # short-circuit: inner layers never run
conn.state['user'] = user # available to all inner layers
conn.state['token'] = token
await call_next(conn, receive, send)
state, not a top-level key
Use conn.state[...]. Setting an attribute or a top-level scope
key on the request object does not reach inner layers: the
scope form a scope-declaring middleware receives is built for
that frame, and call_next passes the Connection down. Only
state (and extensions) are shared by reference across the
whole chain, which is why they are the injection points.
The path-parameter dict conn.path_params and the framework error
keys (conn.state['error_status'], etc.) are populated by the
framework — see Error handling.
Next¶
- Error handling — custom error handlers, the DEV-mode traceback page.
- Events —
@app.on/@app.interceptfor observational and interceptive hooks. - Static files —
StaticFilesmiddleware configuration.