Writing/The Unbearable Lightness of Coding

Chapter 30 · Draft · 50 min read

Web Programming: How to Write a Webserver

Every Website Is a Loop

In the previous chapter we were the client: we were the browser, using curl or requests. We saw that a website is a process, listening on some port, waiting for a request; and when such a request arrives, it parses it, runs some code, and writes a response.

In this chapter, we'll switch sides. We'll be the server, we'll write the process that receives requests, does something, and sends responses.

But before we do that, let's talk about web servers and web frameworks. Why do we need them at all? When people were just starting out with the web, and trying to figure out how it works, they realized pretty quickly that nobody wants to keep writing TCP listeners and HTTP parsers; they want to deal with resources—the website's HTML—and maybe a little bit of routing: this resource for that URL. So, they wrote generic servers, like Apache or nginx, that did all the heavy lifting for you: "serve this folder over HTTP". Then, when websites became dynamic, they wrote generic frameworks, that reduced the problem to two: routing (which request goes where—what URL invokes what code) and the actual logic (what this code returns).

To put it differently, some problems are rigid—and the same for everyone (e.g. HTTP is what it is); and some problems are custom—that's what makes your website yours (e.g. your routes and your logic). A framework is a way to take care of the rigid part, and let you focus on the custom part: fill in some snippets, which it then plugs in.

To illustrate this, I'd like to do what I always do: first, write it by hand—and it should be disgusting; then, write our own infrastructure—with decorators, because they're cool—to solve the problem in a much more elegant way; and finally, use the real libraries and see that it's almost exactly the same, and appreciate just how beautiful this world can be.

So, let's start from the simplest server we have: our synchronous echo server from the sockets chapter:

# echo_server.py
import socket

server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('127.0.0.1', 8000))
server.listen()
while True:
    connection, address = server.accept()
    with connection:
        while data := connection.recv(4096):
            connection.sendall(data)

Quick recap: we create a socket, set the SO_REUSEADDR flag to true so a restart doesn't fail because the old address is still lingering, and then we bind, listen, and go into an infinite loop—accepting a connection, receiving data from it until the client closes the connection (which recv signals by returning an empty bytestring), and sending everything back. It can only handle one client at a time, which is a problem we already know how to solve, from the concurrency and asynchrony chapters—but for now, just note the difference between this and what we want to do—namely, be a web server. It's all in the bit between recv and send: parsing a request, running some code, and formatting a response. The rest of this chapter is going to be all about making this middle part as pleasant as possible.

We're going to implement an HTTP server on top of a raw socket; then do it with the standard library's http.server; then write our own decorator-based framework, with regex routes and request and response objects; then WSGI—the standard plug between servers and applications; templates; Flask; and ASGI, its asynchronous sibling. All the servers will run on localhost, and I'll show both terminals: the one running the server, and the one running the client.

So, let's start with a raw socket.

HTTP from a Raw Socket

At this point, we can write the most basic web server in history: one that ignores the request altogether, and sends the same response to everyone. All it has to do is:

# raw_server1.py
import socket

RESPONSE = b'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 14\r\n\r\nHello, world!\n'

server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('127.0.0.1', 8000))
server.listen()
while True:
    connection, address = server.accept()
    with connection:
        request = connection.recv(4096)
        print(request.decode())
        connection.sendall(RESPONSE)

And that's it; this will print every request and answer them all with "Hello, world!"—which is actually all that's needed to qualify as a web server, if you think about it. After all, the response is just a bunch of bytes: a status line, a Content-Type header, a Content-Length header (14, for the thirteen characters of Hello, world! and the newline), an empty line, and finally, the body—and it's a perfectly valid response, which any client in the world would accept. Let's see it in action:

$ curl -si 'http://localhost:8000/whatever?you=like'
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 14

Hello, world!
$ curl -s -d 'x=1&y=2' http://localhost:8000/add
Hello, world!

And it works: it doesn't really care about what we ask, even if we POST to /add. It doesn't know what to do with it, obviously—but it doesn't care, either, and neither does curl; if we'd use a browser, it would be just as happy to draw it. In fact, the server's terminal also prints out the raw requests we send:

$ python raw_server1.py
GET /whatever?you=like HTTP/1.1
Host: localhost:8000
User-Agent: curl/8.7.1
Accept: */*


POST /add HTTP/1.1
Host: localhost:8000
User-Agent: curl/8.7.1
Accept: */*
Content-Length: 7
Content-Type: application/x-www-form-urlencoded

x=1&y=2

And this is the other half of our conversation, the one we could only guess about in the previous chapter: the request line, with its method, target (including the query string) and version; the Host, User-Agent and Accept headers; and in case of the POST request, the Content-Length and Content-Type headers, the empty line, and then the body, urlencoded just like a query string. If we'd use a browser, it would actually send a lot more headers, and even make an extra request for /favicon.ico, the little icon you see on the tab—but this is the gist, really.

So far, so good—but there are a few problems with our web server. For one, we're using recv(4096), which doesn't guarantee that we'll get a request; TCP is a stream, after all, so it might arrive in several pieces, or have a body that is longer than 4096 bytes. The correct way to read it would be to read until we get an empty line, which indicates the end of the headers, and then read exactly Content-Length bytes, which make the body; and even then, we'd have to parse it, and then route it.

The reason I'm telling you this is that it's actually quite tricky: we don't have a guarantee that even the headers arrive in a single piece, so parsing them is not trivial. Luckily, there's a little trick that makes this much easier: we can use socket.makefile('rb') to wrap the socket in a buffered file object, so we can use readline on it, and let its buffer worry about the details of filling up. Let's do that:

# raw_server2.py
import socket
import urllib.parse
from http import HTTPStatus


def read_request(connection):
    stream = connection.makefile('rb')
    method, target, version = stream.readline().decode().split()
    headers = {}
    while (line := stream.readline()) != b'\r\n':
        key, value = line.decode().split(':', 1)
        headers[key.strip().lower()] = value.strip()
    body = stream.read(int(headers.get('content-length', 0)))
    return method, target, headers, body


def write_response(connection, status, body, content_type='text/plain'):
    body = body.encode()
    head = f'HTTP/1.1 {status} {HTTPStatus(status).phrase}\r\n'
    head += f'Content-Type: {content_type}\r\nContent-Length: {len(body)}\r\nConnection: close\r\n\r\n'
    connection.sendall(head.encode() + body)


def handle(method, target, headers, body):
    url = urllib.parse.urlsplit(target)
    query = urllib.parse.parse_qs(url.query)
    if method == 'GET' and url.path == '/hello':
        name = query.get('name', ['world'])[0]
        return 200, f'Hello, {name}!\n'
    if method == 'POST' and url.path == '/add':
        form = urllib.parse.parse_qs(body.decode())
        return 200, f'{int(form["x"][0]) + int(form["y"][0])}\n'
    return 404, f'No {method} {url.path} here.\n'


server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('127.0.0.1', 8000))
server.listen()
while True:
    connection, address = server.accept()
    with connection:
        method, target, headers, body = read_request(connection)
        status, text = handle(method, target, headers, body)
        print(f'{address[0]} {method} {target} -> {status}')
        write_response(connection, status, text)

That's already quite a bit of code, so let's go over it slowly. The read_request function first splits the request line into the method, target and version; then, it reads the header lines until it encounters one that's exactly \r\n—note that it lowercases the header names, because they're case-insensitive. Finally, it reads exactly Content-Length bytes, which make up the body.

The write_response function prepares the status line, using the reason phrase provided by HTTPStatus, computes the Content-Length, and adds Connection: close, because we don't support keep-alive connections. And finally, our handle function is where the magic happens; it's where our website lives—the urlsplit and parse_qs for /hello?name=, the urlencoded form for /add, and a 404 for everything else.

If we were to run this, we could use requests to do a little session:

>>> import requests
>>> requests.get('http://127.0.0.1:8000/hello').text
'Hello, world!\n'
>>> requests.get('http://127.0.0.1:8000/hello', params={'name': 'Dan'}).text
'Hello, Dan!\n'
>>> requests.post('http://127.0.0.1:8000/add', data={'x': 1, 'y': 2}).text
'3\n'
>>> response = requests.get('http://127.0.0.1:8000/goodbye')
>>> response.status_code, response.reason, response.text
(404, 'Not Found', 'No GET /goodbye here.\n')

And the server's terminal would log:

$ python raw_server2.py
127.0.0.1 GET /hello -> 200
127.0.0.1 GET /hello?name=Dan -> 200
127.0.0.1 POST /add -> 200
127.0.0.1 GET /goodbye -> 404

And it works! And it's already a lot of code for our two routes. But just think how much is missing: we don't support keep-alive connections, chunked bodies, Expect: 100-continue, or any defense against malformed requests (like a client sending a single word instead of a request line, which will crash our split), or limits on the size of headers (a malicious client might send us headers forever), or concurrent requests (we can only handle one client at a time), or HTTPS, or… anything, really. The HTTP/1.1 specification is pretty long, and you don't want to own a parser for it.

The Standard Library's http.server

We've already seen one such parser: http.server, the same module we used for python -m http.server in the previous chapter. It provides the BaseHTTPRequestHandler class, which parses everything for us, and expects us to define a handler method for every HTTP verb we want to support: do_GET, do_POST and so on. The following is an example of such a handler:

# http_server.py
import re
import urllib.parse
from http.server import BaseHTTPRequestHandler, HTTPServer

USERS = {1: 'Alice Lovelace', 2: 'Bob Babbage'}


class Handler(BaseHTTPRequestHandler):

    def do_GET(self):
        if self.path == '/':
            items = ''.join(f'<li><a href="/users/{user_id}">user {user_id}</a></li>' for user_id in USERS)
            return self.respond(200, f'<html><body><ul>{items}</ul></body></html>')
        if match := re.fullmatch(r'/users/(\d+)', self.path):
            user_id = int(match.group(1))
            if user_id in USERS:
                return self.respond(200, f'<html><body><h1>User {user_id}: {USERS[user_id]}</h1></body></html>')
        self.respond(404, '')

    def do_POST(self):
        if self.path != '/add':
            return self.respond(404, '')
        length = int(self.headers['Content-Length'])
        form = urllib.parse.parse_qs(self.rfile.read(length).decode())
        self.respond(200, str(int(form['x'][0]) + int(form['y'][0])), 'text/plain')

    def respond(self, status, body, content_type='text/html'):
        body = body.encode()
        self.send_response(status)
        self.send_header('Content-Type', content_type)
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)


if __name__ == '__main__':
    server = HTTPServer(('127.0.0.1', 8000), Handler)
    server.serve_forever()

Let's go over it, shall we? self.path is the raw target (careful though, it includes the query string); self.headers is a case-insensitive mapping of the message headers; and self.rfile and self.wfile are file objects wrapping the socket, so we can read exactly Content-Length bytes from rfile. To answer, we call send_response, which writes the status line (and a couple of standard headers, like Server and Date, as well as logging the request); send_header to write a header; end_headers to write the empty line; and then write the body to wfile. Our routing is a chain of ifs, and even a regular expression—but this was exactly the first exercise of my course: to serve an index of users and a page for every user.

To run it, we do HTTPServer(('127.0.0.1', 8000), Handler).serve_forever(), which creates a new handler for every request. Note, however, that we're binding it to 127.0.0.1—if we'd want to make it available to the outside world, we'd need to bind it to 0.0.0.0, and then anyone on our network would be able to access it, provided they know our IP address. If my laptop is connected to the same WiFi as my phone, I can use my phone's browser to connect to my laptop's address, which is a nice perk—but if we want to be available to the entire internet, we'd need a public IP address and a domain name, and that's a whole other industry.

Anyway, here's how our server behaves:

>>> import requests
>>> print(requests.get('http://localhost:8000/').text)
<html><body><ul><li><a href="/users/1">user 1</a></li><li><a href="/users/2">user 2</a></li></ul></body></html>
>>> requests.get('http://localhost:8000/users/1').text
'<html><body><h1>User 1: Alice Lovelace</h1></body></html>'
>>> requests.get('http://localhost:8000/users/3')
<Response [404]>
>>> requests.post('http://localhost:8000/add', data={'x': 1, 'y': 2}).text
'3'
>>> requests.request('BREW', 'http://localhost:8000/coffee')
<Response [501]>

And here's its log:

$ python http_server.py
127.0.0.1 - - [14/Oct/2026 19:48:08] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [14/Oct/2026 19:48:08] "GET /users/1 HTTP/1.1" 200 -
127.0.0.1 - - [14/Oct/2026 19:48:08] "GET /users/3 HTTP/1.1" 404 -
127.0.0.1 - - [14/Oct/2026 19:48:08] "POST /add HTTP/1.1" 200 -
127.0.0.1 - - [14/Oct/2026 19:48:08] code 501, message Unsupported method ('BREW')
127.0.0.1 - - [14/Oct/2026 19:48:08] "BREW /coffee HTTP/1.1" 501 -

Note that user 3 is a 404, just like we've routed it to be. And note that the request with the made-up BREW method gets a 501 error, "Unsupported method", because we never implemented it. But this raises an interesting point: how does the server know to call do_GET in the first place?

>>> import http.server, inspect
>>> source = inspect.getsource(http.server.BaseHTTPRequestHandler.handle_one_request)
>>> import textwrap
>>> print(textwrap.dedent(source[source.index('            mname'):source.index('method()') + 8]))
mname = 'do_' + self.command
if not hasattr(self, mname):
    self.send_error(
        HTTPStatus.NOT_IMPLEMENTED,
        "Unsupported method (%r)" % self.command)
    return
method = getattr(self, mname)
method()

It actually constructs the method's name, 'do_' + self.command, and attempts to find it using hasattr and getattr, in what's known as a dispatch by naming convention—the dispatch dictionary of the Conditions chapter, with the class namespace playing the role of the dictionary (for a callback, see the Objects chapters, on attributes). This, then, means that all we need to do to add a verb is to add a method:

>>> import threading
>>> import requests
>>> from http_server import Handler
>>> class TeapotHandler(Handler):
...     def do_BREW(self):
...         self.respond(418, "I'm a teapot.\n", 'text/plain')
>>> server = http.server.HTTPServer(('127.0.0.1', 8000), TeapotHandler)
>>> threading.Thread(target=server.serve_forever, daemon=True).start()
>>> response = requests.request('BREW', 'http://localhost:8000/coffee')
127.0.0.1 - - [14/Oct/2026 19:48:11] "BREW /coffee HTTP/1.1" 418 -
>>> response, response.text
(<Response [418]>, "I'm a teapot.\n")

And there you have it, BREW gets 418 I'm a teapot, just like RFC 2324 intended.

So yeah, it's pretty cumbersome, but even this very basic framework doesn't make any assumptions about your logic. Every new route is an if, every handler a repetition of the send_response, send_header, end_headers, wfile ritual, and parsing forms is on you. It's pretty disgusting, isn't it? But it was promised. So let's get to the second step.

Routing with Decorators

Now, all this HTTP logic—do_GET, end_headers, wfile and the rest—is pretty annoying. So let's encapsulate it in a nice class with a beautiful @ interface, which I'll call a Website. The specification is actually from the second exercise of the course, so here it is: the Website.route(path) decorator should be used to register a handler for a specific path. The paths should be regular expressions, and their groups should be passed to the handler as arguments. The handlers should return a status and a body, and then Website.run(address) should serve the website at this address.

As usual, let's start from how we want to use it, and see if we can make it work:

# website_example.py
from website import Website


website = Website()


@website.route('/')
def index():
    return 200, '<html>users list</html>'


@website.route('/users/([0-9]+)')
def user(user_id):
    if user_id not in ['1', '2']:
        return 404, ''
    return 200, f'<html>user {user_id}</html>'


if __name__ == '__main__':
    website.run(('127.0.0.1', 8000))

Isn't this nice? It almost looks like a table of contents: a URL, and the function that handles it. The '/users/([0-9]+)' pattern captures the user id, so it ends up as an argument—and as a string, since regex groups are strings. Anyway, the handlers are completely agnostic to anything having to do with HTTP, so they can focus on the important bits and just return a tuple of status and body.

# website.py
import re
from http.server import BaseHTTPRequestHandler, HTTPServer


class Website:

    def __init__(self):
        self.routes = []

    def route(self, path):
        def decorator(handler):
            self.routes.append((re.compile(path), handler))
            return handler
        return decorator

    def dispatch(self, path):
        for pattern, handler in self.routes:
            if match := pattern.fullmatch(path):
                return handler(*match.groups())
        return 404, ''

    def run(self, address):
        website = self

        class Handler(BaseHTTPRequestHandler):
            def do_GET(self):
                status, body = website.dispatch(self.path)
                body = body.encode()
                self.send_response(status)
                self.send_header('Content-Type', 'text/html')
                self.send_header('Content-Length', str(len(body)))
                self.end_headers()
                self.wfile.write(body)

        HTTPServer(address, Handler).serve_forever()

The route function is actually a second-order decorator (or decorator factory, if you remember the Decorators chapter); route(path) returns the actual decorator, which registers the compiled pattern and handler, and returns the handler as-is. This way, it's not a decorator that wraps the function, but one that registers it, so it stays an actual function that we can call and test.

The dispatch function tries the routes one after the other, and the first one to match, wins; note that it uses fullmatch rather than match, because re.match('/', '/users/1') would actually succeed (that is, match only anchors strings at their start, and the index route would match and swallow everything). If none of the routes matches, we get a 404.

Finally, the run method defines the handler class inside the method itself, at runtime, in a closure over the website object (we've seen in the Objects and Metaclasses chapters that classes are created when their class statement runs, which is why we can do this here)—hence the website name, since inside the class, self is the request handler, not the website.

And it works:

>>> import requests
>>> requests.get('http://localhost:8000/').text
'<html>users list</html>'
>>> requests.get('http://localhost:8000/users/2').text
'<html>user 2</html>'
>>> requests.get('http://localhost:8000/users/3')
<Response [404]>
>>> requests.get('http://localhost:8000/users/alice')
<Response [404]>

And here's the log:

$ python website_example.py
127.0.0.1 - - [14/Oct/2026 19:48:14] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [14/Oct/2026 19:48:14] "GET /users/2 HTTP/1.1" 200 -
127.0.0.1 - - [14/Oct/2026 19:48:14] "GET /users/3 HTTP/1.1" 404 -
127.0.0.1 - - [14/Oct/2026 19:48:14] "GET /users/alice HTTP/1.1" 404 -

Note how /users/alice is a 404: the [0-9]+ doesn't match the string, so it effectively validates the input.

We can also print the registered routes, call dispatch directly, and even call a handler directly:

>>> from website_example import website
>>> website.routes
[(re.compile('/'), <function index at 0x108721800>), (re.compile('/users/([0-9]+)'), <function user at 0x10881a3e0>)]
>>> website.dispatch('/users/1')
(200, '<html>user 1</html>')
>>> from website_example import user
>>> user('2')
(200, '<html>user 2</html>')

Because route returns the function, and because dispatch doesn't touch any sockets, we can actually test them without having to spin up a server. The tests for this exercise in the course actually start the website in a separate process and talk to it with requests (they pass, by the way); but a lot of it can be tested simply by invoking it as a plain function.

Now, we don't support anything other than GET; and our handlers only see the path, not the query string, the headers or the body—every response is text/html, and there's no way to set custom headers or cookies. But we'll cover this soon.

As an aside, this is actually Flask's design, almost line by line, as we shall see; and Click, which we saw in the CLIs chapter, works pretty much the same way. In fact, as part of this exercise, I had the students develop a simple CLI framework with this neat little trick, before we switched to Click.

Request and Response Objects

Of course, our handlers will need more than just the path—and we might want them to be able to say more than a status and a body, so let's define a couple of objects to contain this information. On the one hand we have the request, which contains the method, path, query, headers and body, as well as a json() helper to parse it; and on the other, we have the response, which holds the body, a status and some headers. Both are dataclasses, which means we get their constructors, representation and equality for free:

# framework.py
import json
import re
import urllib.parse
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, HTTPServer


@dataclass
class Request:
    method: str
    path: str
    query: dict
    headers: dict
    body: bytes = b''

    def json(self):
        return json.loads(self.body)


@dataclass
class Response:
    body: str = ''
    status: int = 200
    headers: dict = field(default_factory=lambda: {'Content-Type': 'text/html'})


def json_response(obj, status=200):
    return Response(json.dumps(obj), status, {'Content-Type': 'application/json'})


class Website:

    def __init__(self):
        self.routes = []

    def route(self, path, methods=('GET',)):
        def decorator(handler):
            self.routes.append((re.compile(path), set(methods), handler))
            return handler
        return decorator

    def dispatch(self, request):
        allowed = set()
        for pattern, methods, handler in self.routes:
            if match := pattern.fullmatch(request.path):
                if request.method not in methods:
                    allowed |= methods
                    continue
                response = handler(request, *match.groups())
                return response if isinstance(response, Response) else Response(response)
        if allowed:
            return Response('', 405, {'Allow': ', '.join(sorted(allowed))})
        return Response('', 404)

    def run(self, address):
        website = self

        class Handler(BaseHTTPRequestHandler):
            def handle_any(self):
                url = urllib.parse.urlsplit(self.path)
                length = int(self.headers.get('Content-Length', 0))
                request = Request(self.command, url.path, urllib.parse.parse_qs(url.query),
                                  {k.lower(): v for k, v in self.headers.items()}, self.rfile.read(length))
                response = website.dispatch(request)
                body = response.body.encode()
                self.send_response(response.status)
                for key, value in response.headers.items():
                    self.send_header(key, value)
                self.send_header('Content-Length', str(len(body)))
                self.end_headers()
                self.wfile.write(body)
            do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = handle_any

        HTTPServer(address, Handler).serve_forever()

Let's walk through it. The route(path, methods=('GET',)) decorator now receives a tuple as its default argument, not a list—to avoid the mutable default argument trap from Functions 1. It passes the request to the handler first, before the regex groups—and if a handler returns a plain string, it's wrapped in a Response, which is a convenience Flask offers, too. But dispatch is now able to distinguish between two different failures: no route matched the request path, in which case we get a 404 error—and some route matched it, but not its method, in which case we get a 405 error, along with an Allow header listing what methods are permitted.

As for run, we simply assign the same handle_any method to do_GET, do_POST and the rest—because figuring out what to do based on the method is now the framework's job, not http.server's. To give an example of a handler that uses all this new functionality, consider this little API:

# thoughts.py
from framework import Website, json_response

website = Website()
THOUGHTS = {1: ["I'm hungry"]}


@website.route('/hello')
def hello(request):
    name = request.query.get('name', ['world'])[0]
    return f'<h1>Hello, {name}!</h1>'


@website.route('/users/([0-9]+)/thoughts')
def get_thoughts(request, user_id):
    return json_response(THOUGHTS.get(int(user_id), []))


@website.route('/users/([0-9]+)/thoughts', methods=['POST'])
def add_thought(request, user_id):
    THOUGHTS.setdefault(int(user_id), []).append(request.json()['thought'])
    return json_response({'ok': True}, 201)


if __name__ == '__main__':
    website.run(('127.0.0.1', 8000))

This is a small API for a user's thoughts; it's actually the same URL with two different handlers, depending on the method: GET lists all the user's thoughts, while POST adds a new one—just like the REST of our previous chapter. To parse the body, we use request.json(), and to set the right Content-Type, we use json_response, which also lets us return 201:

>>> import requests
>>> requests.get('http://localhost:8000/hello', params={'name': 'Dan'}).text
'<h1>Hello, Dan!</h1>'
>>> requests.get('http://localhost:8000/users/1/thoughts').json()
["I'm hungry"]
>>> response = requests.post('http://localhost:8000/users/1/thoughts', json={'thought': "I'm sleepy"})
>>> response.status_code, response.json()
(201, {'ok': True})
>>> requests.get('http://localhost:8000/users/1/thoughts').json()
["I'm hungry", "I'm sleepy"]
>>> response = requests.delete('http://localhost:8000/users/1/thoughts')
>>> response.status_code, response.headers['Allow']
(405, 'GET, POST')

And in the server's log:

$ python thoughts.py
127.0.0.1 - - [14/Oct/2026 19:48:23] "GET /hello?name=Dan HTTP/1.1" 200 -
127.0.0.1 - - [14/Oct/2026 19:48:23] "GET /users/1/thoughts HTTP/1.1" 200 -
127.0.0.1 - - [14/Oct/2026 19:48:24] "POST /users/1/thoughts HTTP/1.1" 201 -
127.0.0.1 - - [14/Oct/2026 19:48:24] "GET /users/1/thoughts HTTP/1.1" 200 -
127.0.0.1 - - [14/Oct/2026 19:48:24] "DELETE /users/1/thoughts HTTP/1.1" 405 -

When we sent a DELETE request to the same URL, we got a 405 error with Allow: GET, POST; and we can also test it by passing a Request directly to dispatch:

>>> from framework import Request
>>> from thoughts import website
>>> website.dispatch(Request('GET', '/users/1/thoughts', {}, {}))
Response(body='["I\'m hungry"]', status=200, headers={'Content-Type': 'application/json'})
>>> website.dispatch(Request('GET', '/hello', {'name': ['test']}, {}))
Response(body='<h1>Hello, test!</h1>', status=200, headers={'Content-Type': 'text/html'})

This is actually a pretty important design consideration in every web framework: the application is a function that receives a request and returns a response, and the server is merely a thin adapter between the two. This means we can test it without having to deal with any network at all; and thanks to our dataclass's nice representation, we can even see the results at a glance.

But if you look at run again, there's something else going on: our web framework is irrevocably coupled with http.server—that's what it uses to handle requests and responses—and it's really a simple, single-threaded webserver, not meant for production use. If we wanted to deploy our application on a serious, battle-tested server, we'd have to write a new adapter for each; and the same would have to happen for every other web framework out there. Luckily, Python already solved this problem, once and for all, with a standard.

WSGI: One Plug for Every Server

It's the m-times-n problem, like we had in the previous chapter's lesson on compilers: m web frameworks need to work with n web servers. Back in the early 2000s, Python had quite a few web frameworks, each with its own way of interacting with a particular web server—so in 2003, PEP 333 (revised for Python 3 in 2010 as PEP 3333) defined the Web Server Gateway Interface, or WSGI: a single, standardized calling convention, so any web framework can run on any web server: m + n, not m times n.

The interface is exceedingly simple: a web application is simply a callable that accepts two arguments, environ and start_response. environ is a dictionary containing all the necessary details about the request—and start_response is a function, which the application should call with the status and the headers of its response. Finally, it returns an iterable of bytestrings, which together make up the body. That's it. The whole thing looks something like this:

# wsgi_hello.py
def app(environ, start_response):
    body = f'Hello from {environ["PATH_INFO"]}!\n'.encode()
    start_response('200 OK', [('Content-Type', 'text/plain'), ('Content-Length', str(len(body)))])
    return [body]


if __name__ == '__main__':
    from wsgiref.simple_server import make_server
    make_server('127.0.0.1', 8000, app).serve_forever()

It's a complete web application, and we can run it with wsgiref—the reference implementation of WSGI that comes with Python:

>>> import threading
>>> import requests
>>> from wsgiref.simple_server import make_server
>>> from wsgi_hello import app
>>> server = make_server('127.0.0.1', 8000, app)
>>> threading.Thread(target=server.serve_forever, daemon=True).start()
>>> requests.get('http://localhost:8000/anything/at/all').text
127.0.0.1 - - [14/Oct/2026 19:48:27] "GET /anything/at/all HTTP/1.1" 200 29
'Hello from /anything/at/all!\n'
>>> server.shutdown()
>>> server.server_close()

The status is a string like '200 OK', the headers are a list of pairs, and the body is a list with a single bytestring—although any iterable will do, so we can use a generator to stream a huge response piecewise. As for wsgiref.simple_server, it's just the reference web server that comes with Python to host such an application.

So where does environ come from? The reason its keys are all in upper case is that they are derived from CGI, or the Common Gateway Interface: a standard way of running a program to handle web requests, back in 1993. The web server would run a program for every request and pass it all the necessary information—like the request's method, path and so on—through environment variables, which is why environ is a dictionary and why its names are in capitals. Here's a spy that captures it and reveals a glimpse of what's inside:

>>> captured = {}
>>> def spy(environ, start_response):
...     captured.update(environ)
...     captured['body'] = environ['wsgi.input'].read(int(environ['CONTENT_LENGTH'] or 0))
...     start_response('204 No Content', [])
...     return []
>>> server = make_server('127.0.0.1', 8000, spy)
>>> threading.Thread(target=server.serve_forever, daemon=True).start()
>>> requests.post('http://localhost:8000/users/1?verbose=yes', json={'x': 1})
127.0.0.1 - - [14/Oct/2026 19:48:28] "POST /users/1?verbose=yes HTTP/1.1" 204 0
<Response [204]>
>>> for key in ['REQUEST_METHOD', 'PATH_INFO', 'QUERY_STRING', 'CONTENT_TYPE', 'CONTENT_LENGTH',
...             'HTTP_HOST', 'HTTP_USER_AGENT', 'SERVER_PROTOCOL', 'wsgi.url_scheme']:
...     print(f'{key:16} {captured[key]!r}')
REQUEST_METHOD   'POST'
PATH_INFO        '/users/1'
QUERY_STRING     'verbose=yes'
CONTENT_TYPE     'application/json'
CONTENT_LENGTH   '8'
HTTP_HOST        'localhost:8000'
HTTP_USER_AGENT  'python-requests/2.34.2'
SERVER_PROTOCOL  'HTTP/1.1'
wsgi.url_scheme  'http'
>>> captured['body']
b'{"x": 1}'

REQUEST_METHOD, PATH_INFO and QUERY_STRING are the request line; CONTENT_TYPE and CONTENT_LENGTH describe the request body, but as strings, so you have to convert them yourself; every other header becomes HTTP_ plus its name in upper case with underscores, like HTTP_USER_AGENT; and finally, wsgi.input is a file-like stream from which we read exactly CONTENT_LENGTH bytes of the request body. Just a heads up, though: wsgiref also copies the server process's own environment variables to environ, so it's never a good idea to dump the whole thing anywhere public.

But why is this important? It's a nice abstraction, but the fact that a web application is just a callable is also a great opportunity for middleware. Since an application is just a callable, we can actually wrap one application in another—and that would be a WSGI application as well, as far as the server is concerned; while to the application it would appear to be a server. We can do all sorts of interesting things this way: authentication, logging, compression, sessions—a modern-day chain of responsibility, and exactly the decorator pattern we talked about in Decorators.

In Django, they even call this design pattern a system for custom hooks, with which you can inject functionality "before and after" every request: a good place to authenticate users, handle proxies and caches, and generally do something for all requests without repeating yourself in every view. Here's how it can look:

>>> import threading, time
>>> import requests
>>> from wsgiref.simple_server import make_server
>>> from wsgi_hello import app
>>> def timed(app):
...     def middleware(environ, start_response):
...         started = time.perf_counter()
...         result = app(environ, start_response)
...         elapsed = time.perf_counter() - started
...         print(f'{environ["REQUEST_METHOD"]} {environ["PATH_INFO"]} took {elapsed * 1000:.3f}ms')
...         return result
...     return middleware
>>> def powered_by(app):
...     def middleware(environ, start_response):
...         def custom_start_response(status, headers):
...             return start_response(status, headers + [('X-Powered-By', 'Lightness')])
...         return app(environ, custom_start_response)
...     return middleware
>>> server = make_server('127.0.0.1', 8000, timed(powered_by(app)))
>>> threading.Thread(target=server.serve_forever, daemon=True).start()
>>> response = requests.get('http://localhost:8000/hello')
GET /hello took 0.036ms
127.0.0.1 - - [14/Oct/2026 19:48:31] "GET /hello HTTP/1.1" 200 19
>>> response.text, response.headers['X-Powered-By']
('Hello from /hello!\n', 'Lightness')

The timed middleware measures how long it takes to handle the request (well, at least until the application returns its iterable, not necessarily until the whole body is sent); and the powered_by middleware actually wraps start_response itself, so it can add an extra header on the way out. And as you can see, it composes together just like you would expect from a stack of decorators.

Now let's see how we can turn our own web framework into a WSGI application. All we need is a single __call__ method, which translates environ to our Request object and our Response object to a call to start_response and a body:

# wsgi_framework.py
import urllib.parse
from http import HTTPStatus

import framework
from framework import Request, Response, json_response


class Website(framework.Website):

    def __call__(self, environ, start_response):
        length = int(environ.get('CONTENT_LENGTH') or 0)
        headers = {key[5:].replace('_', '-').lower(): value
                   for key, value in environ.items() if key.startswith('HTTP_')}
        request = Request(environ['REQUEST_METHOD'], environ['PATH_INFO'],
                          urllib.parse.parse_qs(environ['QUERY_STRING']),
                          headers, environ['wsgi.input'].read(length))
        response = self.dispatch(request)
        body = response.body.encode()
        status = f'{response.status} {HTTPStatus(response.status).phrase}'
        start_response(status, [*response.headers.items(), ('Content-Length', str(len(body)))])
        return [body]

    def run(self, address):
        from wsgiref.simple_server import make_server
        make_server(*address, self).serve_forever()

Now, the http.server adapter is gone—and although our run method uses wsgiref for convenience, we can actually use any WSGI server to host this. All we need to do to switch our thoughts API over to this new version is change the one import statement from framework to wsgi_framework, and suddenly, we're WSGI-compliant; we can even use a different web server, like gunicorn with four workers, to host it:

>>> import requests
>>> for i in range(4):
...     requests.post('http://localhost:8000/users/1/thoughts', json={'thought': f'thought {i}'})
<Response [201]>
<Response [201]>
<Response [201]>
<Response [201]>
>>> for _ in range(4):
...     print(requests.get('http://localhost:8000/users/1/thoughts').json())
["I'm hungry", 'thought 0', 'thought 1', 'thought 3']
["I'm hungry", 'thought 2']
["I'm hungry", 'thought 0', 'thought 1', 'thought 3']
["I'm hungry", 'thought 0', 'thought 1', 'thought 3']

And if you look at gunicorn's terminal:

$ gunicorn --bind 127.0.0.1:8000 --workers 4 wsgi_thoughts:website
[2026-10-14 19:48:32 +0200] [21383] [INFO] Starting gunicorn 26.2.0
[2026-10-14 19:48:32 +0200] [21383] [INFO] Listening at: http://127.0.0.1:8000 (21383)
[2026-10-14 19:48:32 +0200] [21383] [INFO] Using worker: sync
[2026-10-14 19:48:32 +0200] [21384] [INFO] Booting worker with pid: 21384
[2026-10-14 19:48:32 +0200] [21385] [INFO] Booting worker with pid: 21385
[2026-10-14 19:48:32 +0200] [21386] [INFO] Booting worker with pid: 21386
[2026-10-14 19:48:32 +0200] [21387] [INFO] Booting worker with pid: 21387

You'll notice that it's a production-ready, pre-fork web server that's running our sixty-line web framework without so much as blinking. But take a look at the thoughts—something very interesting is happening there. Since no GET request returns all four thoughts, and different GET requests return different subsets of them—we can see that gunicorn actually started four worker processes, each with their own THOUGHTS dictionary, and each POST landed on one worker or another, while each GET returned whatever the worker that happened to answer it remembered. The way gunicorn (or rather, the operating system) assigns a connection to a worker is not guaranteed, so when you run it you'll probably get a different result. This is exactly what I meant in the previous chapter, about the statelessness of web applications; if you want to store something in a process's memory, that's not going to scale—it needs to be in a database, shared by all the workers.

This also means that WSGI is more than an interesting curiosity; it's actually the way things work in real life. For my course's website, we had nginx running in front (for TLS and serving static files), but behind it was uWSGI, serving Django's project/wsgi.py—a file whose sole purpose is to expose one variable, application, which should be the WSGI callable that handles everything. Whenever you want to deploy a Python web framework, this is essentially what it boils down to: where is the callable?

Templates

Our handlers are currently using f-strings to construct HTML; and while this is fine for a single line, it becomes quite miserable for an entire page—not to mention, no syntax highlighting, and near impossible to review in source control. And that's not even the worst part: it's also quite dangerous. Consider our page function, now:

>>> def page(user_id, thoughts):
...     items = ''.join(f'<li>{thought}</li>' for thought in thoughts)
...     return f'<html><body><h1>User {user_id}</h1><ul>{items}</ul></body></html>'
>>> page(1, ["I'm hungry"])
"<html><body><h1>User 1</h1><ul><li>I'm hungry</li></ul></body></html>"
>>> page(1, ['<script>alert("pwned")</script>'])
'<html><body><h1>User 1</h1><ul><li><script>alert("pwned")</script></li></ul></body></html>'

If the thought contains a script tag, it will become part of the page—and if users are able to post their own thoughts, any one of them would be able to post a script that would run in the browser of anyone who views the page. This is known as stored cross-site scripting, or XSS for short—the "a server sends you code that your browser runs" from the previous chapter, turned against you. Incidentally, our /hello?name= route had the same bug, except it was delivered through the query string, so it's known as reflected XSS. The fix is to escape it:

>>> import html
>>> html.escape('<script>alert("pwned")</script>')
'&lt;script&gt;alert(&quot;pwned&quot;)&lt;/script&gt;'

The special characters are replaced with HTML entities, so the browser displays them as text rather than parsing them as tags. The rule is to always escape on output; and since it's easy to forget, it's better to let a tool do it for you. Such tools are called templates, and they're pretty much HTML files with holes in them, and a little bit of logic on top: they're half-declarative, half-imperative. You get to write the page the way you'd like it to look, and use simple statements (like for and if) where you need it to be dynamic. PHP was the first language to popularize this paradigm, but nowadays we have a number of excellent tools in Python: the one we'll use is called Jinja, developed by Armin Ronacher, who also wrote Flask, Click, and Werkzeug (as they say, when one person creates something amazing, they tend to create ten more amazing things). In Jinja, we'd write something along the lines of:

$ cat templates/base.html
<!DOCTYPE html>
<html>
  <head>
    <title>{% block title %}{% endblock %} | Lightness</title>
  </head>
  <body>
    {% block body %}{% endblock %}
  </body>
</html>
$ cat templates/thoughts.html
{% extends "base.html" %}
{% block title %}User {{ user_id }}{% endblock %}
{% block body %}
    <h1>User {{ user_id }}</h1>
    <ul>
    {% for thought in thoughts %}
      <li>{{ thought }}</li>
    {% else %}
      <li>No thoughts yet.</li>
    {% endfor %}
    </ul>
{% endblock %}

base.html is a skeleton with empty blocks; thoughts.html is a template that extends it, and fills those blocks in—a nice use case of the template method pattern, where an abstract base class provides some functionality, and expects its subclasses to fill in the gaps. Double braces are used to evaluate an expression, while brace-percent tags are used for statements like for. Note, however, that Jinja's for has an else clause, which is triggered when the sequence is empty; in Python, it happens when a loop ends "successfully" (that is, without a break). Anyway, to render a template, we can do:

>>> import jinja2
>>> environment = jinja2.Environment(loader=jinja2.FileSystemLoader('templates'), autoescape=True,
...                                   trim_blocks=True, lstrip_blocks=True)
>>> template = environment.get_template('thoughts.html')
>>> print(template.render(user_id=1, thoughts=["I'm hungry", '<script>alert("pwned")</script>']))
<!DOCTYPE html>
<html>
  <head>
    <title>User 1 | Lightness</title>
  </head>
  <body>
    <h1>User 1</h1>
    <ul>
      <li>I&#39;m hungry</li>
      <li>&lt;script&gt;alert(&#34;pwned&#34;)&lt;/script&gt;</li>
    </ul>
  </body>
</html>

And note how I pass autoescape=True: this way, the script arrives as harmless text, and even the apostrophe in I'm is properly escaped (and the trim_blocks and lstrip_blocks are just for tidying up the whitespace that Jinja's tags leave behind).

How does it work? Templating is essentially a code generation technique: Jinja compiles every template into Python source code, once, and then executes it on every render. The Python code for a one-line template looks like this:

>>> print(environment.compile('<li>{{ thought }}</li>', raw=True))
from jinja2.runtime import LoopContext, Macro, Markup, Namespace, TemplateNotFound, TemplateReference, TemplateRuntimeError, Undefined, escape, identity, internalcode, markup_join, missing, str_join
name = None

def root(context, missing=missing, environment=environment):
    resolve = context.resolve_or_missing
    undefined = environment.undefined
    concat = environment.concat
    cond_expr_undefined = Undefined
    if 0: yield None
    l_0_thought = resolve('thought')
    pass
    yield '<li>'
    yield escape((undefined(name='thought') if l_0_thought is missing else l_0_thought))
    yield '</li>'

blocks = {}
debug_info = '1=13'

That is, root is a generator that yields the output in pieces (remember our Generators chapter?)—and note how every expression goes through escape. Code that writes code: the same idea that generates classes from protobuf definitions, or generates Dockerfiles from templates.

As an aside, from our previous chapter: this baking of data into HTML on the server side does tie the logic to its presentation; if we were writing a single-page application, we could get the JSON data from some API, and render it in the browser. However, this method is still the quickest way to get a full-fledged website up and running.

And Then Use Flask

Now for the fun part. On my slide, this part was called "write a cool web framework (and then use Flask)", because this is what we're going to do: learn how to write a cool web framework, and then use Flask. Flask is a web framework developed by Armin Ronacher in 2010, and it's built on top of Werkzeug, which handles HTTP and WSGI, and Jinja, which handles templates; it describes itself as a micro framework, because it only provides what's necessary for web development—routing, requests and responses, templates, and little else. Its big brother, Django, takes a more comprehensive approach, providing not just a web framework, but a whole platform, with models that map to database tables, an admin interface, authentication and authorization, and its own templates. I prefer to teach Flask because it's smaller—so let's see what it can do, and how it compares to our own framework.

# flask_thoughts.py
from flask import Flask, abort, render_template, request
from markupsafe import escape

app = Flask(__name__)
app.jinja_env.trim_blocks = app.jinja_env.lstrip_blocks = True
THOUGHTS = {1: ["I'm hungry"]}


@app.get('/hello')
def hello():
    name = request.args.get('name', 'world')
    return f'<h1>Hello, {escape(name)}!</h1>'


@app.get('/users/<int:user_id>/thoughts')
def get_thoughts(user_id):
    return THOUGHTS.get(user_id, [])


@app.post('/users/<int:user_id>/thoughts')
def add_thought(user_id):
    THOUGHTS.setdefault(user_id, []).append(request.get_json()['thought'])
    return {'ok': True}, 201


@app.get('/users/<int:user_id>')
def user_page(user_id):
    if user_id not in THOUGHTS:
        abort(404)
    return render_template('thoughts.html', user_id=user_id, thoughts=THOUGHTS[user_id])


if __name__ == '__main__':
    app.run(port=8000)

Let's go over it: app.get and app.post are just shortcuts for app.route(methods=...), and <int:user_id> is a converter rather than a regex group, but it converts to an integer, so the handler gets a number rather than a string. request is not an argument, but a global proxy that Flask points to the current request (a context-local, if you will), and returning a dict or a list makes it JSON, while returning a tuple can set the status code; abort(404) raises an exception that results in a 404 page; and render_template looks in the templates/ directory and finds our own base.html and thoughts.html, unmodified. So, running a few requests against our new Flask application:

>>> import requests
>>> requests.get('http://localhost:8000/hello', params={'name': '<b>Dan</b>'}).text
'<h1>Hello, &lt;b&gt;Dan&lt;/b&gt;!</h1>'
>>> requests.post('http://localhost:8000/users/1/thoughts', json={'thought': "I'm sleepy"}).json()
{'ok': True}
>>> requests.get('http://localhost:8000/users/1/thoughts').json()
["I'm hungry", "I'm sleepy"]
>>> requests.get('http://localhost:8000/users/one/thoughts')
<Response [404]>
>>> requests.delete('http://localhost:8000/users/1/thoughts')
<Response [405]>
>>> response = requests.get('http://localhost:8000/users/1')
>>> response.headers['Content-Type']
'text/html; charset=utf-8'
>>> print(response.text)
<!DOCTYPE html>
<html>
  <head>
    <title>User 1 | Lightness</title>
  </head>
  <body>
    <h1>User 1</h1>
    <ul>
      <li>I&#39;m hungry</li>
      <li>I&#39;m sleepy</li>
    </ul>
  </body>
</html>

And if you want to see how it looks in the terminal:

$ python flask_thoughts.py
 * Serving Flask app 'flask_thoughts'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:8000
Press CTRL+C to quit
127.0.0.1 - - [14/Oct/2026 19:48:40] "GET /hello?name=<b>Dan</b> HTTP/1.1" 200 -
127.0.0.1 - - [14/Oct/2026 19:48:40] "POST /users/1/thoughts HTTP/1.1" 201 -
127.0.0.1 - - [14/Oct/2026 19:48:40] "GET /users/1/thoughts HTTP/1.1" 200 -
127.0.0.1 - - [14/Oct/2026 19:48:40] "GET /users/one/thoughts HTTP/1.1" 404 -
127.0.0.1 - - [14/Oct/2026 19:48:41] "DELETE /users/1/thoughts HTTP/1.1" 405 -
127.0.0.1 - - [14/Oct/2026 19:48:41] "GET /users/1 HTTP/1.1" 200 -

We see the name being escaped in /hello, a 404 for /users/one because the int converter didn't match, a 405 for DELETE without us having to do anything, and the template rendering just fine. Flask's development server even tells us not to use it in production, but to use a proper WSGI server instead; because guess what? A Flask application is a WSGI application.

So how does it compare to our own framework? If we look at it from the outside, we see a registry of routes that matches by method, returning 404 or 405 as necessary, and wrapping return values in responses—but we wrote all of that, so it's not like any of Flask's functionality is magic. If we peek inside, we can see that its url_map is exactly what our list of routes grew up to be:

>>> from flask_thoughts import app
>>> app.url_map
Map([<Rule '/static/<filename>' (HEAD, GET, OPTIONS) -> static>,
 <Rule '/hello' (HEAD, GET, OPTIONS) -> hello>,
 <Rule '/users/<user_id>/thoughts' (HEAD, GET, OPTIONS) -> get_thoughts>,
 <Rule '/users/<user_id>/thoughts' (POST, OPTIONS) -> add_thought>,
 <Rule '/users/<user_id>' (HEAD, GET, OPTIONS) -> user_page>])
>>> client = app.test_client()
>>> response = client.post('/users/2/thoughts', json={'thought': 'Testing is easy'})
>>> response.status_code, response.json
(201, {'ok': True})
>>> client.get('/users/2/thoughts').json
['Testing is easy']
>>> import threading, requests
>>> from wsgiref.simple_server import make_server
>>> server = make_server('127.0.0.1', 8000, app)
>>> threading.Thread(target=server.serve_forever, daemon=True).start()
>>> requests.get('http://localhost:8000/users/2/thoughts').json()
127.0.0.1 - - [14/Oct/2026 19:48:44] "GET /users/2/thoughts HTTP/1.1" 200 20
['Testing is easy']

The rules even list the methods, with HEAD and OPTIONS added automatically, and a built-in route for static files. Flask's test client calls the WSGI application directly, bypassing the whole socket thing—much like we did with our own dispatch. And since the application is a WSGI callable, it can be served by wsgiref just like ours.

Flask supports middleware in two flavors: hooks like before_request and after_request, or plain WSGI middleware that is wrapped around app.wsgi_app, just like our timed. And finally, the lazy REST implementation from the previous chapter in about twenty lines—one endpoint, a dictionary of functions, and an @rpc decorator to register them in it; those registration decorators again:

# flask_rpc.py
import flask

app = flask.Flask(__name__)
functions = {}


def rpc(function):
    functions[function.__name__] = function
    return function


@app.route('/rpc', methods=['POST'])
def handle_rpc():
    request = flask.request.json
    try:
        return_value = functions[request['function']](*request['args'])
        return flask.jsonify({'return_value': return_value, 'error': None})
    except Exception as error:
        return flask.jsonify({'return_value': None, 'error': repr(error)})


@rpc
def add(x, y):
    return x + y


@rpc
def upper(text):
    return text.upper()


if __name__ == '__main__':
    app.run(port=8000)

And this client:

>>> import requests
>>> def call(function, *args):
...     return requests.post('http://localhost:8000/rpc', json={'function': function, 'args': args}).json()
>>> call('add', 1, 2)
{'error': None, 'return_value': 3}
>>> call('upper', 'lightness')
{'error': None, 'return_value': 'LIGHTNESS'}
>>> call('add', 1, 'two')
{'error': 'TypeError("unsupported operand type(s) for +: \'int\' and \'str\'")', 'return_value': None}

Note the methods=['POST']: the version of this code on my slide didn't have it, in which case the endpoint is accessible by GET only, and any POST request results in a 405. And rather than json.dumps, it uses jsonify, which not only returns a JSON response, but also sets the proper Content-Type (application/json). As we discussed, the errors are sent back in the body, with a 200, which is easy enough, but not very RESTful.

So, why would we want to use Flask, now that we understand how it works? Well, because of all the stuff we didn't cover: keep-alives and chunked bodies, cookies and signed sessions, redirects and file uploads, error pages and a debug mode with a debugger in the browser, blueprints to split big applications and an ecosystem of extensions, documentation, and thousands of people who already encountered whatever bug you're going to encounter next. Before we leave, however, I'd be remiss if I didn't give you a warning: writing tiny Flask apps is so easy, that it's easy to forget that they're still web applications, and write them in an insecure way. SQL is built with f-strings (SQL injections), subprocess is run with shell=True, user input is passed to render_template_string—Flask goes a long way to help you secure your app, but it doesn't save you from yourself.

ASGI: the Asynchronous Sibling

And that's the crux of web programming: web servers are, by their very nature, I/O-bound. The request spends most of its time waiting: for the database, for some other service, for a slow client. This is the argument we made in the asynchronous chapter, and it's web servers that are its best use case.

Unfortunately, WSGI is synchronous by design: the server calls the application, and the call doesn't return until the response is ready. If we want to support multiple requests at once, we need to do so from the outside, by spawning multiple processes or threads. That's what gunicorn's workers are (or threads inside each of them). But there's no way to express a long-lived connection, like a websocket, at all.

For this reason, a new interface was introduced, called ASGI, or the Asynchronous Server Gateway Interface, which grew out of the Django Channels project. It's similar to WSGI, in that it retains its basic idea and makes it async: an application is an async callable, which receives a scope (a dictionary similar to environ, but describing the entire connection—that is, its type, which can be http, websocket or lifespan) as well as a receive coroutine, which lets it await incoming events (like chunks of the request body), and a send coroutine, which lets it emit outgoing events (like the start of a response followed by its body). Here's such an app in action:

# asgi_app.py
import asyncio


async def app(scope, receive, send):
    if scope['type'] != 'http':
        return
    if scope['path'] == '/slow':
        await asyncio.sleep(1)
    body = f'Hello from {scope["method"]} {scope["path"]}!\n'.encode()
    await send({
        'type': 'http.response.start',
        'status': 200,
        'headers': [(b'content-type', b'text/plain'), (b'content-length', str(len(body)).encode())],
    })
    await send({'type': 'http.response.body', 'body': body})

So yeah, the headers are a list of pairs of bytestrings with lowercase names, and the response is two events: http.response.start with the status and the headers, and http.response.body with the actual body. The /slow path is where it gets interesting: we await on asyncio.sleep(1), as a stand-in for a slow database query, and ignore scopes that aren't http (because uvicorn also sends a lifespan scope on startup and shutdown, for setting things up and tearing them down).

To run it, we can use uvicorn, an ASGI server implemented in asyncio: uvicorn asgi_app:app. Here it is in action, in all its glory:

>>> import time
>>> import requests
>>> from concurrent.futures import ThreadPoolExecutor
>>> requests.get('http://localhost:8000/').text
'Hello from GET /!\n'
>>> def timed_burst(url, n=10):
...     started = time.perf_counter()
...     with ThreadPoolExecutor(n) as pool:
...         texts = list(pool.map(lambda _: requests.get(url).text, range(n)))
...     return len(texts), round(time.perf_counter() - started, 2)
>>> timed_burst('http://localhost:8000/slow')
(10, 1.02)

Compare this to a similar burst against a WSGI application:

>>> import threading
>>> from wsgiref.simple_server import make_server, WSGIRequestHandler
>>> from wsgi_slow import app as wsgi_app
>>> class QuietHandler(WSGIRequestHandler):
...     def log_message(self, *args):
...         pass
>>> server = make_server('127.0.0.1', 8001, wsgi_app, handler_class=QuietHandler)
>>> threading.Thread(target=server.serve_forever, daemon=True).start()
>>> timed_burst('http://localhost:8001/slow')
(10, 10.07)

And notice how ten requests take one second with uvicorn—that is, one thread and one event loop, running all of them concurrently and switching between them as they're waiting for their respective database query (that Israeli clerk from the asynchronous chapter again). The wsgiref server, on the other hand, handles one request at a time, so it takes ten seconds to address all of them; and to be fair, a threaded or multi-worker WSGI server would handle them better, too. But what I wanted to illustrate is how a single thread can handle so much load—and as usual, your mileage might vary, since these timings are from my computer. Here's uvicorn's terminal:

$ uvicorn asgi_app:app --port 8000
INFO:     Started server process [21457]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     127.0.0.1:57950 - "GET / HTTP/1.1" 200 OK
INFO:     127.0.0.1:57956 - "GET /slow HTTP/1.1" 200 OK
INFO:     127.0.0.1:57957 - "GET /slow HTTP/1.1" 200 OK
INFO:     127.0.0.1:57959 - "GET /slow HTTP/1.1" 200 OK
INFO:     127.0.0.1:57960 - "GET /slow HTTP/1.1" 200 OK
INFO:     127.0.0.1:57961 - "GET /slow HTTP/1.1" 200 OK
INFO:     127.0.0.1:57962 - "GET /slow HTTP/1.1" 200 OK
INFO:     127.0.0.1:57966 - "GET /slow HTTP/1.1" 200 OK
INFO:     127.0.0.1:57967 - "GET /slow HTTP/1.1" 200 OK
INFO:     127.0.0.1:57968 - "GET /slow HTTP/1.1" 200 OK
INFO:     127.0.0.1:57970 - "GET /slow HTTP/1.1" 200 OK

And the warning from our asynchronous chapter still applies: if you put a time.sleep (or do some heavy computation) in an async handler, every request that comes in through that event loop will stall until it's over.

Of course, there are several frameworks that implement the ASGI interface for you, just like there are several frameworks that implement the WSGI interface. Starlette is a minimal toolkit in the same spirit as Flask; FastAPI, which we'll talk about momentarily, is built on top of Starlette; and even Django supports both interfaces.

FastAPI takes our decorator idea from before to its logical conclusion. Using type annotations (remember those, from the Type Hinting chapter?), it lets us describe the request:

# fastapi_add.py
from fastapi import FastAPI
from pydantic import BaseModel
import uvicorn


app = FastAPI()


class AddRequest(BaseModel):
    x: int
    y: int


@app.post('/add')
async def post_add(request: AddRequest) -> int:
    return request.x + request.y


if __name__ == '__main__':
    uvicorn.run(app, port=8000)

And if we call it:

>>> import requests
>>> requests.post('http://localhost:8000/add', json={'x': 1, 'y': 2}).json()
3
>>> response = requests.post('http://localhost:8000/add', json={'x': 1, 'y': 'two'})
>>> response.status_code, response.json()['detail'][0]['msg']
(422, 'Input should be a valid integer, unable to parse string as an integer')
>>> requests.get('http://localhost:8000/openapi.json').json()['paths']['/add']['post']['requestBody']
{'content': {'application/json': {'schema': {'$ref': '#/components/schemas/AddRequest'}}}, 'required': True}

This is already quite a lot of validation, and we didn't have to do anything; annotating the parameter with AddRequest was enough for FastAPI to infer that our endpoint expects JSON data and should validate it accordingly. 'two' gets a 422 Unprocessable Content response with an error message, before our function is even called; and the same annotations can be used to automatically generate an OpenAPI description of our API, as well as interactive documentation at /docs. The function's signature, as Functions 1 put it, has become the interface—this time, read by the framework.

Frameworks as Crystallized Decisions

So let's go back to our framework. Every line we wrote was a decision: whether to use regex groups or typed converters, to return tuples or response objects, to pass in the request as an argument or make it available as a global, whether the first match wins, 404 versus 405, a tuple as the default for methods, and so on. Flask also made all these decisions, and some of them it decided differently; FastAPI made a different set of decisions still.

So in a way, a framework is just a set of decisions that somebody made once, and then crystallized into a reusable format—so you don't have to make these decisions again. The flip side, though, is that it's not always easy to make these decisions differently.

It's also what's known as inversion of control: something we've seen in Functions 3. When using a library, we call into it to get something done; when using a framework, it calls into us to get something done. We write the snippets, and it has the main loop in which they are pieced together and used. And it makes sense, doesn't it?—it's the tradeoff you make. You give up control over the flow, and in exchange the otherwise rigid parts, like sockets and parsing and concurrency and all the other aspects that conform to some standard—all of this is done for you, and done well.

This can lead to what's known as prescriptive vs. descriptive frameworks: Django makes a lot of decisions for you, so a lot of things just work out of the box—models become tables, there's an admin interface and an authentication system, and so on. It's very productive, especially when your problem fits within this mold; but Flask doesn't make so many decisions, and you get to assemble the pieces yourself.

Of course, there's nothing wrong with either approach: it's not about which framework is right, but which decisions you'd like to have already made for you. Taken to the extreme, all you do is write the handler, and everything else is done by magic: this is known as serverless functions, like AWS Lambda—a function with a lot of overhead. When someone browses to /hello on my website, go ahead and run this function—it's just like in a Flask route, except somebody else is running the server for you, and only invoking your function on demand (and charging you accordingly).

Anyway, once you've written a framework yourself, you can appreciate the full magnitude of what those "real" frameworks do, because they're no longer this inscrutable block of alien code; they're actually a diff against your code, and the next one is even easier to learn, because once you've learned ten of these technologies, the eleventh one is much simpler, because you start developing a sense of what's essential and what's not.

So, to illustrate the point, this decorator-based registry pops up in the next chapter as well: in the popular Click library, written by the same Armin Ronacher who wrote Flask. It's used to build command-line interfaces in much the same way that Flask is used to build websites—and, in fact, a good command-line interface is often just another thin consumer on top of the same API.