HTTP and Networking
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.pyOutput:
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.pyOutput:
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.pyOutput:
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.pyOutput:
200
timeout= is how a stuck peer fails instead of hanging the process.
The boring rule
- Bind
127.0.0.1and port0in tests. - Thread + one request +
shutdown+join. The process must exit. - Use
urllib.requestuntil you have a reason for a third-party client. - Catch
HTTPErrorandURLErrorat the edge. - Do not use public echo services as fixtures.
Try this
- In
desk_http.py, change the body tob"shift closed"and printresp.statusas well. - In
post_ticket.py,json.loadsthe stored body and printtable. - In
http_missing.py, send 200 for/and 404 for anything else (self.path).