Skip to content

Middleware

Middleware runs around every HTTP handler.

from aether import Reply

@app.middleware
async def require_key(request, call_next):
    if request.header("x-api-key") != SECRET:
        return Reply({"error": "unauthorized"}, status=401)
    reply = await call_next(request)
    reply.headers["x-served-by"] = "aether"
    return reply

Middleware runs outermost-first in registration order and unwinds in reverse. It can observe a request, add response headers, change the status, or refuse to call the handler at all.

call_next returns a Reply, not a response

A Reply holds whatever the handler returned, still unserialized: a dict, a model, a Response, an SSE, or None. It is not a finished body.

This is intentional. A handler returning a dict is serialized to JSON in Rust, and materializing a body so that middleware could inspect it would discard that on every request, including requests whose middleware never inspects anything. Middleware that does need the bytes can replace reply.value with a Response it builds itself.

@app.middleware
async def timing(request, call_next):
    started = time.perf_counter()
    reply = await call_next(request)
    reply.headers["x-elapsed-ms"] = f"{(time.perf_counter() - started) * 1000:.1f}"
    return reply

Cost

Routes are untouched when no middleware is registered — the chain is built at startup, not per request, and a route with an empty chain calls the handler directly.

WebSocket routes

Middleware does not wrap socket handlers. A WebSocket handler runs after the handshake has already completed, so there is nothing useful left to intercept: the 101 is sent and the client believes it is connected.

Middleware does wrap a socket route's authorize function, which runs before the handshake. That is how an app-wide rule still reaches sockets: give the route an authorizer, even one that accepts everything, and the middleware chain wraps it.

async def accept(request):
    return None            # None or True accepts; a Response or Reply refuses

@app.websocket("/feed", authorize=accept)
async def feed(request, ws): ...

With require_key registered above, that socket is now refused with 401 before the upgrade, by the same rule that guards the REST routes. A socket route with no authorizer at all is not wrapped by anything. See WebSocket.