Our error pages lost their security headers
See the problem
Section titled “See the problem”A security scan of your notes app comes back. Every normal page sends the headers X-Frame-Options: DENY and X-Content-Type-Options: nosniff. But the 404 and 500 pages don’t. The scan also found that /notes/1 opens without a login.
Both bugs have the same cause. The checks and headers live inside each view, and some paths never reach that code. To fix it, you need to know every step a request takes, and in what order.
Define the task
Section titled “Define the task”Run three things on every request, in one place:
- A login check. No user: answer 401, and don’t run the view.
- Security headers. Add them to every response: 200, 401, 404 and 500 alike.
- Error handling. A missing note (a
KeyError) answers 404. Any other error answers 500, not a crash.
A request is a dict like {"path": "/notes/1", "user": "ana"}. A response is {"status": ..., "headers": {...}, "body": ...}.
Example: a logged-out user asks for /notes/1. The answer is {"status": 401, "headers": {"X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY"}, "body": "log in first"}.
Pause and think
Section titled “Pause and think”Hints open one at a time:
Hint 1 of 3
An error skips every line after it, until a try catches it. "After the view" never comes.
Hint 2 of 3
So the header code must sit outside something that catches the error, and turns it into a response.
Hint 3 of 3
Picture layers around the view, each calling the next one inside it. Each layer gets back whatever the inner layers made, error page or not.
Understand the idea
Section titled “Understand the idea”Every framework runs the same steps. The names differ, but the order is the same:
- The server (Gunicorn, Uvicorn) reads the HTTP and calls the app, as in the WSGI vs ASGI lesson.
- The framework builds a request object from it:
request.method,request.path,request.headers. - Middleware runs, outermost first: sessions, login, security, timing, error handling.
- The router matches the path to a view, and pulls out parts like the
9in/notes/9. - The view runs: it reads input, talks to the database, and returns data or a rendered template.
- The response goes back out through the same middleware, in reverse, then to the server.
Middleware is a layer around the view. Each layer gets the request and a way to call the next layer in. It can stop early and answer itself, like a login check. It can change the request on the way in, or the response on the way out. Picture an onion: in through every layer, out through every layer, in reverse.
Where the error goes decides what the user sees. An error in the view travels back out through the layers until one catches it. Layers outside that one get a normal response. Layers inside never finish.
| Step | Flask | Django | FastAPI |
|---|---|---|---|
| Hooks or middleware | before_request, after_request, WSGI middleware | MIDDLEWARE list in settings | @app.middleware("http"), add_middleware |
| Router | @app.route("/notes/<int:id>") | path("notes/<int:id>/", view) | @app.get("/notes/{id}") |
| Errors become responses | @app.errorhandler(404) | handler404, exception middleware | @app.exception_handler(...) |
Follow one request for a note that doesn’t exist.
GET /notes/9, through the layers
Parts:
- Browser (the request is here)
- Security headers
- Errors
- Login check
- Router
- View
- Database
Arrows:
- Browser sends requests to Security headers
- Security headers sends requests to Errors
- Errors sends requests to Login check
- Login check sends requests to Router
- Router sends requests to View
- View sends requests to Database
Step 1 of 10. Ana asks for note 9. It doesn’t exist. Watch where the request goes, and how it comes back.
Climb the ladder
Section titled “Climb the ladder”Three ways to add the login check, the headers and the error pages. The views are the same in all three.
Copy the checks into every view
The idea: Each view checks the login itself, and adds the headers to its own response.
It works for the first view, and it’s plain to read.
SECURITY = {"X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY"}NOTES = {1: "Buy milk"}
def list_notes(request): if request.get("user") is None: return {"status": 401, "headers": dict(SECURITY), "body": "log in first"} return {"status": 200, "headers": dict(SECURITY), "body": list(NOTES.values())}
def read_note(request): # Someone forgot the login check here. note = NOTES[int(request["path"].rsplit("/", 1)[1])] # KeyError for a missing note return {"status": 200, "headers": dict(SECURITY), "body": note}
def handle(request): """Okay: every view does its own login check and adds its own headers.""" if request["path"] == "/notes": return list_notes(request) if request["path"].startswith("/notes/"): return read_note(request) return {"status": 404, "headers": {}, "body": "not found"}- Covers
Views that remember- Copies
One per view- Why
- The rule lives in every view, so it holds only where someone wrote it.
Where it breaks: someone forgot it in read_note, so /notes/1 opens without a login. A missing note raises KeyError, and the crash page has no headers. The tests show both.
Hooks before and after every view
The idea: Run a list of before-hooks, which may answer early, then the view, then a list of after-hooks on the response.
The waste in Okay: the same rule, written again and again, and missed once. Now there is one copy. This is Flask’s before_request and after_request.
Highlighted lines are new in this rung.
SECURITY = {"X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY"}NOTES = {1: "Buy milk"}
def list_notes(request): return {"status": 200, "headers": {}, "body": list(NOTES.values())}
def read_note(request): note = NOTES[int(request["path"].rsplit("/", 1)[1])] # KeyError for a missing note return {"status": 200, "headers": {}, "body": note}
def login_required(request): if request.get("user") is None: return {"status": 401, "headers": {}, "body": "log in first"} return None # carry on to the view
def add_security_headers(response): response["headers"].update(SECURITY) return response
BEFORE = [login_required]AFTER = [add_security_headers]
def route(request): if request["path"] == "/notes": return list_notes if request["path"].startswith("/notes/"): return read_note return lambda request: {"status": 404, "headers": {}, "body": "not found"}
def handle(request): """Better: hooks before and after every view, in one place.""" for hook in BEFORE: response = hook(request) if response is not None: break else: response = route(request)(request) # an error here skips the AFTER hooks for hook in AFTER: response = hook(response) return response- Covers
Every view that returnswas Views that remember- Copies
Onewas One per view- Why
- The login check and the headers live in one place, and every request runs them.
What changed from the previous rung
SECURITY = {"X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY"}NOTES = {1: "Buy milk"}
def list_notes(request): if request.get("user") is None: return {"status": 401, "headers": dict(SECURITY), "body": "log in first"} return {"status": 200, "headers": dict(SECURITY), "body": list(NOTES.values())} return {"status": 200, "headers": {}, "body": list(NOTES.values())}
def read_note(request): # Someone forgot the login check here. note = NOTES[int(request["path"].rsplit("/", 1)[1])] # KeyError for a missing note return {"status": 200, "headers": dict(SECURITY), "body": note} return {"status": 200, "headers": {}, "body": note}
def handle(request): """Okay: every view does its own login check and adds its own headers."""def login_required(request): if request.get("user") is None: return {"status": 401, "headers": {}, "body": "log in first"} return None # carry on to the view
def add_security_headers(response): response["headers"].update(SECURITY) return response
BEFORE = [login_required]AFTER = [add_security_headers]
def route(request): if request["path"] == "/notes": return list_notes(request) return list_notes if request["path"].startswith("/notes/"): return read_note(request) return {"status": 404, "headers": {}, "body": "not found"} return read_note return lambda request: {"status": 404, "headers": {}, "body": "not found"}
def handle(request): """Better: hooks before and after every view, in one place.""" for hook in BEFORE: response = hook(request) if response is not None: break else: response = route(request)(request) # an error here skips the AFTER hooks for hook in AFTER: response = hook(response) return responseWhere it breaks: when the view raises, the error skips the after-hooks. /notes/9 still crashes with no headers. And the order of hooks is a separate setting for each list.
Middleware layers that wrap the view
The idea: Each layer takes the request and the next layer. build() wraps them, outermost first: headers, then errors, then login.
The waste in Better: “after” was a list run after the view, not a layer around it. A layer can use try, so it sees errors too.
The order is the design. The tests swap headers and errors, and the 404 loses its headers again.
Highlighted lines are new in this rung.
SECURITY = {"X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY"}NOTES = {1: "Buy milk"}
def list_notes(request): return {"status": 200, "headers": {}, "body": list(NOTES.values())}
def read_note(request): note = NOTES[int(request["path"].rsplit("/", 1)[1])] # KeyError for a missing note return {"status": 200, "headers": {}, "body": note}
def security_headers(request, call_next): response = call_next(request) # runs whatever is inside, errors included response["headers"].update(SECURITY) return response
def errors(request, call_next): try: return call_next(request) except KeyError: return {"status": 404, "headers": {}, "body": "not found"} except Exception: return {"status": 500, "headers": {}, "body": "something went wrong"}
def login_required(request, call_next): if request.get("user") is None: return {"status": 401, "headers": {}, "body": "log in first"} return call_next(request)
def route(request): if request["path"] == "/notes": return list_notes(request) if request["path"].startswith("/notes/"): return read_note(request) return {"status": 404, "headers": {}, "body": "not found"}
def wrap(middleware, inner): def handler(request): return middleware(request, inner)
return handler
def build(middlewares, view): """Wrap view in each middleware, the first one outermost.""" handler = view for middleware in reversed(middlewares): handler = wrap(middleware, handler) return handler
# Outermost first: the headers go on every response, even an error page.handle = build([security_headers, errors, login_required], route)- Covers
Every request, errors toowas Every view that returns- Copies
One- Why
- The error layer turns any error into a response inside the headers layer, so the headers go on every response.
What changed from the previous rung
SECURITY = {"X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY"}NOTES = {1: "Buy milk"}
def list_notes(request): return {"status": 200, "headers": {}, "body": list(NOTES.values())}
def read_note(request): note = NOTES[int(request["path"].rsplit("/", 1)[1])] # KeyError for a missing note return {"status": 200, "headers": {}, "body": note}
def login_required(request): if request.get("user") is None: return {"status": 401, "headers": {}, "body": "log in first"} return None # carry on to the view
def add_security_headers(response):def security_headers(request, call_next): response = call_next(request) # runs whatever is inside, errors included response["headers"].update(SECURITY) return response
BEFORE = [login_required]AFTER = [add_security_headers]def errors(request, call_next): try: return call_next(request) except KeyError: return {"status": 404, "headers": {}, "body": "not found"} except Exception: return {"status": 500, "headers": {}, "body": "something went wrong"}
def login_required(request, call_next): if request.get("user") is None: return {"status": 401, "headers": {}, "body": "log in first"} return call_next(request)
def route(request): if request["path"] == "/notes": return list_notes return list_notes(request) if request["path"].startswith("/notes/"): return read_note return lambda request: {"status": 404, "headers": {}, "body": "not found"} return read_note(request) return {"status": 404, "headers": {}, "body": "not found"}
def handle(request): """Better: hooks before and after every view, in one place.""" for hook in BEFORE: response = hook(request) if response is not None: break else: response = route(request)(request) # an error here skips the AFTER hooks for hook in AFTER: response = hook(response) return responsedef wrap(middleware, inner): def handler(request): return middleware(request, inner)
return handler
def build(middlewares, view): """Wrap view in each middleware, the first one outermost.""" handler = view for middleware in reversed(middlewares): handler = wrap(middleware, handler) return handler
# Outermost first: the headers go on every response, even an error page.handle = build([security_headers, errors, login_required], route)When Best isn’t best: every layer runs on every request, even for static files and health checks. Keep middleware for rules that truly apply everywhere. A check for one group of pages fits better on those routes: a decorator, a blueprint hook or a FastAPI dependency.
Write it yourself
Section titled “Write it yourself”Write build(middlewares, view), the part of a framework that wraps the view in its middleware. Press Run tests to check your code.
view(request)gives back a response.- Each middleware is
m(request, call_next). It callscall_next(request)to run the layers inside it. buildgives back one function,handler(request). The first middleware in the list is the outermost.
The tests build small middleware that write to a log, so they can check the order.
Wrap a view in middleware
Ctrl + Enter runs- Not run yet:
No middleware: the handler runs the view→{'status': 200} - Not run yet:
A then B: "A in", "B in", "view", "B out", "A out"→['A in', 'B in', 'view', 'B out', 'A out'] - Not run yet:
A layer that answers early: the view never runs→[{'status': 401}, []] - Not run yet:
An outer layer adds a header to an inner layer’s 401→{'status': 401, 'x': '1'} - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Way | Covers | Good | Not so good | Use it when |
|---|---|---|---|---|
| Okay: checks in every view | Views that remember | Plain, local | One forgotten copy is a hole; errors skip it | A rule for one view only |
| Better: before and after hooks | Every view that returns | One copy | An error skips the after-hooks | Flask hooks for simple, non-error work |
| Best: middleware layers | Every request, errors too | Sees every response; the order is clear | Runs on every request, even ones that don’t need it | Rules for the whole app |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- A layer that answers early skips the inner layers. That is how a login check works. It also means the inner layers never see that request.
- Middleware can’t see the view’s name in every framework. It runs before the router in Django and FastAPI. Use route-level tools, like decorators or dependencies, for rules that depend on the view.
- Don’t read the whole body in middleware. A big upload is read into memory, and the view may not be able to read it again.
after_requestin Flask is skipped on an unhandled error. Useteardown_requestfor clean-up that must always run.- Async apps need async middleware. In FastAPI, a blocking middleware stalls every request, just as a blocking view does.
Say it in the interview
Section titled “Say it in the interview”Questions to ask first
- Which framework, and which version?
- Should this rule apply to every request, or only to some routes?
- What should the user see when it fails: a 401, a 403, or a redirect to a login page?
A 60-second walkthrough
“The server, say Gunicorn, reads the HTTP and calls the app. The framework builds a request object and runs the middleware, outermost first. Then the router matches the path to a view and pulls out the parameters. The view runs and returns a response, and the response goes back out through the same middleware in reverse. Each middleware wraps everything inside it, so it can stop early, like a login check, or change the response, like adding headers. If the view raises, the error goes back out until a layer catches it. So I put the error-to-response layer inside anything that must touch every response, like security headers or timing.”
Likely follow-ups
- Middleware or decorator? Middleware for rules on every request. A decorator or dependency for rules on some routes, because it knows which view it wraps.
- Where do sessions come from? A middleware reads the session cookie on the way in, and saves changes and sets the cookie on the way out.
- How do you time a request? An outer middleware that notes the time, calls the next layer in a
try, and logs infinally. The third practice problem builds it. - What runs at start-up instead of per request? App set-up: config, database pools, routes. FastAPI calls this the lifespan, covered in the advanced FastAPI lesson.
Practice
Section titled “Practice”Three problems, from easy to medium. Each has hints, tests you can run here, and its own Okay, Better and Best answers.
1. Match a route (easy)
Section titled “1. Match a route (easy)”Write match(pattern, path). Parts in angle brackets are values to pull out. <int:id> matches only digits and gives an int. Give back a dict of the values, or None if the path doesn’t match.
Example: match("/notes/<int:id>", "/notes/3") is {"id": 3}, and match("/notes/<int:id>", "/notes/abc") is None.
Hint 1 of 3
Split both on "/" and compare them part by part. Different counts can never match.
Hint 2 of 3
A part like <name> takes any text. A plain part must be equal.
Hint 3 of 3
For <int:id>, check str.isdigit() before you convert. An empty part should not match.
Match a route
Ctrl + Enter runs- Not run yet:
match('/notes/<int:id>', '/notes/3')→{'id': 3} - Not run yet:
match('/notes', '/notes')→{} - Not run yet:
match("/notes/<int:id>", "/notes/abc"): not digits→None - Not run yet:
match("/notes/<id>", "/notes/3/share"): one part too many→None - Not run yet:
match('/users/<name>/notes/<int:id>', '/users/ana/notes/7')→{'name': 'ana', 'id': 7} - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Match the start, take the rest
The idea: Check the path starts with the text before the first <, and take everything after it.
It handles the most common route.
def match(pattern: str, path: str) -> dict | None: """Okay: the part before the first < must match, and the rest is the value.""" prefix, _, rest = pattern.partition("<") if not path.startswith(prefix): return None if not rest: return {} if path == prefix else None name = rest.split(">")[0].split(":")[-1] return {name: path[len(prefix) :]}- Matches
One value at the end- Wrong on
Extra parts, types- Why
- It never looks at the slashes after the start.
Where it breaks: /notes/3/share gives {"id": "3/share"}, and <int:id> gives a string.
Part by part
The idea: Split both on /, check the counts match, and compare or capture each part.
The waste in Okay: it treated the path as one string, not as parts.
Highlighted lines are new in this rung.
def match(pattern: str, path: str) -> dict | None: """Better: compare part by part between the slashes.""" want, got = pattern.strip("/").split("/"), path.strip("/").split("/") if len(want) != len(got): return None params = {} for w, g in zip(want, got, strict=True): if w.startswith("<") and w.endswith(">"): params[w[1:-1].split(":")[-1]] = g elif w != g: return None return params- Matches
Any number of valueswas One value at the end- Wrong on
Typeswas Extra parts, types- Why
- Every part is checked, so extra parts can’t get into a value.
What changed from the previous rung
def match(pattern: str, path: str) -> dict | None: """Okay: the part before the first < must match, and the rest is the value.""" prefix, _, rest = pattern.partition("<") if not path.startswith(prefix): """Better: compare part by part between the slashes.""" want, got = pattern.strip("/").split("/"), path.strip("/").split("/") if len(want) != len(got): return None if not rest: return {} if path == prefix else None name = rest.split(">")[0].split(":")[-1] return {name: path[len(prefix) :]} params = {} for w, g in zip(want, got, strict=True): if w.startswith("<") and w.endswith(">"): params[w[1:-1].split(":")[-1]] = g elif w != g: return None return paramsWhere it breaks: <int:id> still matches abc. The view then fails on int("abc"), with a 500 instead of a 404.
Part by part, with types
The idea: Like Better, and <int:name> takes only digits and gives an int.
The waste in Better: the pattern said “int”, and Better ignored it.
Highlighted lines are new in this rung.
def match(pattern: str, path: str) -> dict | None: """Best: part by part, and <int:name> takes only digits and gives an int.""" want, got = pattern.strip("/").split("/"), path.strip("/").split("/") if len(want) != len(got): return None params: dict = {} for w, g in zip(want, got, strict=True): if not (w.startswith("<") and w.endswith(">")): if w != g: return None continue kind, _, name = w[1:-1].rpartition(":") if kind == "int": if not g.isdigit(): return None params[name] = int(g) elif g: params[name] = g else: return None return params- Matches
Values with typeswas Any number of values- Wrong on
Nothing in the taskwas Types- Why
- A bad value doesn’t match, so the router answers 404 and the view gets an int.
What changed from the previous rung
def match(pattern: str, path: str) -> dict | None: """Better: compare part by part between the slashes.""" """Best: part by part, and <int:name> takes only digits and gives an int.""" want, got = pattern.strip("/").split("/"), path.strip("/").split("/") if len(want) != len(got): return None params = {} params: dict = {} for w, g in zip(want, got, strict=True): if w.startswith("<") and w.endswith(">"): params[w[1:-1].split(":")[-1]] = g elif w != g: if not (w.startswith("<") and w.endswith(">")): if w != g: return None continue kind, _, name = w[1:-1].rpartition(":") if kind == "int": if not g.isdigit(): return None params[name] = int(g) elif g: params[name] = g else: return None return paramsWhen Best isn’t best: real routers compile each pattern once and try many routes fast. Use the framework’s router.
2. Give every request an id (medium)
Section titled “2. Give every request an id (medium)”Write the middleware request_id(request, call_next). It sets request["id"] and sends it back in the X-Request-ID response header. If the caller sent an X-Request-ID header of 1 to 64 letters, digits and dashes, keep it. Otherwise make a new one with uuid.uuid4().hex.
The id goes into every log line, so you can find all the lines of one request. Keeping the caller’s id lets one id follow a request through several services.
Example: with request["headers"] == {"X-Request-ID": "abc-123"}, the response has X-Request-ID: abc-123.
Hint 1 of 3
Set request["id"] before you call call_next, so the inner layers can log it.
Hint 2 of 3
The header comes from outside. A client can send anything, even a line break that fakes a log line.
Hint 3 of 3
re.fullmatch(r"[A-Za-z0-9-]{1,64}", text) says whether all of it is safe.
Give every request an id
Ctrl + Enter runs- Not run yet:
The caller sent X-Request-ID: abc-123→['abc-123', 'abc-123'] - Not run yet:
No header: a new id of 32 letters and digits→[32, True, True] - Not run yet:
A header with a line break in it is replaced→32 - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
A new id every time
The idea: Make a uuid, put it on the request, and send it back.
Every log line of one request now shares an id.
import uuid
def request_id(request, call_next): """Okay: a new id for every request, sent back in the response.""" request["id"] = uuid.uuid4().hex response = call_next(request) response["headers"]["X-Request-ID"] = request["id"] return response- Follows a request
In this service- Risk
None- Why
- Each service makes its own id, so the ids don’t join up.
Where it breaks: when the request came from another of your services, its id is thrown away. You can’t follow it from one service to the next.
Keep the caller’s id
The idea: Use the X-Request-ID header if there is one, or else make a new id.
The waste in Okay: it threw away an id that was already there.
Highlighted lines are new in this rung.
import uuid
def request_id(request, call_next): """Better: keep the id the caller sent, so one id follows the request through every service.""" request["id"] = request["headers"].get("X-Request-ID") or uuid.uuid4().hex response = call_next(request) response["headers"]["X-Request-ID"] = request["id"] return response- Follows a request
Across serviceswas In this service- Risk
Fake log lineswas None- Why
- The same id travels from service to service.
What changed from the previous rung
import uuid
def request_id(request, call_next): """Okay: a new id for every request, sent back in the response.""" request["id"] = uuid.uuid4().hex """Better: keep the id the caller sent, so one id follows the request through every service.""" request["id"] = request["headers"].get("X-Request-ID") or uuid.uuid4().hex response = call_next(request) response["headers"]["X-Request-ID"] = request["id"] return responseWhere it breaks: the header comes from anyone. A line break in it writes a fake line into your logs, and a huge one fills them.
Keep it only if it’s plain
The idea: Keep the caller’s id if it is 1 to 64 letters, digits and dashes. Otherwise make a new one.
The waste in Better: it trusted text from outside.
Highlighted lines are new in this rung.
import reimport uuid
SAFE_ID = re.compile(r"[A-Za-z0-9-]{1,64}")
def request_id(request, call_next): """Best: keep the caller's id only if it is short and plain; it ends up in your logs.""" sent = request["headers"].get("X-Request-ID", "") request["id"] = sent if SAFE_ID.fullmatch(sent) else uuid.uuid4().hex response = call_next(request) response["headers"]["X-Request-ID"] = request["id"] return response- Follows a request
Across services- Risk
Nonewas Fake log lines- Why
- A strict check lets real ids through and turns away anything odd.
What changed from the previous rung
import reimport uuid
SAFE_ID = re.compile(r"[A-Za-z0-9-]{1,64}")
def request_id(request, call_next): """Better: keep the id the caller sent, so one id follows the request through every service.""" request["id"] = request["headers"].get("X-Request-ID") or uuid.uuid4().hex """Best: keep the caller's id only if it is short and plain; it ends up in your logs.""" sent = request["headers"].get("X-Request-ID", "") request["id"] = sent if SAFE_ID.fullmatch(sent) else uuid.uuid4().hex response = call_next(request) response["headers"]["X-Request-ID"] = request["id"] return responseWhen Best isn’t best: for many services, use a tracing standard like the W3C traceparent header and a tracing library, not a home-made id.
3. Time every request (medium)
Section titled “3. Time every request (medium)”Write timing(clock, log). It gives back a middleware that times the layers inside it with clock(), which gives seconds. After each request, it appends (path, status, milliseconds) to log. If the inner layers raise, log status 500, then let the error go on.
Example: clock gives 1.0, then 1.012, and the view answers 200 for /notes. Then log is [("/notes", 200, 12)].
Hint 1 of 3
Read the clock before and after call_next. Times 1000 for milliseconds, and round().
Hint 2 of 3
If call_next raises, the lines after it never run. Which part of a try always runs?
Hint 3 of 3
Start with status = 500, and change it only when a response comes back.
Time every request
Ctrl + Enter runs- Not run yet:
/notes answers 200 in 12 ms→[['/notes', 200, 12]] - Not run yet:
The inner layers raise: log 500 and 1000 ms, and the error goes on→[[['/boom', 500, 1000]], True] - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Time it, then log
The idea: Read the clock, call the next layer, read the clock again, and log the path and milliseconds.
It times every request that works.
def timing(clock, log): """Okay: time the inner layers, and log the path and milliseconds."""
def middleware(request, call_next): start = clock() response = call_next(request) log.append((request["path"], round((clock() - start) * 1000))) return response
return middleware- Logs
Requests that answer- Misses
Failures, status- Why
- The log line comes after call_next, so an error skips it.
Where it breaks: the requests you most need to see, the ones that fail, are never logged.
Log in finally
The idea: Put the log line in a finally block, so it runs whether call_next returns or raises.
The waste in Okay: the same line, in the wrong place.
Highlighted lines are new in this rung.
def timing(clock, log): """Better: log in finally, so a request that fails is timed too."""
def middleware(request, call_next): start = clock() try: return call_next(request) finally: log.append((request["path"], round((clock() - start) * 1000)))
return middleware- Logs
Every requestwas Requests that answer- Misses
Statuswas Failures, status- Why
- finally always runs, then the error goes on.
What changed from the previous rung
def timing(clock, log): """Okay: time the inner layers, and log the path and milliseconds.""" """Better: log in finally, so a request that fails is timed too."""
def middleware(request, call_next): start = clock() response = call_next(request) log.append((request["path"], round((clock() - start) * 1000))) return response try: return call_next(request) finally: log.append((request["path"], round((clock() - start) * 1000)))
return middlewareWhere it breaks: a fast failure and a fast success look the same in the log. You can’t ask “how slow are our 500s?”.
Log the status too
The idea: Like Better, with status = 500 until a response comes back.
The waste in Better: it threw away the status it had in hand.
Highlighted lines are new in this rung.
def timing(clock, log): """Best: log the status too, 500 for an error, so slow failures stand out."""
def middleware(request, call_next): start = clock() status = 500 # stays 500 if call_next raises try: response = call_next(request) status = response["status"] return response finally: log.append((request["path"], status, round((clock() - start) * 1000)))
return middleware- Logs
Every request, with statuswas Every request- Misses
Nothing in the taskwas Status- Why
- Path, status and time together answer most questions about a slow app.
What changed from the previous rung
def timing(clock, log): """Better: log in finally, so a request that fails is timed too.""" """Best: log the status too, 500 for an error, so slow failures stand out."""
def middleware(request, call_next): start = clock() status = 500 # stays 500 if call_next raises try: return call_next(request) response = call_next(request) status = response["status"] return response finally: log.append((request["path"], round((clock() - start) * 1000))) log.append((request["path"], status, round((clock() - start) * 1000)))
return middlewareWhen Best isn’t best: in production, send these numbers to a metrics system, like Prometheus or OpenTelemetry, instead of a list. And use time.perf_counter(), which never jumps when the system clock changes.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”- Server, request object, middleware in, router, view, middleware out, server.
- Middleware wraps the view: in, outermost first; out, in reverse.
- A layer can answer early (login), change the request, or change the response (headers).
- An error goes out until a layer catches it. Put must-run work outside the error layer.
- Rules for every request: middleware. Rules for some routes: a decorator or dependency.
Go deeper
Section titled “Go deeper”- Django: middleware, and the order of the built-in ones in the middleware reference.
- Flask: the request context and application structure and lifecycle.
- FastAPI: middleware.
- Next on the Frameworks roadmap: REST design, how to name the paths the router matches.
Can you do these now?
If you’re not sure of any of these, scroll back to that part before moving on.
Status: Not started