This is the sequel to a post walking through an HTTP client in Python. The client in that post sent a request to a server that performs addition. This post walks through the code for that server

Server terminology can be confusing. "Server" can refer to both the program that listens for HTTP messages and the metal and ceramic box sitting in a rack in a data center where that program runs. In this post, we are focusing on the first of those.

Some Python

Here's the code for the server. We'll spend the rest of the post stepping through it.

from http.server import BaseHTTPRequestHandler, HTTPServer
import json

host = "192.168.1.42"
port = 8000


class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        content_length = int(self.headers['Content-Length'])
        request_body = json.loads(
            self.rfile.read(content_length).decode('utf-8')
        )

        answer = request_body["val_a"] + request_body["val_b"]
        response_body = json.dumps({"sum": answer}).encode("utf-8")

        self.send_response(200)
        self.send_header("Content-type", "application/json")
        self.send_header("Content-Length", len(response_body))
        self.end_headers()
        self.wfile.write(response_body)


if __name__ == "__main__":
    httpd = HTTPServer((host, port), RequestHandler)
    httpd.serve_forever()

The foundation classes

The two classes BaseHTTPRequestHandler and HTTPServer do a lot of heavy lifting here. They hide a lot of things that we would rather not have to think about and don't need to know about to make good server.

from http.server import BaseHTTPRequestHandler, HTTPServer

HTTPServer is the class that create the server.

httpd = HTTPServer((host, port), RequestHandler)
httpd.serve_forever()

It takes two arguments, a tuple containing the IP address of the computer server is running on and the port it plans to use, and a Request Handler class (more on that below).

serve_forver() is the method that kicks off the server daemon. It will keep running forever until something kills it. It's not exactly right (but also not totally wrong) to imagine that a server is a fast while loop that checks for a new request on every iteration.

Request handling

Each time a new request comes in, the server's goal is to get it processed. To help do this, there is a separate class, the RequestHandler. The http.server library does a lot of behind-the-scenes work for us here with a BaseHTTPRequestHandler class.

class RequestHandler(BaseHTTPRequestHandler):

To build out a full fledged request handler, we only need to extend the base class with methods to handle each server action. For instance, the GET action needs to have a corresponding do_GET() method in the RequestHandler class.

def do_GET(self):
    ...

If the server accepts a POST then the request handler needs a do_POST(). If we expect it to handle DELETE then it needs a do_DELETE(). You get the idea.

Server actions

HTTP actions are defined by tradition and convention, but there are no hard constraints on what they do. If you are writing a server, you can make GET and POST do whatever you want. There's nothing in the http.server library that enforces any particular behavior for these actions. As long as the client knows what to expect, it's fine.

If you choose to be an agent of chaos, you can even define your own actions. Imagine writing a server with GETONE, GETTWO, and GETMANY actions. (This is fine if you are also the one writing the clients. It may be awkward if you're making a public API.) Or if you'd like to bury an easter egg, plant a RICKROLL action. And if you lean chaotic evil, remember that actions are case-sensitive. You could write POST, post, and pOsT methods. But you shouldn't. You will go to computer jail.

Interpreting requests

Any action handler method will have to interpret the request message and compose a response message.

Request messages have three parts

  1. a request to do something
  2. some headers
  3. a body (optional)

Thanks to the BaseHTTPRequestHandler class, headers can be conveniently accessed through the self.headers attribute

content_length = int(self.headers['Content-Length'])

and the body can be accessed through the file-like self.rfile

request_body = json.loads(
    self.rfile.read(content_length).decode('utf-8')
)

Other than those differences, the operations for reading and transforming the headers and body are the same as what the client does when interpreting response messages.

The path argument is also available via the attribute self.path It doesn't get used in the addition server, so the code never bothers to call it, but it's there.

After the request body is fully ingested, any information or arguments it contains can be taken and used to do whatever it is the server intends to do. This particular server looks for a JSON payload, which gets translated into a Python dict. The two keys it expects to find are val_a and val_b, which it adds together.

answer = request_body["val_a"] + request_body["val_b"]

Composing a response

Response messages also have three parts

  1. a status report
  2. some headers
  3. a body (optional)

Sending an appropriate status is the first order of business.

self.send_response(200)

This server is poorly engineered in that it doesn't check for any failure modes and doesn't offer to send back any other status values. If this were destined for production someplace important, there may be a check for whether the values passed were numerical, and a 422 status (Unprocessable Content) returned if they were not.

Formulating the headers is done a little differently than in http.client thanks to some additional syntactic sugar in the http.server library.

self.send_header("Content-type", "application/json")
self.send_header("Content-Length", len(response_body))
self.end_headers()

Also, writing the body in the server is done by writing to a file-like object called self.wfile.

response_body = json.dumps({"sum": answer}).encode("utf-8")
self.wfile.write(response_body)

It's not exactly writing to a file, but you don't have to think about that fact, thanks to how it's implemented. Under the hood, it's a BufferedIOBase from Python's io library. BufferedIOBase is a stream, allowing the data to start being sent in small chunks before the server is even done writing it.

And that's it. A whole bare-bones HTTP server. There are a thousand ways to make it more sophisticated and fault tolerant and faster and scalable and efficient, but this is the foundation.

Other ways to use the path

One common trick is to use the path argument to sneak in extra information.

If the server performs a function, rather than pulling from a particular file, then the path argument doesn't matter much. It will still be included, but it's fine to discard it.

The path argument itself is just a string. It's only by convention that it gets interpreted as a path most of the time. This means we have wiggle room to use it creatively.

One trick is to stack information in a way that looks like a path. These path parameters are a convenient shortcut. For instance, a path argument of

/param-a/param-a-val/param-b/param-b-val/
is a way of sending two parameter values without having to formulate a body and get it encoded properly. Path parameters are most common when specifying which page, file, model, or database to reach.

Alternatively parameters can be tagged onto the end of the path the same way all those annoying annotations appear on URLs when you copy them from your browser bar

/?param-a=param-a-val&param-b=param-b-val
These query parameters are most common when specifying how to do something. Search filters, model parameters, and formatting instructions, for instance.

The parameter information contained in each method is the same. Browsers tend to cache the result when you use path arguments, so whenever you are dealing with demanding requests and want to take advantage of the browser holding the result in short term memory, path parameters are the way to go.

What version of HTTP?

As far as I've been able to tell, http.client and http.server are all HTTP version 1.1 (or 1.0) all the time. HTTP/2 and HTTP/3 are definitely alive and well, but haven't made their way into the basic Python toolbox yet. The requests and responses are identical in nearly all cases, but under the hood the later versions do some things to increase transfer speeds and decrease latency.

Waiting, timing, blocking, and threads

By default, RequestHandler classes are synchronous. That means that the request handler is blocking—that the (conceptual) loop that the server is running on repeat has to pause until the request handler is done handline the request.

This is a single-threaded implementation, and it's perfectly fine as long as

But very often, none of these is the case. In that situation, a helpful trick is to move to asynchronous request handlers. Each time the server listener encounters a new request, it quickly spins out a new thread to handle it and then continues listening for the next request message.

The code for this relies on the ThreadingHTTPServer class from http.server. A fully-fledged example will have to wait for another post, but the critical snippet looks like this

from http.server import HTTPServer
from socketserver import ThreadingMixIn

class ThreadingServer(ThreadingMixIn, HTTPServer):
    pass

Everything else is exactly the same.



Oh the places you'll go!

Like high school, this is just the barest outline of what you need to know to get going. We haven't talked at all about security or authentication or scaling or redundancy or load balancing in multi-tier servers. Unless that's what your employer pays you to do, there's a good chance you'll never need to learn about any of that, but if/when you do, you'll have a solid scaffold to anchor it to.

In the meantime, you have what you need to prototype a dizzyingly vast set of projects. Writing your own HTTP clients and servers lets you tie your work into popular apps (like Slack and GitHub), broader infrastructure (every large cloud provider has an API), and public datasets (everything from economic data to satellite imagery). The whole reason I decided to dig into this was to support a distributed reinforcement learning and robotics project, and I couldn't figure out an easier way to get different compute-heavy processes to coordinate with each other.

So go wild! Find something you're interested in or curious about and tinker away. More than anything else, I hope you have fun.