Middleware and Cross-Cutting Concerns
Some work belongs to every request and to no handler: assigning the request id, checking the token, counting the request, enforcing the rate limit, logging the outcome. Put that work in the handlers and each of Stagedoor's 31 routes repeats five things and one of them forgets the fourth. Middleware is the ring around the handler where that work lives once. A request passes inward through every ring, reaches the router and the handler, and the response passes back outward through the same rings in reverse.
A service has half a dozen rings, and their order is a design decision with consequences that show up under attack and in the logs. The rate limiter must run before the 100-millisecond password hash, or an attacker spends the service's CPU on requests that should have been refused for free. The access log must run outside everything, or it never sees the 401 that the auth ring produced. This topic is the rings, their order, and the short list of things a ring must never do.
The Ring Model
A middleware receives the request, may act on it, calls the next ring, receives the response that comes back, may act on that, and returns it. Two moments, before and after, and a ring may use either or both. The request-id ring uses both: before, it reads the client's X-Request-Id or generates one; after, it echoes the id in the response header so the client can quote it to support. A ring that short-circuits does not call the next ring at all: the rate limiter returns a 429 and the request never reaches authentication.
class RequestIdMiddleware: def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] != "http": return await self.app(scope, receive, send) rid = header(scope, "x-request-id") or str(uuid4()) # before: read or generate token = request_ctx.set(RequestContext.new(rid, scope)) async def send_with_id(message): if message["type"] == "http.response.start": message["headers"].append((b"x-request-id", rid.encode())) # after: echo it await send(message) try: await self.app(scope, receive, send_with_id) finally: request_ctx.reset(token)
A dozen lines, and each line is one of the two moments or the plumbing between them. Before the inner application runs, the ring reads the header or makes an id and sets the context of Topic 21. It then calls inward, wrapping the response channel so that when the first bytes of the response start, the id is appended as a header. And whatever happened inside, the context is reset afterward. This is a raw ASGI middleware rather than the framework's decorator form, because the outermost rings run on every request including the ones the framework never sees, and a ring that adds a header to the response start is clearer written against the protocol than against a wrapper of it.
Stagedoor's Rings, in Order
Outermost to innermost: the request id and context, from Topic 21. The access log, one line per request with method, path, status, duration and request id, for Chapter 13. Metrics, the RED counters and histograms that Chapter 13's dashboards read. The error handler, which turns anything that escapes the rings inside it into a 500 with the request id. The rate limiter of Chapter 14, keyed on the user or the API key and falling back to the client address. Authentication, the token check of Chapter 5. Then the router, and then the handler. Six rings and a router, in that order in the framework's middleware list, with the outermost listed first.
The order means a rate-limited request is logged and counted but never authenticated. An unauthenticated request is logged and counted with its 401. A request for a path that does not exist is logged and counted with its 404 and never rate-limited against a user, because there is no user yet. Read the list from the outside in and each ring sees exactly what the rings outside it let through, and produces a response that each ring outside it will see on the way back.
Why Order Matters
Auth before the rate limiter, and a credential-stuffing run against POST /login costs the service 100 milliseconds of CPU per attempt, the argon2 cost that Chapter 5 chooses on purpose, with no limit on attempts. At 1,000 attempts a second that is 100 CPU-seconds a second, which is every core on both instances. The limiter outside auth refuses the 11th attempt from one address in a minute with a Redis increment that costs 200 microseconds, and the hash never runs. The same two rings in the other order is a denial of service that the attacker did not have to design.
Logging inside auth, and the failed logins nobody can see: the 401 is produced by the auth ring and returned outward, and a logger inside it never receives a response to log. Metrics inside the router, and a 404 for an unknown route is never counted, so the client that has been hitting a path that was renamed three months ago is invisible on the dashboard. Every one of these is a ring placed on the wrong side of the ring whose output it needs to see, and the rule that fixes all of them is the same: a ring goes outside every ring whose response it must observe.
What a Ring May Do
Read and set headers. Short-circuit with a response: a 429 with Retry-After, a 401 with the challenge, a 400 for a body over the size limit. Set context. That is the list. What it may not do: business logic, because a ring has no domain type, no test and no idea which route it is wrapping. Database calls in the hot path, because a middleware that queries Postgres runs that query on every request, on every health check and every static asset, and under 3,000 requests a second that is 3,000 queries a second the handlers did not ask for. And swallowing exceptions it does not own, which the next section is about.
The rate limiter's Redis increment is the one exception the book allows to "no I/O in a ring," and it is allowed because it is one round trip of 200 microseconds to a store built for it, with a fail-open path when Redis is unreachable. The authentication ring's token verification is a signature check in memory; the session lookup that Chapter 5's refresh path needs is done in the handler for that one route, not in the ring for all of them.
Exception Handling as a Ring
The outermost error handler catches any exception that escaped everything else and turns it into a 500 Problem Details, in Chapter 3's shape, with the request id as the only detail and the full trace logged under that id. It is one ring, and it sits inside the request id, the access log and the metrics rings, so that the id exists when it needs it and the log line and the counters both record the 500 it produced, and outside the limiter, auth and the router, so that a bug in any of them is still a 500 with an id. The domain-error mapping of Topic 19, which turns SeatUnavailable into a 409, is a narrower handler registered inside the router, and it catches only DomainError subclasses.
The two must not compete. If the domain mapping catches everything, a bug in a repository becomes a 409 with a misleading type and the outer ring never logs the trace. If the outer ring is registered inside the router, an exception in the auth ring escapes it and the framework's default handler returns a 500 with no request id and no log line. Each catches its own kind, the narrow one inside and the broad one outside, and Stagedoor's test suite has one test per handler that raises the wrong kind and asserts it passed through.
Per-Route vs Global
The scanner's API-key check of Chapter 5 applies to /tickets/* only; putting it in a global ring means every buyer request pays for a check that does not apply to it and every route gains a code path for "no key present." The CORS preflight ring applies to browser-facing routes and not to the scanner's. FastAPI's router-level dependencies do per-route; global middleware does everything. The book's rule: identity is global, because every request must know who is calling before anything else decides; permission is per route, because what a caller may do depends on which resource they are asking for, and only the route knows that.
The organizer report route declares a dependency that checks the principal's organizer matches the event's; the refund route declares one that checks whether the caller's role may refund at all. Those checks read the context that the global auth ring filled and the resource that the route is about, which is why they cannot be a ring: a ring does not know which order. Chapter 5 builds each of them, and puts the check that finally decides inside the domain operation, where the worker reaches it too; this topic only fixes where the route's own gate sits.
- Auth outside the rate limiter — a credential-stuffing run costs a 100-millisecond hash per attempt with no limit, and 1,000 attempts a second is every core on both instances.
- Logging inside the auth ring — the failed logins nobody can see, because the 401 is produced outside the logger and the run that guessed 40,000 passwords leaves no line.
- A database call in a global middleware — one query per request, on every static asset and every health check, and 3,000 queries a second that no handler asked for.
- Catching every exception in an inner ring and returning 200 — the outer error ring never sees it, the metric says success, and the buyer sees a blank page that the dashboard calls fine.
- Business rules in middleware — "reject holds after on-sale closes" lives in a ring that has no domain type and no test, and the worker, which has no rings, does not enforce it at all.
- The outer error ring registered inside the router — an exception in the auth ring escapes it, and the client gets a 500 with no request id and the log gets nothing.
- Order the rings by cost and by what each must see: request id, access log, metrics, error handler, rate limit, authentication, then the router, with the outermost listed first in the middleware list.
- Keep rings free of business logic and database access; a ring reads context, sets headers and short-circuits, and the one permitted round trip is the limiter's Redis increment.
- Register one exception ring outside the limiter, auth and the router that produces the 500 Problem Details and logs the trace under the request id, and keep the domain-error mapping narrow and inside the router.
- Apply identity checks globally and permission checks per route, as router dependencies that read the context and the resource the route is about, with the check that decides inside the domain operation of Chapter 5.
- Write one test per exception handler that raises the wrong kind and asserts it passed through to the right one.
http.Handler wrapping the pattern with no framework at allEnvoy filters the same idea outside the process, in the proxyKnowledge Check
Marek places the authentication ring outside the rate limiter. What does a credential-stuffing run against the login route cost the service?
- Nothing extra, because the limiter still refuses the 11th attempt before it reaches the handler
- A 100-millisecond hash per attempt with no limit, which at 1,000 a second is every core
- One Redis round trip per attempt, which at 1,000 a second saturates the rate-limit counters
- A pooled Postgres connection held per attempt, which exhausts the pool of 20 within seconds
Which ring must be outermost, and what goes wrong if it is not?
- Authentication, because every other ring needs to know the principal before it can act
- The rate limiter, because a refused request should cost nothing and touch no other ring
- The request id, then the access log, so every response, even a 401 or a 429, gets an id and a line
- The router, because it decides which route's rings apply before any of the global rings may run at all
A middleware looks up the caller's organizer in Postgres on every request to make it available to handlers. What is the objection?
- It runs a query per request, including health checks and paths that never use the result
- It cannot read the principal, because the context is not available to rings, only to handlers
- It runs before authentication, so the principal is empty and the lookup returns nothing
- It cannot return a 404 when the organizer is missing, because rings may only return 401 or 429
Where does the book put the first, cheap refusal for a caller whose role may not refund at all?
- In the global auth ring, so that every request is checked before it reaches any route
- In the domain function alone, so that no layer above it has to know about refunds
- In the storage layer, as a WHERE clause on every order query keyed on the principal
- As a dependency on the refund route, because a ring wraps every route and knows none of them
You got correct