omwagh28/Networks-Assignment

★ 0Forks 0PythonGitHub ↗Compare

README

HTTP Calculator That Stays On The Line

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).

What is this?

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.

Assignment goal

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:

  1. 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.
  2. If a body is present, read exactly Content-Length bytes. Byte n+1 belongs 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.

Architecture

   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)

Repository structure

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

Requirements

  • Python 3.9+ (standard library only — no pip install needed)

Running the server

python3 server.py [host] [port]     # defaults: localhost 8080
# or
./scripts/run_server.sh

Running the client demo

With the server running in another terminal:

python3 client_demo.py [host] [port]   # defaults: localhost 8080
# or
./scripts/run_demo.sh

This 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.

Tests

python3 -m unittest discover -s tests -v

15 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.

Example run (actual output)

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.

Design decisions

  • BufferedSocket (in server.py) is the one piece of state that makes persistent connections safe. It keeps a small internal buffer per connection so that read_headers() returns exactly one header block and read_exact(n) returns exactly n body 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. /add is a real endpoint, so POST /add is 405 Method Not Allowed. /pow isn't an endpoint at all, so it's 404 Not Found regardless 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-Length but gets rejected (e.g. a POST /add with 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 by test_rejected_body_is_drained_stream_stays_in_sync.

  • Host header is mandatory, matching real HTTP/1.1 — a request without one is 400 Bad Request, connection stays open (the request was still framed correctly, just semantically invalid).

  • Connection: close is honored (assignment's optional stretch goal): if the client sends it, or the request is HTTP/1.0 with no explicit Connection: 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 400 and 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.

Assignment requirements checklist

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

Contributors

omwagh28

Issues