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
- What You Are Building
- The Server
- Parsing a Request
- Building a Response
- Handling Concurrency
- A Client
- Watching It on the Wire
- Extensions
- 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 outTHE 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, bodyTwo 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") + bodydef 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_WAITWhy 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, bodyThree 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 connectionWhat 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 80808. 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.
sendallexists becausesendmay 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
- Why must the reader keep unconsumed bytes after parsing a request?
- What goes wrong if you use
sendinstead ofsendallunder load? - Why does omitting
server_hostnamebreak an HTTPS client, and what is that parameter actually called on the wire?
Next Chapter
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to Computer Networks Index