HTTP and Networking

Updated

September 8, 2026

HTTP and Networking

The boring HTTP client is urllib.request. The boring local server is http.server.HTTPServer. Start the server in a thread, make one request, shutdown, and exit. Do not leave serve_forever on the main thread. Do not call out to the public internet for a test.

Mental model

HTTP is request/response over a socket. urlopen is a GET (or a Request you configure). A successful body is bytes; decode it. A 404 is urllib.error.HTTPError, not an empty string.

HTTPServer((host, port), Handler) binds a socket. Port 0 means “pick a free port.” serve_forever blocks, so it belongs on a thread. server.shutdown() from the main thread asks that loop to stop. join the thread. The process must exit.

BaseHTTPRequestHandler implements do_GET / do_POST. Override log_message if you do not want the default stderr access log.

Worked examples

Case 1: Local GET, then shutdown

Save as desk_http.py. The handler writes a small body. The client reads it. Then the server dies.

# desk_http.py
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
from urllib.request import urlopen


class DeskHandler(BaseHTTPRequestHandler):
    def do_GET(self) -> None:
        body = b"desk is open"
        self.send_response(200)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, format: str, *args: object) -> None:
        return


def main() -> None:
    server = HTTPServer(("127.0.0.1", 0), DeskHandler)
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    host, port = server.server_address
    with urlopen(f"http://{host}:{port}/") as resp:
        print(resp.read().decode("utf-8"))
    server.shutdown()
    thread.join(timeout=2)


if __name__ == "__main__":
    main()

Run:

uv run python desk_http.py

Output:

desk is open

The program returns. If it hangs, you skipped shutdown.

Case 2: POST a ticket as JSON

Save as post_ticket.py. Request sets method, body, and headers. The handler reads Content-Length bytes.

# post_ticket.py
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
from urllib.request import Request, urlopen


class TicketHandler(BaseHTTPRequestHandler):
    last = ""

    def do_POST(self) -> None:
        length = int(self.headers.get("Content-Length", "0"))
        TicketHandler.last = self.rfile.read(length).decode("utf-8")
        body = b"ok"
        self.send_response(200)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, format: str, *args: object) -> None:
        return


def main() -> None:
    server = HTTPServer(("127.0.0.1", 0), TicketHandler)
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    host, port = server.server_address
    payload = json.dumps({"id": 7, "table": 12}).encode("utf-8")
    req = Request(
        f"http://{host}:{port}/tickets",
        data=payload,
        method="POST",
        headers={"Content-Type": "application/json"},
    )
    with urlopen(req) as resp:
        print(resp.read().decode("utf-8"))
    print(TicketHandler.last)
    server.shutdown()
    thread.join(timeout=2)


if __name__ == "__main__":
    main()

Run:

uv run python post_ticket.py

Output:

ok
{"id": 7, "table": 12}

Case 3: 404 is an exception

Save as http_missing.py. urlopen raises HTTPError for 404. Read the body from the exception.

# http_missing.py
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
from urllib.error import HTTPError
from urllib.request import urlopen


class DeskHandler(BaseHTTPRequestHandler):
    def do_GET(self) -> None:
        body = b"missing"
        self.send_response(404)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, format: str, *args: object) -> None:
        return


def main() -> None:
    server = HTTPServer(("127.0.0.1", 0), DeskHandler)
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    host, port = server.server_address
    try:
        urlopen(f"http://{host}:{port}/nope")
    except HTTPError as exc:
        print(exc.code)
        print(exc.read().decode("utf-8"))
    server.shutdown()
    thread.join(timeout=2)


if __name__ == "__main__":
    main()

Run:

uv run python http_missing.py

Output:

404
missing

The trap

http.server.test() and serve_forever() on the main thread never return. This program is the shape that exits. The broken shape is the same server without shutdown — do not ship that.

A second trap: urlopen("https://httpbin.org/...") in a unit test. That is a network dependency, a flaky clock, and someone else’s machine. Serve yourself, one request, stop.

Save as http_timeout_note.py (the good shape again, with an explicit timeout on the client):

# http_timeout_note.py
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
from urllib.request import urlopen


class DeskHandler(BaseHTTPRequestHandler):
    def do_GET(self) -> None:
        body = b"desk is open"
        self.send_response(200)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, format: str, *args: object) -> None:
        return


def main() -> None:
    server = HTTPServer(("127.0.0.1", 0), DeskHandler)
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    host, port = server.server_address
    with urlopen(f"http://{host}:{port}/", timeout=2) as resp:
        print(resp.status)
    server.shutdown()
    thread.join(timeout=2)


if __name__ == "__main__":
    main()

Run:

uv run python http_timeout_note.py

Output:

200

timeout= is how a stuck peer fails instead of hanging the process.

The boring rule

  • Bind 127.0.0.1 and port 0 in tests.
  • Thread + one request + shutdown + join. The process must exit.
  • Use urllib.request until you have a reason for a third-party client.
  • Catch HTTPError and URLError at the edge.
  • Do not use public echo services as fixtures.

Try this

  1. In desk_http.py, change the body to b"shift closed" and print resp.status as well.
  2. In post_ticket.py, json.loads the stored body and print table.
  3. In http_missing.py, send 200 for / and 404 for anything else (self.path).