A web framework, from the inside
Decorators, middleware and error handling: how Flask and FastAPI work.
Create a free account Try part of it here
Why it matters
FastAPI, Flask and Express are the tools you'll actually ship with. They can look like magic: you write a function, add one line above it, and it answers web requests, with typed parameters and automatic error pages. This lesson builds that magic from parts you already have. After that, a framework's docs read as a menu of features you understand, and its errors become clues instead of mysteries.
What you'll be able to do
- Write decorators, including ones that take arguments like @app.get("/path")
- Read a handler's signature to pass in path values, converting them by their type hints
- Wrap an app in middleware for logging, timing and other cross-cutting work
- Turn every error into a clean JSON response without leaking internals
What it covers
- Decorators
- Parameters from the signature
- Middleware: layers around the whole app
- Errors become responses
- What a decorator really does
- Reading a framework's errors
Decorators
Think of gift wrap. The present inside stays the same, but what you hand over now has something around it. A decorator does that to a function. It takes a function and returns a function, usually the same one with something wrapped around it.
The @ line is shorthand. @timed above def report() means report = timed(report).
In plain words: a decorator is a function that receives your function, and decides what the name should point to from now on.
Here, timed wraps a function so that it reports how long each call took. *args, **kwargs means "accept whatever arguments you're given and pass them along":
import functools, time def timed(fn): @functools.wraps(fn) # keep fn's name and docstring on the wrapper def wrapper(*args, **kwargs): start = time.perf_counter() result = fn(*args, **kwargs) print(f"{fn.__name__} took {(time.perf_counter() - start) * 1000:.2f} ms") return result return wrapper @timed def total(prices): return sum(prices) print(total([4, 9, 12]))
@app.get("/campaigns") has an argument, so it needs one more layer. app.get("/campaigns") runs first, and returns the real decorator.
A framework's route decorators usually don't wrap the function at all. Picture adding a name to a staff directory: the person doesn't change, but now people can find them. The decorator records the function in the route table, and hands it back unchanged:
ROUTES = {}
def get(path): # called with the path...
def register(fn): # ...returns the decorator, called with the function
ROUTES[("GET", path)] = fn
return fn
return register
@get("/health")
def health():
return {"ok": True}
print(ROUTES)
print(ROUTES[("GET", "/health")]())
@cache above def price(sku): do, in plain Python terms?Show the answer
It runs price = cache(price) right after the function is defined
A decorator line is shorthand for passing the new function to the decorator and keeping what it returns under the same name.
Parameters from the signature
Picture a clerk handed a form with labeled boxes. The labels say what goes where, and what kind of answer each box wants. FastAPI treats your function the same way. You write a handler like this:
@app.get("/campaigns/{campaign_id}") def get_campaign(campaign_id: int, include_stats: bool = False): ...
How does it know that campaign_id comes from the path and include_stats from the query string, and that "42" should become the number 42? It reads the labels. A function's signature is its list of parameter names, type hints and defaults, and Python's inspect module can read it, using the same type hints you met in Phase 7:
import inspect def get_campaign(campaign_id: int, include_stats: bool = False): return campaign_id, include_stats sig = inspect.signature(get_campaign) for name, param in sig.parameters.items(): default = None if param.default is inspect.Parameter.empty else param.default print(name, "->", param.annotation.__name__, "default:", default) raw = {"campaign_id": "42"} # everything in a URL arrives as text kwargs = {name: sig.parameters[name].annotation(value) for name, value in raw.items()} print(get_campaign(**kwargs))
Everything in a URL arrives as text, so the type hint is used to convert it. int is itself a function, and int("42") gives 42. If conversion fails, the framework answers 422 Unprocessable Content with a message naming the parameter, before your code runs.
The same signature also drives the automatic API docs in a later lesson. So one function definition does routing, conversion, checking and documentation.
import inspect def list_campaigns(status: str = "active", limit: int = 20): pass print(list(inspect.signature(list_campaigns).parameters))
Show the answer
['status', 'limit']
inspect.signature exposes each parameter's name, type hint and default, so the list of names is ['status', 'limit']. That's how a framework maps ?limit=5 to the limit argument.
Middleware: layers around the whole app
Picture an office building. Everyone passes the same guard at the door and signs the same visitor book, whichever office they're going to. You wouldn't hire a separate guard for each room.
Some jobs in an API are like that. Logging, timing, checking an API key, adding headers and catching crashes apply to every request. Copying them into every handler is the duplication you learned to avoid. Middleware wraps the whole app instead.
A middleware is a function that receives the request and a call_next function, which means "pass it further in". It can work before passing the request on, after the response comes back, or both. It can even answer by itself without calling call_next, as a guard does when your badge is wrong.
Several middlewares stack like the layers of an onion. A request goes in through each layer, reaches the handler, and the response comes back out through the same layers. The first one added is the outermost.
def handler(request): print(" handler runs") return {"status": 200, "body": "ok"} def logging_mw(request, call_next): print(f"log: {request['path']} starting") response = call_next(request) print(f"log: {request['path']} -> {response['status']}") return response def auth_mw(request, call_next): print(" auth: checking key") if request.get("key") != "secret": return {"status": 401, "body": "bad key"} # answer early; handler never runs return call_next(request) def build(handler, middlewares): app = handler for mw in reversed(middlewares): # wrap from the inside out app = (lambda mw, inner: lambda req: mw(req, inner))(mw, app) return app app = build(handler, [logging_mw, auth_mw]) print(app({"path": "/report", "key": "secret"})) print(app({"path": "/report", "key": "wrong"}))
Order matters. Put logging outside auth and you log rejected requests too. Put it inside and you only log the ones that got through.
Each layer wraps the next, like nested function calls.
Show the answer
Timing starts, auth rejects with 401 without calling the handler, and timing records the 401
The first middleware is the outermost layer, so it sees every request and every response, including early rejections.
Errors become responses
Two kinds of failure reach the framework. Think of a restaurant: "we're out of the fish" is a normal thing to tell a customer, while a fire in the kitchen is not, and you don't describe the fire to them.
- Expected errors are the ones your code raises on purpose, like "campaign not found". Frameworks give you an exception for this:
HTTPException(404, ...)in FastAPI,abort(404)in Flask. The framework turns it into a response with that status and a JSON error body. - Unexpected errors are bugs, or a crashed database. These become 500 Internal Server Error with a generic message. Log the full traceback on the server, but never send it to the client. Tracebacks reveal file paths, library versions and sometimes secrets, which is a gift to attackers.
So you can write handlers for the normal case, and raise for the expected problems.
The frameworks you'll meet
| framework | style | notes |
|---|---|---|
| FastAPI | ASGI, async | type hints drive conversion, validation and docs; the common choice for new Python APIs |
| Flask | WSGI | small and flexible; decorators and an app object, very much like this lesson |
| Django | WSGI or ASGI | "batteries included": database layer, admin pages, auth, templates |
| Express | Node.js | the JavaScript equivalent of Flask, built around middleware |
What a decorator really does
Decorators look like magic, and they're not. @get("/items") above a function means this:
def get(path): def register(fn): print("registered", fn.__name__, "at", path) return fn return register @get("/items") def list_items(): return ["tent"] # That's the same as: list_items = get("/items")(list_items) print(list_items())
The function's own code doesn't change at all. The decorator runs once, when Python reaches that line, and can either record the function or replace it with a wrapper. This also explains a common surprise: the "registered" line appears when the module loads, long before any request arrives.
Middleware order matters
Middleware wraps your app in layers, so order decides what each layer can see. If logging sits inside authentication, then failed logins never get logged. If rate limiting sits outside authentication, you can't limit per user, since you don't know who it is yet. Write the order down and test it.
It's also the right home for anything every route needs. Copying auth and logging into each handler means one forgotten copy becomes a security hole. Put them in one layer, and new routes get them automatically.
Never send tracebacks to clients
A traceback helps you, and it helps an attacker too. It reveals file paths, library versions, and sometimes values from the code. So catch every error at the outermost layer, log the full details on your side with a request id, and return a plain 500 with only that id. The user can quote the id to support, and you can find the exact log line.
Choosing between frameworks
Flask and FastAPI are built on the same ideas you've just written. Pick by what you need: FastAPI validates input and writes API docs from your type hints, and it suits async work. Flask is smaller, and older, with a huge library of examples. Either is a fine choice. You can move between them, because the concepts carry over.
Reading a framework's errors
When something goes wrong inside a framework, the traceback can run to dozens of lines of code you didn't write. Look for the lines from your own files, usually near the bottom, since those are the ones you can change. The rest is the same loop, router and middleware you've seen in this lesson, doing its usual job.
Practice what you just read
- 6 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why does @app.get("/path") need a function that returns a function?