Module 12: Web Apps and Service Boundaries
The Server Side: Lifecycle, Routes, and Handlers
What happens between a request arriving and a response leaving, how a route table maps a method and path to a handler, and why routing is worth testing on its own.
Lesson 37 of 46 in the recommended order · About 25 min (estimate)
On this page
Outcome
By the end of this lesson you can describe what a server does between receiving a request and sending a response, and you can match a method and path against a route table while telling an unknown path apart from a wrong method.
Why it matters
Module 7 looked at HTTP as a client: you send a request and interpret what comes back. This is the same conversation from the other side, and it is where the review assistant's results become available to other software rather than only to whoever runs the script.
The stage worth understanding first is routing, because it is the one that decides whether the selected business handler runs. It is also, conveniently, the stage that needs no server to test: matching a method and path against a table is an ordinary function.
A note on scope. Frameworks such as FastAPI or Flask are what you would use on your own machine, and both are excellent. Neither runs here, and neither runs on the platform hosting this course, which is a Cloudflare Worker executing JavaScript rather than Python. So the examples here model the parts in plain Python. What transfers is the set of responsibilities and the boundary discipline. Frameworks can arrange those responsibilities differently.
Concept
A simplified lifecycle for this lesson; real frameworks may arrange these responsibilities differently:
- Receive. The server accepts a connection and parses the method, path, query string, headers, and body.
- Route. It matches the method and path to a handler. No match means a response without calling the selected business handler.
- Authenticate and authorise. Who is asking, and are they allowed? Two separate questions, and conflating them is a recurring source of security defects.
- Validate. Are the inputs well-formed and within bounds? Reject here rather than deeper in.
- Handle. Run the actual work, which should be the functions you already have.
- Serialise. Turn the result into a response body, usually JSON, with a status code and headers.
- Log. Record what happened, with a request identifier, and without recording anything private.
A route table maps a method and path to a handler. Method and path together identify a route: GET /shortlist and POST /shortlist are two different routes that may or may not both exist.
In this lesson’s route table, that produces a distinction worth getting right:
- 404 Not Found means the path is unknown.
- 405 Method Not Allowed means the path exists but not with that method.
Both are failures; they tell a client different things. A 405 says "this resource exists, you asked for the wrong operation", which is actionable. Returning 404 for both hides that.
Path parameters let one route serve many resources: /shortlist/{notice_id}. The value is extracted and handed to the handler, and it is input from outside, so it is validated like any other.
Two design rules that matter more than any framework detail. Keep handlers thin: a handler should validate, call a function you already have and tested, and serialise the result. Separating business logic from request handling lets you test it without constructing a request or framework context. Here we model routing as a pure function, from method and path to a handler or a status, so test that decision directly rather than through a running server.
Read the code
def list_shortlist(request):
return 200, {"results": [{"notice_id": "A-1"}], "count": 1}
def shortlist_summary(request):
return 200, {"count": 1, "total_value": 310000}
ROUTES = {
("GET", "/shortlist"): list_shortlist,
("GET", "/shortlist/summary"): shortlist_summary,
}
def handle(method, path, request=None):
"""Return (status, body). Pure: no sockets, no framework."""
handler = ROUTES.get((method, path))
if handler is not None:
return handler(request)
known_methods = {m for (m, p) in ROUTES if p == path}
if known_methods:
return 405, {"error": "method_not_allowed", "allowed": sorted(known_methods)}
return 404, {"error": "not_found"}
print(handle("GET", "/shortlist"))
print(handle("POST", "/shortlist"))
print(handle("GET", "/nope"))
handle models routing and dispatch: it either calls a matching handler or returns an error tuple. It takes a method and a path and returns a status and a body with no network involved. It does not implement authentication, authorisation, request validation, HTTP serialisation, or logging.
The 405 branch puts the registered methods in an allowed body field for this simulation. That field is not an HTTP header. A real HTTP 405 response must include an Allow header listing the supported methods so a client can correct its request.
Each handler returns a status and a body rather than writing a response. That keeps them thin and independently testable, and it is what lets the same functions be wired into a real framework later without changing them.
Inspect the lifecycle and route traces after predicting the output
Conceptual service: responsibilities and early exits
Illustrated successful path: 1 → 2 → 3 → 4 → 5 → 6 → 7
Rejected requests at 2, 3 or 4 skip business work and join response handling at 6. This is a teaching model, not a universal framework order or a complete error-handling design.
1. Receive
Responsibility- Read the incoming method, path and request data.
Continue- → 2. Route
A conceptual service receives HTTP. The worked Python function receives arguments.
2. Route
Responsibility- Select the handler for method + path.
Match- → 3. Identity and permission
Early exit- No usable route: skip the business handler → 6. Error response
The simulation models this decision; its exact branches are in the next figure.
3. Authenticate and authorise
Responsibility- Identify the caller; separately check permission.
Allowed- → 4. Validate
Early exit- Access rejected: skip the handler → 6. Error response
Not implemented in the worked code. Actual placement depends on the service.
4. Validate
Responsibility- Check required inputs and bounds.
Valid- → 5. Handle
Early exit- Invalid input: skip the handler → 6. Error response
Not implemented in the routing example.
5. Handle
Responsibility- Call the selected business operation.
Result- → 6. Response
The example’s matched route calls a tiny handler returning (status, body).
6. Serialise and respond
Responsibility- Build the success or error response, including body, status and headers.
Outcome- → 7. Record the outcome
The simulation returns Python values; it does not send HTTP or JSON bytes.
7. Log
Responsibility- Record the outcome and request identifier without private content.
Not implemented in the example. Real logging can surround processing; this final box represents the outcome record.
Worked Python simulation: three separate calls
GET /shortlist
Exact method + path match
Call list_shortlist(request)
Status- 200
Body- results contains A-1; count is 1
The selected handler runs. No authentication or validation is added by this dispatcher.
POST /shortlist
No exact match, but the path exists
Return early; do not call a handler
Status- 405
Body- error: method_not_allowed; allowed: [GET]
allowed is a Python body field here, not the Allow header required in real HTTP.
GET /nope
No exact match and no methods for this path
Return early; do not call a handler
Status- 404
Body- error: not_found
This branch means unknown path in the example. Real services may also use 404 to conceal a resource.
Predict the output
Predict the three printed lines.
Check your prediction
(200, {'results': [{'notice_id': 'A-1'}], 'count': 1})
(405, {'error': 'method_not_allowed', 'allowed': ['GET']})
(404, {'error': 'not_found'})
The middle line is the useful one. A client that tried to POST to /shortlist is told the resource exists and accepts GET. Compare that with a bare 404, which would send someone checking whether they had the path wrong.
Both error bodies use a short machine-readable code rather than a sentence, which is what lets a client branch on the failure. Human-readable text can accompany it; the code is what software reads.
Modify the code
Remove the known_methods branch, so any unmatched request returns 404. Predict the three lines, then say what a client loses.
What changes, and why
The second line becomes (404, {'error': 'not_found'}), identical to the third.
A client, or a developer, now cannot distinguish "you have the wrong path" from "you have the wrong method". Both look like the resource does not exist, which misleadingly sends anyone debugging the POST request to check a path that is already valid.
There is a real argument on the other side, worth knowing: some services return 404 deliberately for resources the caller is not permitted to see, so that the response does not confirm existence. That is a considered decision about a specific class of resource, and it is different from collapsing the distinction everywhere by accident.
Debug the bug
An assistant was asked for a handler returning the shortlist for one state. It produced this.
import sqlite3
connection = sqlite3.connect(":memory:")
connection.execute("CREATE TABLE notices (notice_id TEXT PRIMARY KEY, state TEXT, amount INTEGER)")
connection.execute("INSERT INTO notices VALUES ('A-1', 'OR', 310000)")
def shortlist_by_state(request):
state = request["query"]["state"]
rows = connection.execute(
"SELECT notice_id, amount FROM notices WHERE state = '" + state + "'"
).fetchall()
return 200, {"results": rows}
print(shortlist_by_state({"query": {"state": "OR"}}))
What's actually wrong
Three defects, in increasing order of severity.
The parameter is read without a default. request["query"]["state"] raises KeyError when the caller omits it, which a framework turns into a 500. A missing required parameter is a client error and should be a 400 with a message naming the parameter.
The query is built by concatenation. This is Module 9's injection hole, now reachable by anyone who can send an HTTP request rather than only by someone with local access. The value comes straight from a URL a stranger controls.
The handler contains the logic. The query lives inside the handler, so the only way to test it is to construct a request. The database access, the filtering, and the response shape are welded together.
The version with the boundary in the right place:
def fetch_by_state(connection, state):
"""Ordinary function. Testable with no request and no server."""
return connection.execute(
"SELECT notice_id, amount FROM notices WHERE state = ?", (state,)
).fetchall()
def shortlist_by_state(request):
state = request.get("query", {}).get("state")
if not state:
return 400, {"error": "missing_parameter", "parameter": "state"}
return 200, {"results": fetch_by_state(connection, state)}
The handler now does the three things a handler should: read input, validate it, call a function that already exists. Every interesting behaviour is in fetch_by_state, which needs no HTTP to test.
Try it yourself
Write the route matcher. Four requests, and the wrong-method case must be distinguishable from the unknown-path case.
Loading this exercise…
Practical challenge (optional)
Optional: add a route with a path parameter, GET /shortlist/{notice_id}, and extend the matcher to recognise it. Decide what to return when the identifier does not exist in the data: a 404 naming the resource, or a 200 with an empty result. Then write one sentence justifying the choice. Both are defensible, and knowing why you picked one is what a reviewer will ask.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- Name the seven responsibilities in this lesson’s simplified lifecycle order.
- What is the difference between
404and405? - Why should a handler be thin?
- Why is routing worth testing without starting a server?
Answers
- Receive, route, authenticate and authorise, validate, handle, serialise, log.
- In this example,
404means the path is unknown.405means the path exists but not with the method used. A real HTTP405requires anAllowheader; the example only returns a body field. - A plain Python handler can be called directly with a minimal request-shaped value; no server is required. Extracting business logic into a separate function makes it easier to test without that request context.
- The routing decision in this lesson is modelled as a pure function from method and path to a handler or a status. Testing it directly is faster, more precise, and covers cases that are awkward to provoke through a real server.
Sign in to track your progress on this exercise.
Summary and next step
This lesson separates seven request-handling responsibilities; routing decides which business handler is selected, 405 is a more useful failure than 404 when the path exists, and a thin handler keeps every interesting behaviour testable. Next: validating what arrives at that boundary, and writing errors that help a caller without helping an attacker.