Skip to content

How an HTTP request reaches your code and comes back

~20 min ยท prerequisite: Parts 1 to 4 (functions, strings, dictionaries, exceptions) and Part 7 (classes)

What you will be able to do

  • Name the parts of an HTTP request and an HTTP response.
  • Write a small http.server handler that answers a GET request with JSON.
  • Choose a status code, such as 200, 404, or 501, that matches what happened.

The idea

A client asks for something, such as a web browser or the curl command. A server waits, listens, and answers. They talk over HTTP, the HyperText Transfer Protocol, a set of rules for what each message looks like.

Think of a letter and its reply. The request says what you want through the method and path. It may carry headers, which are labelled details such as the content type, and a body, which is the content itself. The response has a status code that says how things went, its own headers, and a body with the answer.

The first line of a request holds the method, the path, and the version, separated by spaces: GET /hello HTTP/1.1. GET asks for data without changing anything. POST creates something, PUT replaces an item, and DELETE removes one. A query string may follow the path after a ?, as in /hello?name=Ada.

A status code is a three-digit number. A 2xx code means success, such as 200 OK. A 4xx code means the client sent something the server cannot accept, such as 404 Not Found. A 5xx code means the server failed.

One request follows these steps:

  1. The client opens a connection and sends the request text.
  2. The server reads the request line and the headers.
  3. The server chooses code to run from the method and path. This choice is called routing.
  4. The code builds a response, and the server writes the status line, the headers, and the body.
  5. The connection closes. HTTP is stateless: the server remembers nothing about earlier requests unless your code stores that memory, such as in a database.

Worked example

Save this as hello_server.py. It is a plain Python file with no extra installs.

import json
from http.server import BaseHTTPRequestHandler, HTTPServer


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/hello":
            status = 200
            body = {"message": "hello"}
        else:
            status = 404
            body = {"error": "not found"}

        data = json.dumps(body).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, format, *args):
        pass  # silence the default access log so the output stays readable


if __name__ == "__main__":
    server = HTTPServer(("127.0.0.1", 8000), Handler)
    print("Listening on http://127.0.0.1:8000")
    server.serve_forever()

Line by line, this is what Python does:

  1. class Handler(BaseHTTPRequestHandler) is the class the standard library uses for each request. You write its do_GET method.
  2. do_GET runs for GET requests, so its name must be exactly do_GET.
  3. self.path holds the path, such as /hello. The if picks a status and a body.
  4. json.dumps(body).encode("utf-8") turns the dictionary into JSON text, then into bytes. The socket sends bytes.
  5. send_response writes the status line, and send_header adds each header. Content-Length tells the client how many bytes to expect.
  6. end_headers ends the header section with a blank line. self.wfile.write(data) sends the body.
  7. The if __name__ == "__main__": block starts the server only when you run the file directly. serve_forever waits for requests until you press Ctrl+C.

Try it

Run the server in one terminal:

$ python3 -I hello_server.py
Listening on http://127.0.0.1:8000

In a second terminal, ask for the route and then a missing one:

$ curl -i http://127.0.0.1:8000/hello
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 20

{"message": "hello"}
$ curl -i http://127.0.0.1:8000/nothing
HTTP/1.0 404 Not Found
Content-Type: application/json
Content-Length: 22

{"error": "not found"}

curl -i prints the headers too. The Server and Date headers are left out above.

The next blocks need no server.

def parse_request_line(line):
    method, path, version = line.split(" ")
    return {"method": method, "path": path, "version": version}


parsed = parse_request_line("GET /hello HTTP/1.1")
assert parsed["method"] == "GET"
assert parsed["path"] == "/hello"
assert parsed["version"] == "HTTP/1.1"
import json

body = {"message": "hello"}
data = json.dumps(body).encode("utf-8")

assert type(data) is bytes
assert len(data) == 20  # the value of Content-Length
assert data.decode("utf-8") == '{"message": "hello"}'

Common mistakes

  1. Writing text instead of bytes. Writing the str from json.dumps directly fails with:

    TypeError: a bytes-like object is required, not 'str'
    

    Call .encode("utf-8") first.

  2. Forgetting end_headers(). Without it the body is not separated from the headers. Python raises no error. curl reports:

    curl: (1) Received HTTP/0.9 when not allowed
    

    Add the call back before the write.

  3. Starting two servers on one port. The second fails with:

    OSError: [Errno 48] Address already in use
    

    On Linux the number is 98. Stop the first server with Ctrl+C.

  4. Sending a method you did not write. A POST request to the server above returns status 501. The status line reads HTTP/1.0 501 Unsupported method ('POST'), and the body is an HTML error page. The fix is to write a do_POST method.

  5. Comparing the path with a query string. For /hello?name=Ada, self.path is /hello?name=Ada, so the check self.path == "/hello" is false and the server answers 404.

Practice

  1. Call parse_request_line with "DELETE /notes/3 HTTP/1.1" and assert that the method is "DELETE".
  2. Write status_class(code) that returns "success", "client error", or "server error" for 2xx, 4xx, or 5xx codes. Assert it for 200, 404, and 500.
  3. Add a /health route to hello_server.py that returns {"status": "ok"} with status 200.
  4. Write make_response(body) that returns (status_line, data), where status_line is "HTTP/1.0 200 OK" and data is the JSON bytes. Assert the length of data.
  5. Stretch: add a do_POST method for /echo that sends back the request body with status 200.
Solution
  1. python parsed = parse_request_line("DELETE /notes/3 HTTP/1.1") assert parsed["method"] == "DELETE"

  2. ```python def status_class(code): kinds = {2: "success", 4: "client error", 5: "server error"} return kinds.get(code // 100, "other")

assert status_class(200) == "success" assert status_class(404) == "client error" assert status_class(500) == "server error" ```

  1. Add this branch inside do_GET, before the else:
if self.path == "/health":
    status = 200
    body = {"status": "ok"}

Check it with curl -i http://127.0.0.1:8000/health.

  1. ```python import json

def make_response(body): data = json.dumps(body).encode("utf-8") return "HTTP/1.0 200 OK", data

status_line, data = make_response({"message": "hello"}) assert status_line == "HTTP/1.0 200 OK" assert len(data) == len('{"message": "hello"}') ```

  1. python def do_POST(self): if self.path == "/echo": length = int(self.headers["Content-Length"]) data = self.rfile.read(length) self.send_response(200) self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) else: self.send_error(404)

Test with curl -i -X POST -d hi http://127.0.0.1:8000/echo.

Recap

  • A request has a method, a path, headers, and an optional body. A response has a status code, headers, and a body.
  • http.server calls the method named after the request method, such as do_GET.
  • Send bytes, set Content-Length, and call end_headers() before the body.

Sources