Computer Networks

Practice And Capstone

Capstone: HTTP From Sockets

An HTTP/1.1 server and client, from raw TCP

JrCodex·11 min read

Jr Codex Computer Networks Notes

Level: Advanced Prerequisites: All previous modules Time to complete: ~30 minutes reading; the build is a multi-session project


Table of Contents

  1. What You Are Building
  2. The Server
  3. Parsing a Request
  4. Building a Response
  5. Handling Concurrency
  6. A Client
  7. Watching It on the Wire
  8. Extensions
  9. Summary & Next Steps

1. What You Are Building

The Task
─────────────────────────────────────────
  An HTTP/1.1 server and client, from raw TCP
  sockets. No web framework, no http library.

  You will implement, by hand:
    - the socket lifecycle (Module 4, Chapter 1)
    - MESSAGE FRAMING over a byte stream
      (Module 4, Chapter 1)
    - request parsing and response building
      (Module 5, Chapter 2)
    - keep-alive, so connections are reused
      (Module 4, Chapter 5)
    - concurrency, without one slow client
      blocking everyone
─────────────────────────────────────────
Why This Is the Right Capstone
─────────────────────────────────────────
  Every framework hides this. Writing it once makes
  the layers permanent knowledge rather than
  described knowledge.

  In particular, you will HIT the byte-stream
  framing problem rather than reading about it —
  and that is the single most useful realisation in
  socket programming.
─────────────────────────────────────────

2. The Server

import socket, threading
 
class HTTPServer:
    def __init__(self, host="0.0.0.0", port=8080):
        self.host, self.port, self.routes = host, port, {}
 
    def route(self, method, path):
        def register(fn):
            self.routes[(method, path)] = fn
            return fn
        return register
 
    def serve(self):
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)   # Module 4, Ch.1
        s.bind((self.host, self.port))
        s.listen(128)                       # the accept backlog
        print(f"listening on {self.host}:{self.port}")
 
        try:
            while True:
                conn, addr = s.accept()
                threading.Thread(target=self._handle, args=(conn, addr),
                                 daemon=True).start()
        finally:
            s.close()
Two Details That Are Not Optional
─────────────────────────────────────────
  SO_REUSEADDR
    Without it, restarting the server fails with
    "address already in use" because old
    connections sit in TIME_WAIT holding the port
    (Module 4, Chapter 1).

  BIND TO 0.0.0.0, NOT 127.0.0.1
    127.0.0.1 is reachable only from this machine.
    This is the most common cause of "it works
    locally but not from the container"
    (Module 8, Chapter 1).
─────────────────────────────────────────

3. Parsing a Request

This is where the byte-stream problem becomes concrete.

class ConnectionReader:
    """TCP gives a BYTE STREAM with no message boundaries (Module 4, Chapter 1).
       Everything here exists to impose framing on it."""
 
    def __init__(self, conn, timeout=30):
        self.conn = conn
        self.conn.settimeout(timeout)       # ALWAYS. Otherwise a dead peer hangs forever.
        self.buf = bytearray()
 
    def _fill(self) -> bool:
        chunk = self.conn.recv(8192)
        if not chunk:
            return False                    # the peer closed
        self.buf.extend(chunk)
        return True
 
    def read_until(self, delim: bytes, limit=65536) -> bytes | None:
        """Read until the delimiter. It may take several recv calls, or arrive in one."""
        while delim not in self.buf:
            if len(self.buf) > limit:
                raise ValueError("headers too large")     # do not buffer unboundedly
            if not self._fill():
                return None
        idx = self.buf.index(delim) + len(delim)
        out, self.buf = bytes(self.buf[:idx]), self.buf[idx:]
        return out                          # LEFTOVER BYTES STAY IN THE BUFFER
 
    def read_exactly(self, n: int) -> bytes:
        """For a body of known Content-Length."""
        while len(self.buf) < n:
            if not self._fill():
                raise ConnectionError("peer closed mid-body")
        out, self.buf = bytes(self.buf[:n]), self.buf[n:]
        return out
THE LEFTOVER BUFFER IS THE WHOLE POINT
─────────────────────────────────────────
  One recv() may return:
    - half a request
    - exactly one request
    - one request AND the start of the next
      (keep-alive, or a pipelined client)

  So the reader must KEEP the unconsumed bytes for
  the next parse.

  Code that assumes "one recv = one message" works
  in testing and fails under load, when TCP happens
  to coalesce or split differently. This buffer is
  what makes it correct.
─────────────────────────────────────────
def parse_request(reader: ConnectionReader):
    head = reader.read_until(b"\r\n\r\n")            # headers end at a BLANK LINE
    if head is None:
        return None                                   # connection closed cleanly
 
    lines = head.decode("iso-8859-1").split("\r\n")
    method, target, version = lines[0].split(" ", 2)
 
    headers = {}
    for line in lines[1:]:
        if not line:
            break
        name, _, value = line.partition(":")
        headers[name.strip().lower()] = value.strip()      # case-INSENSITIVE
 
    body = b""
    if (length := headers.get("content-length")):
        body = reader.read_exactly(int(length))            # framing by length
    elif headers.get("transfer-encoding") == "chunked":
        body = read_chunked(reader)                        # framing by chunks
 
    return method, target, version, headers, body
Two Framing Modes, One Rule
─────────────────────────────────────────
  Content-Length     read exactly N bytes
  Transfer-Encoding: read size-prefixed chunks
  chunked            until a zero-size chunk

  If a request has BOTH, or an ambiguous
  Content-Length, REJECT IT. Disagreement between a
  proxy and a server about where a request ends is
  REQUEST SMUGGLING (Module 5, Chapter 2) — a real
  and serious vulnerability.
─────────────────────────────────────────

4. Building a Response

REASONS = {200: "OK", 201: "Created", 204: "No Content", 400: "Bad Request",
           404: "Not Found", 405: "Method Not Allowed", 500: "Internal Server Error"}
 
def build_response(status: int, body: bytes = b"",
                   content_type="text/plain; charset=utf-8",
                   keep_alive=True, extra=None):
    headers = {
        "Content-Type": content_type,
        "Content-Length": str(len(body)),          # FRAMING for the client
        "Connection": "keep-alive" if keep_alive else "close",
        "Date": http_date(),
        "Server": "jrcodex/0.1",
        **(extra or {}),
    }
    head = f"HTTP/1.1 {status} {REASONS.get(status, 'Unknown')}\r\n"
    head += "".join(f"{k}: {v}\r\n" for k, v in headers.items())
    head += "\r\n"
    return head.encode("iso-8859-1") + body
def handle_connection(self, conn, addr):
    reader = ConnectionReader(conn)
    try:
        while True:                                  # KEEP-ALIVE: many requests per
            parsed = parse_request(reader)           # connection
            if parsed is None:
                break
 
            method, target, version, headers, body = parsed
            path = target.split("?", 1)[0]
 
            handler = self.routes.get((method, path))
            if handler is None:
                resp = build_response(404, b"Not Found")
            else:
                resp = handler(headers, body)
 
            keep = headers.get("connection", "keep-alive").lower() != "close"
            conn.sendall(resp)                       # sendall: handles PARTIAL writes
            if not keep:
                break
    except (socket.timeout, ConnectionError, ValueError):
        pass
    finally:
        conn.close()                                 # ALWAYS — or you leak CLOSE_WAIT
Why sendall, Not send
─────────────────────────────────────────
  send() may write FEWER bytes than you gave it,
  returning the count — because the socket's send
  buffer filled (Module 4, Chapter 4's flow
  control).

  Ignoring the return value silently truncates
  responses under load. sendall loops until
  everything is written.

  The same asymmetry as recv: neither send nor recv
  promises to handle your whole message.
─────────────────────────────────────────

5. Handling Concurrency

Three Models
─────────────────────────────────────────
  THREAD PER CONNECTION
    Simple. Each connection blocks its own thread.
    Breaks down at a few thousand connections —
    each thread costs memory and scheduling.
    ── what the code above does

  THREAD POOL
    A bounded number of workers pull from a queue.
    Bounded memory; a slow handler still occupies a
    worker.

  EVENT LOOP (async)
    ONE thread, non-blocking sockets, and a
    readiness notification (epoll/kqueue).
    Handles tens of thousands of connections in one
    process.
    ── how nginx and Node work
─────────────────────────────────────────
import asyncio
 
async def handle(reader, writer):
    """The same protocol logic, without a thread per connection."""
    addr = writer.get_extra_info("peername")
    try:
        while True:
            head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=30)
            method, target, version, headers, body = parse_head(head, reader)
 
            resp = await route(method, target, headers, body)
            writer.write(resp)
            await writer.drain()                     # BACKPRESSURE — wait if the
                                                      # buffer is full
            if headers.get("connection", "").lower() == "close":
                break
    except (asyncio.TimeoutError, asyncio.IncompleteReadError, ConnectionError):
        pass
    finally:
        writer.close()
        await writer.wait_closed()
 
async def main():
    server = await asyncio.start_server(handle, "0.0.0.0", 8080)
    async with server:
        await server.serve_forever()
Why C10K Was Hard, and Then Was Not
─────────────────────────────────────────
  Ten thousand concurrent connections with one
  thread each means ten thousand threads — several
  gigabytes of stacks and enormous context
  switching.

  An event loop holds ten thousand SOCKETS, which
  cost a few kilobytes each, and one thread.

  The difference is not cleverness; it is that a
  connection is mostly IDLE, waiting on the network
  (Module 1, Chapter 5). A thread waiting on a
  socket is an expensive way to represent "nothing
  is happening".
─────────────────────────────────────────

6. A Client

import socket, ssl
 
def http_get(url, timeout=10):
    scheme, _, rest = url.partition("://")
    host, _, path = rest.partition("/")
    host, _, port = host.partition(":")
    port = int(port) if port else (443 if scheme == "https" else 80)
    path = "/" + path
 
    with socket.create_connection((host, port), timeout=timeout) as sock:
        if scheme == "https":
            ctx = ssl.create_default_context()        # verification ON (Module 6, Ch.3)
            sock = ctx.wrap_socket(sock, server_hostname=host)   # server_hostname = SNI
 
        request = (f"GET {path} HTTP/1.1\r\n"
                   f"Host: {host}\r\n"                # MANDATORY (Module 5, Chapter 2)
                   f"User-Agent: jrcodex/0.1\r\n"
                   f"Accept-Encoding: identity\r\n"   # keep it simple
                   f"Connection: close\r\n"
                   f"\r\n")
        sock.sendall(request.encode())
 
        buf = bytearray()
        while chunk := sock.recv(8192):               # read until the peer closes
            buf.extend(chunk)
 
    head, _, body = bytes(buf).partition(b"\r\n\r\n")
    lines = head.decode("iso-8859-1").split("\r\n")
    status = int(lines[0].split(" ")[1])
    headers = dict(l.split(": ", 1) for l in lines[1:] if ": " in l)
    return status, headers, body
Three Things This Makes Concrete
─────────────────────────────────────────
  THE HOST HEADER IS MANDATORY. Omit it and an
  HTTP/1.1 server returns 400 — because virtual
  hosting needs it (Module 5, Chapter 2).

  server_hostname IS SNI. Omit it and TLS
  verification fails, or the wrong certificate is
  served (Module 6, Chapter 3).

  Connection: close MAKES THE BODY EASY. Read until
  EOF. With keep-alive you MUST parse
  Content-Length to know where the response ends —
  the same framing problem, from the other side.
─────────────────────────────────────────

7. Watching It on the Wire

The point of building it: now observe what you built.

# Terminal 1 — capture before starting the server (Chapter 1).
sudo tcpdump -i lo -nn -A 'tcp port 8080'
 
# Terminal 2 — run the server, then make requests.
curl -v http://localhost:8080/hello
curl -v http://localhost:8080/hello http://localhost:8080/world   # ONE connection
What to Look For
─────────────────────────────────────────
  THE THREE-WAY HANDSHAKE
    SYN, SYN-ACK, ACK before any data (Module 4,
    Chapter 3). Count the packets.

  KEEP-ALIVE
    Two curl URLs, ONE handshake. The second
    request reuses the connection — visible as data
    with no new SYN.

  THE FOUR-WAY CLOSE
    FIN, ACK, FIN, ACK at the end (Module 4,
    Chapter 3).

  ACK PIGGYBACKING
    Acknowledgements riding on data segments rather
    than being sent separately.

  Then run with -c 100 concurrency and watch the
  window sizes change (Module 4, Chapter 4).
─────────────────────────────────────────
# Test the framing edge cases your code must handle.
printf 'GET /hello HTTP/1.1\r\nHost: x\r\n\r\n' | nc localhost 8080
 
# Two requests in ONE write — does your reader handle the leftover buffer?
printf 'GET /a HTTP/1.1\r\nHost: x\r\n\r\nGET /b HTTP/1.1\r\nHost: x\r\n\r\n' \
  | nc localhost 8080
 
# A slow client — send one byte per second. Does your timeout fire?
(printf 'GET '; sleep 2; printf '/a HTTP/1.1\r\nHost: x\r\n\r\n') | nc localhost 8080

8. Extensions

In Rough Order of Difficulty
─────────────────────────────────────────
  1. CHUNKED transfer encoding, both directions

  2. HEAD and OPTIONS, and correct 405 responses
     with an Allow header

  3. CONDITIONAL REQUESTS — ETag and 304
     (Module 5, Chapter 2). Measure the bandwidth
     saved.

  4. RANGE requests, for resumable downloads

  5. TLS on the server side, with a self-signed
     certificate. Then add your CA to the trust
     store rather than disabling verification
     (Module 6, Chapter 3).

  6. A REVERSE PROXY: accept a request, forward it
     to a backend, return the response. Add
     least-connections balancing and health checks
     (Module 7, Chapter 1).

  7. HTTP/2 framing — binary frames and stream
     multiplexing (Module 5, Chapter 3). Ambitious,
     and genuinely instructive.
─────────────────────────────────────────

9. Summary & Next Steps

Key Takeaways

  • TCP delivers a byte stream, so a reader must buffer leftover bytes between messages — code assuming one recv equals one message passes tests and fails under load.
  • sendall exists because send may write fewer bytes than given; ignoring that silently truncates responses when buffers fill.
  • Framing is by Content-Length or chunked encoding, and ambiguity between them is the request-smuggling vulnerability class.
  • An event loop scales where thread-per-connection does not, because a connection is mostly idle and a thread is an expensive way to represent waiting.

Module 8 Capstone Complete

You have implemented, by hand, the socket lifecycle, byte-stream framing, HTTP parsing, keep-alive, timeouts and concurrency — and then watched the handshake, reuse and close on the wire. Every layer of this curriculum is now something you have built rather than read about.

Concept Check

  1. Why must the reader keep unconsumed bytes after parsing a request?
  2. What goes wrong if you use send instead of sendall under load?
  3. Why does omitting server_hostname break an HTTPS client, and what is that parameter actually called on the wire?

Next Chapter

Chapter 4: Where to Go Next


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to Computer Networks Index