Frontend Labs
⋯

42. HTTP/1.1 from a bare socket

Before you use an HTTP library, speak HTTP yourself once (Net Ch 28, Net Ch 39).

42.1 A client

import socket

def http_get(host: str, path: str = "/", port: int = 80) -> tuple[str, dict[str, str], bytes]:
    req = (
        f"GET {path} HTTP/1.1\r\n"
        f"Host: {host}\r\n"
        "User-Agent: py-labs/0.1\r\n"
        "Accept: */*\r\n"
        "Connection: close\r\n"          # ask the server to close after the response
        "\r\n"
    )
    with socket.create_connection((host, port), timeout=5) as s:
        s.sendall(req.encode("ascii"))
        raw = b""
        while chunk := s.recv(65536):
            raw += chunk
    head, _, body = raw.partition(b"\r\n\r\n")
    status, *header_lines = head.decode("iso-8859-1").split("\r\n")
    headers = {}
    for line in header_lines:
        k, _, v = line.partition(":")
        headers[k.strip().lower()] = v.strip()
    return status, headers, body

status, headers, body = http_get("example.com")
print(status)
print(headers.get("content-type"), len(body), "bytes")

Reading until the server closes only works because we sent Connection: close. With keep-alive, the client must use Content-Length or decode Transfer-Encoding: chunked to know where the body ends. Getting that wrong, or two parties disagreeing about it, is request smuggling (Security in Depth Ch 30).

42.2 A server

# labs/tinyhttp.py: a tiny threaded HTTP/1.1 server with strict limits
import json
import socket
import threading
from datetime import datetime, timezone

MAX_HEADER = 16 * 1024

def read_request(conn: socket.socket) -> tuple[str, str, dict[str, str]] | None:
    buf = b""
    while b"\r\n\r\n" not in buf:
        chunk = conn.recv(4096)
        if not chunk:
            return None
        buf += chunk
        if len(buf) > MAX_HEADER:
            raise ValueError("headers too large")
    head = buf.split(b"\r\n\r\n", 1)[0].decode("iso-8859-1")
    request_line, *lines = head.split("\r\n")
    method, target, version = request_line.split(" ", 2)
    headers = {}
    for line in lines:
        name, sep, value = line.partition(":")
        if not sep or name != name.strip():          # reject "Name : value" (a smuggling vector)
            raise ValueError("malformed header")
        headers[name.lower()] = value.strip()
    return method, target, headers

def respond(conn: socket.socket, status: str, body: bytes, ctype: str = "text/plain") -> None:
    conn.sendall(
        f"HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {len(body)}\r\n"
        f"Date: {datetime.now(timezone.utc):%a, %d %b %Y %H:%M:%S GMT}\r\n"
        "Connection: close\r\n\r\n".encode() + body
    )

def handle(conn: socket.socket) -> None:
    with conn:
        conn.settimeout(10)                           # slowloris defense: no idle sockets forever
        try:
            req = read_request(conn)
            if req is None:
                return
            method, target, headers = req
            if method != "GET":
                respond(conn, "405 Method Not Allowed", b"only GET\n")
            elif target == "/health":
                respond(conn, "200 OK", json.dumps({"ok": True}).encode(), "application/json")
            else:
                respond(conn, "404 Not Found", b"not found\n")
        except (ValueError, TimeoutError):
            respond(conn, "400 Bad Request", b"bad request\n")

if __name__ == "__main__":
    with socket.create_server(("127.0.0.1", 8080)) as srv:
        print("http://127.0.0.1:8080/health")
        while True:
            c, _ = srv.accept()
            threading.Thread(target=handle, args=(c,), daemon=True).start()
curl -v http://127.0.0.1:8080/health
printf 'GET /health HTTP/1.1\r\nHost: x\r\nBad Header : 1\r\n\r\n' | nc 127.0.0.1 8080   # 400

The standard library has http.server for local file serving and http.client for a low-level client. Neither is meant for production. For real services, use the tools in the next two chapters.


Generated from real-life-python-guide.md · 8 Oct 2026 16:36