A tiny calculator reachable over HTTP/1.1 — built directly on raw TCP sockets, with no web framework and no HTTP library. This implements the assignment "Build a calculator that stays on the line" (early HTTP/1.1, due before Session 7).
A server that answers GET /add?a=2&b=3-style requests, and a client
that talks to it. Neither uses http.server, Flask, or any socket
wrapper — just Python's socket module. The arithmetic itself is
trivial; the assignment is about correctly implementing HTTP/1.1's
persistent connection framing on top of raw TCP.
HTTP/1.0 could tell where a message ended by waiting for the socket to close — "read until EOF." HTTP/1.1 keeps the connection open across many requests, so that trick is gone. The server has to know exactly how many bytes belong to the current message before it's safe to look at what comes next:
- Read the header block up to the blank line (
\r\n\r\n) — and not one byte past it, since whatever follows might already be the next request. - If a body is present, read exactly
Content-Lengthbytes. Byten+1belongs to the next request, not this one.
This has to hold even though a single recv() call might return less
than one message, exactly one message, or several messages back to
back — TCP is a byte stream, not a message stream.
client_demo.py server.py
| |
|------ 1 TCP handshake -------->|
| |
|------ GET /add?a=2&b=3 ------->|
|<----------- 200 5 ------------|
|------ GET /sub?a=10&b=4 ------>|
|<----------- 200 6 ------------|
|------ GET /mul?a=6&b=7 ------->|
|<----------- 200 42 -----------|
|------ ... more requests ------>|
|<----------- ... ---------------|
| |
(same socket the whole time — no reconnects)
http-calculator/
├── README.md
├── .gitignore
├── server.py # the TCP server + HTTP/1.1 framing + calculator logic
├── client_demo.py # reproduces the assignment's exact grading scenario
├── tests/
│ └── test_server.py # unittest suite (spins up a real server, real sockets)
└── scripts/
├── run_server.sh
└── run_demo.sh
- Python 3.9+ (standard library only — no
pip installneeded)
python3 server.py [host] [port] # defaults: localhost 8080
# or
./scripts/run_server.shWith the server running in another terminal:
python3 client_demo.py [host] [port] # defaults: localhost 8080
# or
./scripts/run_demo.shThis opens one TCP connection and sends the exact sequence of requests from the assignment's "how I will mark it" example, then checks that the socket is still open afterward.
python3 -m unittest discover -s tests -v15 tests, all passing. They start a real server on an OS-assigned
port and talk to it over real sockets (no mocking), including a test
that sends two full requests glued together in a single sendall()
call, to prove the server doesn't assume one recv() equals one
request.
Server log for one connection handling all six requests:
listening on localhost:8080 (Ctrl+C to stop)
[+] connection from ('127.0.0.1', 53600)
GET /add?a=2&b=3 -> 200
GET /sub?a=10&b=4 -> 200
GET /mul?a=6&b=7 -> 200
GET /div?a=1&b=0 -> 400
GET /pow?a=2&b=8 -> 404
POST /add -> 405
[-] connection from ('127.0.0.1', 53600) closed
Client output:
1 TCP handshake to localhost:8080
GET /add?a=2&b=3 -> 200 '5' [ok]
GET /sub?a=10&b=4 -> 200 '6' [ok]
GET /mul?a=6&b=7 -> 200 '42' [ok]
GET /div?a=1&b=0 -> 400 'Bad Request: division by zero' [ok]
GET /pow?a=2&b=8 -> 404 'Not Found' [ok]
POST /add -> 405 'Method Not Allowed' [ok]
socket still open: True
1 TCP handshake, 6 responses
One handshake, six responses, socket still usable at the end — exactly what the assignment marks for.
-
BufferedSocket(inserver.py) is the one piece of state that makes persistent connections safe. It keeps a small internal buffer per connection so thatread_headers()returns exactly one header block andread_exact(n)returns exactlynbody bytes, regardless of how the bytes were chopped up by the network. Any leftover bytes (start of a body, or an already-arrived next request) stay buffered for the next call instead of being thrown away or double-read. -
Route existence before method check.
/addis a real endpoint, soPOST /addis405 Method Not Allowed./powisn't an endpoint at all, so it's404 Not Foundregardless of method. This mirrors how real HTTP servers distinguish "wrong verb" from "no such thing." -
A rejected request still has its body drained. If a request carries
Content-Lengthbut gets rejected (e.g. aPOST /addwith a body), the server still reads exactly that many body bytes before looking for the next request. Skipping this would leave the body's bytes sitting in the stream, and the next request's parser would misread them — silently corrupting every request after it. This is covered bytest_rejected_body_is_drained_stream_stays_in_sync. -
Hostheader is mandatory, matching real HTTP/1.1 — a request without one is400 Bad Request, connection stays open (the request was still framed correctly, just semantically invalid). -
Connection: closeis honored (assignment's optional stretch goal): if the client sends it, or the request isHTTP/1.0with no explicitConnection: keep-alive, the server closes its end after replying instead of waiting for another request. -
Idle timeout (30s, also a stretch goal): a connection that goes quiet between requests is closed rather than held open forever.
-
A genuinely unparseable request line closes the connection. Every other 4xx case above leaves the byte-stream position known and well-defined, so the connection stays open. But if the request line itself doesn't parse, the server has no reliable way to know where that malformed message ends — so it responds
400and closes rather than guessing at the next request's boundary. -
Threaded connections. Each accepted TCP connection runs in its own thread, so multiple independent clients can be served at once. Requests within one connection are still handled strictly one at a time, in order — which is what the assignment tests.
| Requirement | Status |
|---|---|
| Raw TCP sockets, no framework | ✅ socket module only |
GET /add, /sub, /mul, /div with query params |
✅ |
200 on success with correct numeric body |
✅ |
400 on division by zero |
✅ |
400 on non-integer params |
✅ |
404 on unknown path (/pow) |
✅ |
405 on wrong method (POST /add) |
✅ |
400 on missing Host header |
✅ |
| One TCP connection, many requests | ✅ (test_persistent_connection_six_requests_one_socket) |
Consume exactly Content-Length bytes, not more/less |
✅ BufferedSocket.read_exact + drain-on-reject test |
Server correctly finds message boundaries regardless of recv() chunking |
✅ test_two_requests_in_a_single_tcp_segment |
Stretch: honor Connection: close |
✅ |
| Stretch: idle timeout | ✅ (30s) |
| Stretch: chunked encoding | ❌ not implemented (optional; not exercised by the grading example) |
| Stretch: pipelining (answer six requests sent at once, in order) | ✅ works today — the buffered reader already handles requests arriving back-to-back; test_two_requests_in_a_single_tcp_segment demonstrates it with two |