Skip to main content
Learning Center
Python Programming

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:

  1. Receive. The server accepts a connection and parses the method, path, query string, headers, and body.
  2. Route. It matches the method and path to a handler. No match means a response without calling the selected business handler.
  3. Authenticate and authorise. Who is asking, and are they allowed? Two separate questions, and conflating them is a recurring source of security defects.
  4. Validate. Are the inputs well-formed and within bounds? Reject here rather than deeper in.
  5. Handle. Run the actual work, which should be the functions you already have.
  6. Serialise. Turn the result into a response body, usually JSON, with a status code and headers.
  7. 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.

The next figure is the much smaller Python simulation. It covers route selection and handler dispatch only; no real server or security checks run.

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.

Each call returns (status, body) as Python values. A real HTTP adapter would still need to create the response and headers. These examples do not exercise all seven lifecycle responsibilities.

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

  1. Name the seven responsibilities in this lesson’s simplified lifecycle order.
  2. What is the difference between 404 and 405?
  3. Why should a handler be thin?
  4. Why is routing worth testing without starting a server?
Answers
  1. Receive, route, authenticate and authorise, validate, handle, serialise, log.
  2. In this example, 404 means the path is unknown. 405 means the path exists but not with the method used. A real HTTP 405 requires an Allow header; the example only returns a body field.
  3. 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.
  4. 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.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.