
Web frameworks hide almost everything that happens on the wire. You write a function, return some JSON, and it somehow reaches the browser. I wanted to see the part in between, so I built an HTTP server using nothing but Python's standard library and plain TCP streams.
It is about 487 lines with no dependencies. It handles GET, HEAD and POST, routes requests to handler functions, serves static files with caching, keeps connections open between requests, and rejects malformed or dangerous input. It comes with 105 tests, and 12 of them use curl as the client, so the server is checked against software I did not write.
This is a learning project. Please do not put it on the public internet.
What HTTP looks like on the wire
Before writing any code, look at what actually travels between a client and a server. HTTP/1.1 is plain text. This is a complete request:
GET /hello?name=ada HTTP/1.1
Host: localhost
User-Agent: demo/1.0
Connection: closeThe first line is the request line: a method (GET), a target (/hello?name=ada), and a protocol version. Then come headers, one per line, in Name: value form. A blank line ends the headers. Every line ends with a carriage return and a line feed (\r\n), which is why you cannot see the line endings in the text above.
(I added Connection: close so the server hangs up when it is done, which makes the captured output easy to read.)
The server answers in the same shape:
HTTP/1.1 200 OK
Date: Wed, 14 Oct 2026 06:39:57 GMT
Server: tinyhttp/1.0
Content-Type: text/plain; charset=utf-8
Content-Length: 12
Connection: close
Hello, ada!The first line is the status line: version, status code, and a human-readable reason. Then headers, a blank line, and the body. The Content-Length header says the body is exactly 12 bytes, which is how the client knows where the response ends. HTTP needs some way to mark the end of a message, and the options are Content-Length, chunked encoding, or closing the connection. This server uses Content-Length and closing.
A request can have a body too. This POST sends 15 bytes of JSON, and the Content-Length header says so:
POST /echo HTTP/1.1
Host: localhost
Content-Type: application/json
Content-Length: 15
Connection: close
{"name": "ada"}HTTP/1.1 200 OK
Date: Wed, 14 Oct 2026 06:39:57 GMT
Server: tinyhttp/1.0
Content-Type: application/json
Content-Length: 92
Connection: close
{
"content_type": "application/json",
"length": 15,
"data": {
"name": "ada"
}
}That is almost everything HTTP/1.1 is. The work is in handling all the ways real clients, and bad clients, deviate from this picture.
The plan
The server has a few jobs:
Accept TCP connections
Read a request from the byte stream and check it carefully
Find the right handler, or serve a file
Write a response with correct headers
Decide whether to keep the connection open for the next request
It all lives in one file. Here are the imports and a few constants, including the limits that protect the server from oversized input:
import argparse
import asyncio
import json
import mimetypes
import os
import re
import time
import traceback
from dataclasses import dataclass, field
from email.utils import formatdate
from http import HTTPStatus
from urllib.parse import parse_qs, quote, unquoteSERVER_NAME = "tinyhttp/1.0"
KNOWN_METHODS = {"GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "TRACE", "CONNECT"}
MAX_LINE = 8 * 1024 # the request line, or any single header line
MAX_HEADERS = 100
MAX_BODY = 1024 * 1024
TOKEN = re.compile(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+") # characters allowed in a header nameStep 1: Reading a request
TCP gives you a stream of bytes with no message boundaries. Two requests sent back to back can arrive in one chunk, and one request can arrive in ten. So the server cannot just "receive a request". It has to follow the rules: read the request line, read header lines until a blank line, then read exactly Content-Length bytes of body.
First, two small types. HttpError carries a status code for requests we refuse, and Request holds what we parsed:
class HttpError(Exception):
"""Raised while reading a request that we cannot or will not handle."""
def __init__(self, status, message=None):
super().__init__(message or HTTPStatus(status).phrase)
self.status = status
@dataclass
class Request:
method: str
target: str # exactly what came after the method, e.g. /hello?name=ada
version: str
headers: dict # names are lowercased because header names are case-insensitive
body: bytes = b""
@property
def path(self):
return unquote(self.target.partition("?")[0])
@property
def query(self):
return parse_qs(self.target.partition("?")[2])
def header(self, name, default=None):
return self.headers.get(name.lower(), default)Header names are case-insensitive, so Request keeps them lowercased. The path and query properties decode percent-escapes like %20.
Now the reading code:
async def read_line(reader, too_long_status):
"""Read one line without its line ending. Returns None if the client disconnected."""
try:
line = await reader.readline()
except ValueError: # longer than the stream's own buffer limit
raise HttpError(too_long_status)
if len(line) > MAX_LINE:
raise HttpError(too_long_status)
if not line.endswith(b"\n"):
return None
line = line[:-1]
if line.endswith(b"\r"):
line = line[:-1]
return line
async def read_headers(reader):
headers = {}
count = 0
while True:
line = await read_line(reader, 431)
if line is None:
return None
if line == b"": # an empty line ends the headers
return headers
count += 1
if count > MAX_HEADERS:
raise HttpError(431, "too many header fields")
name, colon, value = line.decode("latin-1").partition(":")
if not colon or not TOKEN.fullmatch(name):
raise HttpError(400, "malformed header line")
name = name.lower()
value = value.strip(" \t")
if name == "content-length" and name in headers:
if headers[name] != value:
raise HttpError(400, "conflicting Content-Length headers")
elif name in headers:
headers[name] += ", " + value # repeated headers are equivalent to one comma-joined header
else:
headers[name] = value
async def read_request(reader, writer):
"""Read one request. Returns None if the client disconnected before it was complete."""
line = await read_line(reader, 414)
if line == b"": # be forgiving about one stray blank line before a request
line = await read_line(reader, 414)
if line is None:
return None
try:
method, target, version = line.decode("ascii").split(" ")
except (UnicodeDecodeError, ValueError):
raise HttpError(400, "malformed request line")
if not re.fullmatch(r"[A-Z]+", method) or not target.startswith("/"):
raise HttpError(400, "malformed request line")
if not re.fullmatch(r"HTTP/[0-9]\.[0-9]", version):
raise HttpError(400, "malformed HTTP version")
if version not in ("HTTP/1.0", "HTTP/1.1"):
raise HttpError(505)
headers = await read_headers(reader)
if headers is None:
return None
if version == "HTTP/1.1" and "host" not in headers:
raise HttpError(400, "HTTP/1.1 requests must have a Host header")
if "transfer-encoding" in headers:
raise HttpError(501, "Transfer-Encoding is not supported")
length = 0
if "content-length" in headers:
raw = headers["content-length"]
if not (raw.isascii() and raw.isdigit()):
raise HttpError(400, "invalid Content-Length")
if len(raw) > 10 or int(raw) > MAX_BODY:
raise HttpError(413)
length = int(raw)
expect = headers.get("expect")
if expect is not None and expect.lower() != "100-continue":
raise HttpError(417)
body = b""
if length:
if expect: # the client is waiting for permission before it sends the body
writer.write(b"HTTP/1.1 100 Continue\r\n\r\n")
await writer.drain()
try:
body = await reader.readexactly(length)
except asyncio.IncompleteReadError:
return None
return Request(method, target, version, headers, body)There are a lot of small decisions in there, and most of them exist because ambiguity is where security bugs come from.
Strict parsing. The request line must have exactly three parts separated by single spaces. Header names must be made of token characters, so Host : x (a space before the colon) and folded continuation lines are rejected with 400 Bad Request. Lenient parsers are how two servers end up disagreeing about where a request ends.
Limits. A line over 8 KB gets 414 (request line) or 431 (header line), more than 100 headers gets 431, and a body over 1 MB gets 413. A server with no limits lets any client make it allocate unlimited memory.
The Host header. HTTP/1.1 requires it, because one server and IP address can host many sites. A missing Host is a 400.
Content-Length validation. It must be digits only. I check isascii() as well as isdigit(), because Python's "²".isdigit() is True and int("²") fails. I also check the length of the string before converting it, because Python 3.11 and later raises an error when you convert a very long digit string to an integer. Without that check, a header with 5,000 digits would raise an unhandled error. Conflicting duplicate Content-Length headers are rejected too.
Transfer-Encoding. This server does not support chunked request bodies, so any request that uses it gets 501 Not Implemented. Rejecting it outright also avoids a classic trap: if two programs in a chain disagree about whether Transfer-Encoding or Content-Length decides where a body ends, an attacker can hide a second request inside the first. That family of attacks is called request smuggling.
Expect: 100-continue. Some clients, before sending a large body, send the headers and wait. The server can answer 100 Continue to say "go ahead" or reject the request right away. The code checks the size first, so an oversized upload is refused without ever telling the client to send the body.
Line endings. The spec says lines end in \r\n, but it allows servers to accept a bare \n. I accept both, which makes it easy to test by hand with tools that send only \n.
Step 2: Writing a response
A response is a status, some headers, and a body. These small types and helpers build one:
@dataclass
class Response:
status: int = 200
body: bytes = b""
headers: dict = field(default_factory=dict)
def text_response(body, status=200, headers=None):
return Response(status, body.encode("utf-8"),
{"Content-Type": "text/plain; charset=utf-8", **(headers or {})})
def html_response(body, status=200):
return Response(status, body.encode("utf-8"),
{"Content-Type": "text/html; charset=utf-8"})
def json_response(data, status=200):
body = (json.dumps(data, indent=2) + "\n").encode("utf-8")
return Response(status, body, {"Content-Type": "application/json"})
def not_found():
return text_response("404 Not Found\n", 404)The part that turns a Response into bytes is serialize, along with the keep-alive decision:
def wants_keep_alive(request):
tokens = {t.strip() for t in request.header("connection", "").lower().split(",")}
if request.version == "HTTP/1.1":
return "close" not in tokens # 1.1 connections stay open unless someone says otherwise
return "keep-alive" in tokens # 1.0 connections close unless someone says otherwise
def serialize(response, method, keep_alive):
"""Turn a Response into the exact bytes that go on the wire."""
status = response.status
reason = HTTPStatus(status).phrase
bodyless = status < 200 or status in (204, 304)
headers = {"Date": formatdate(usegmt=True), "Server": SERVER_NAME}
headers.update(response.headers)
if not bodyless:
headers.setdefault("Content-Length", str(len(response.body)))
headers["Connection"] = "keep-alive" if keep_alive else "close"
lines = [f"HTTP/1.1 {status} {reason}"]
for name, value in headers.items():
if "\r" in f"{name}{value}" or "\n" in f"{name}{value}":
raise ValueError("a header contains a line break") # stops response splitting
lines.append(f"{name}: {value}")
head = ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1")
body = b"" if bodyless or method == "HEAD" else response.body
return head + bodyA few things here come straight from the HTTP rules:
Content-Lengthis added automatically, so handlers cannot forget it.HEADrequests get the headers but no body. TheContent-Lengthstill describes the body aGETwould have returned. That is the whole point ofHEAD.204 No Content,304 Not Modifiedand1xxresponses never have a body. A server must not sendContent-Lengthon a204or a1xx, and a304carries no content either, soserializeleaves the header out for all three.Header values cannot contain a line break. If user input ever reaches a header, for example in a redirect, a
\r\nin it would let an attacker add their own headers or even a fake second response. This is called response splitting.serializeraises an error instead, and the connection handler turns that into a500.Keep-alive has different defaults per version. HTTP/1.1 keeps the connection open unless someone sends
Connection: close. HTTP/1.0 closes unless the client sendsConnection: keep-alive.
Step 3: Routing
A router maps a method and a path to a function. I wanted something like Flask's decorators in a few lines:
class App:
def __init__(self, root=None):
self.root = os.path.realpath(root) if root else None
self.routes = [] # (method, compiled pattern, handler)
def route(self, method, pattern):
"""Register a handler. A {name} in the pattern matches one path segment."""
parts = re.split(r"\{(\w+)\}", pattern)
regex = "".join(
re.escape(part) if i % 2 == 0 else f"(?P<{part}>[^/]+)"
for i, part in enumerate(parts)
)
compiled = re.compile(regex)
def register(handler):
self.routes.append((method, compiled, handler))
return handler
return register
def dispatch(self, request):
if request.method not in KNOWN_METHODS:
return text_response("501 Not Implemented\n", 501)
method = "GET" if request.method == "HEAD" else request.method # HEAD is GET without the body
allowed = set()
for route_method, regex, handler in self.routes:
match = regex.fullmatch(request.path)
if match is None:
continue
if route_method == method:
return handler(request, **match.groupdict())
allowed.add(route_method)
if self.root and not allowed:
if method == "GET":
return serve_file(self.root, request)
if find_in_root(self.root, request.path) is not None:
allowed.add("GET")
if allowed:
if "GET" in allowed:
allowed.add("HEAD")
return text_response("405 Method Not Allowed\n", 405,
{"Allow": ", ".join(sorted(allowed))})
return not_found()A {name} in a route pattern matches one path segment and is passed to the handler as an argument. The literal parts are escaped, so a . in a route means a dot and not "any character". There is a test for exactly that.
dispatch also settles three status codes that people often mix up:
Situation | Response |
|---|---|
The path matches nothing |
|
The path exists, but not for this method |
|
The method is not one we recognize at all |
|
HEAD support comes for free: a HEAD request is routed as a GET, and serialize drops the body.
Step 4: A few endpoints that teach
These are the built-in routes:
def build_app(root=None):
app = App(root)
@app.route("GET", "/")
def index(request):
return html_response(INDEX_HTML)
@app.route("GET", "/hello")
def hello(request):
name = request.query.get("name", ["world"])[0]
return text_response(f"Hello, {name}!\n")
@app.route("GET", "/time")
def current_time(request):
return json_response({"utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
@app.route("GET", "/headers")
def show_headers(request):
return json_response(request.headers)
@app.route("POST", "/echo")
def echo(request):
content_type = request.header("content-type", "").split(";")[0].strip().lower()
if content_type == "application/json":
try:
data = json.loads(request.body)
except ValueError:
return json_response({"error": "the body is not valid JSON"}, 400)
elif content_type == "application/x-www-form-urlencoded":
data = parse_qs(request.body.decode("utf-8", "replace"))
else:
data = request.body.decode("utf-8", "replace")
return json_response({"content_type": content_type or None,
"length": len(request.body),
"data": data})
@app.route("GET", "/old")
def old(request):
return Response(301, b"", {"Location": "/hello"})
@app.route("GET", "/status/{code}")
def status(request, code):
# A demo only: some statuses (401, 416 and others) normally need extra headers.
phrase = None
if code.isascii() and code.isdigit() and len(code) == 3 and 200 <= int(code) <= 599:
try:
phrase = HTTPStatus(int(code)).phrase
except ValueError:
pass
if phrase is None:
return text_response("Ask for a known status code from 200 to 599\n", 400)
number = int(code)
headers = {"Location": "/hello"} if number in (301, 302, 303, 307, 308) else {}
body = "" if number in (204, 205, 304) else f"{number} {phrase}\n"
return text_response(body, number, headers)
return app(The HTML for the home page is a short string called INDEX_HTML in the full file.)
/headers is my favorite. It echoes back every header your client sent, so you can see what curl sends compared with a browser. /status/{code} returns whatever status code you ask for, which is handy for poking at how clients react to a 418 or a 503.
Step 5: Serving files safely
Serving files is where a lot of first web servers go wrong. If the URL is /../../etc/passwd and you naively join it onto your web root, you hand out files that were never meant to be public.
def guess_type(path):
content_type = mimetypes.guess_type(path)[0] or "application/octet-stream"
if content_type.startswith("text/") or content_type == "application/json":
content_type += "; charset=utf-8"
return content_type
def etag_matches(header, etag):
if not header:
return False
candidates = [c.strip().removeprefix("W/") for c in header.split(",")]
return "*" in candidates or etag in candidates
def find_in_root(root, url_path):
"""Map a URL path to a file or directory inside root, or None.
This is the check that stops /../../etc/passwd. We resolve the final path
first (following .. and symlinks), then confirm it is still inside root.
"""
if "\0" in url_path:
return None
candidate = os.path.realpath(os.path.join(root, url_path.lstrip("/")))
if candidate != root and not candidate.startswith(root + os.sep):
return None
return candidate if os.path.exists(candidate) else None
def serve_file(root, request):
path = find_in_root(root, request.path)
if path is not None and os.path.isdir(path):
if not request.path.endswith("/"):
# Relative links inside a directory page only work with a trailing slash.
return Response(301, b"", {"Location": "/" + quote(request.path.strip("/")) + "/"})
path = find_in_root(root, request.path + "index.html")
elif request.path.endswith("/"):
path = None # "/file.txt/" is not a thing
if path is None or not os.path.isfile(path):
return not_found()
try:
stat = os.stat(path)
etag = f'"{stat.st_mtime_ns:x}-{stat.st_size:x}"'
headers = {"ETag": etag, "Cache-Control": "no-cache"}
if etag_matches(request.header("if-none-match"), etag):
return Response(304, b"", headers) # the browser's copy is still good
with open(path, "rb") as f:
body = b"" if request.method == "HEAD" else f.read()
except PermissionError:
return text_response("403 Forbidden\n", 403)
except FileNotFoundError:
return not_found() # the file disappeared after we found it
headers["Content-Type"] = guess_type(path)
if request.method == "HEAD":
headers["Content-Length"] = str(stat.st_size) # report the size without reading the file
return Response(200, body, headers)find_in_root is the security check. The order matters: it first resolves the final path with os.path.realpath, which collapses every .. and follows symlinks, and only then checks that the result is still inside the web root. Checking the string before resolving it is the classic mistake.
There is a subtler bug waiting too. If the root is /srv/public, then the check path.startswith("/srv/public") would also allow /srv/public-evil/secret.txt. Comparing against the root plus a path separator fixes that, and there is a test for it.
Three smaller details in serve_file are worth a mention. A HEAD request reports the file's size from the filesystem and never reads the file. A file the server is not allowed to read gets 403 Forbidden. And a file that disappears between the lookup and the read gets a 404, not a generic 500.
serve_file also shows three more pieces of HTTP.
ETag and 304. Each file gets an ETag built from its modification time and size. A client that already has the file sends it back in If-None-Match, and if it still matches the server answers 304 Not Modified with no body. Here is the first response:
HTTP/1.1 200 OK
Date: Wed, 14 Oct 2026 06:39:57 GMT
Server: tinyhttp/1.0
ETag: "18dbd7d1f3e9b8be-12"
Cache-Control: no-cache
Content-Type: text/plain; charset=utf-8
Content-Length: 18
Connection: close
hello from a fileAnd the second request, which includes the ETag it just received:
GET /hello.txt HTTP/1.1
Host: localhost
If-None-Match: "18dbd7d1f3e9b8be-12"
Connection: closeHTTP/1.1 304 Not Modified
Date: Wed, 14 Oct 2026 06:39:57 GMT
Server: tinyhttp/1.0
ETag: "18dbd7d1f3e9b8be-12"
Cache-Control: no-cache
Connection: closeCache-Control: no-cache does not mean "never store this". It means "you may store it, but check with me before using it". That is exactly what the 304 flow does.
Directory redirects. A request for /docs redirects to /docs/ before serving docs/index.html. Relative links inside a page resolve against the URL's directory, so without the trailing slash they would point to the wrong place. The redirect target is rebuilt from the cleaned path, so a request for //docs cannot turn into Location: //docs/, which browsers read as a link to a different website.
Content types. The Content-Type header comes from the file extension. Text types are labeled UTF-8, and unknown extensions fall back to application/octet-stream.
A traversal attempt gets nothing:
GET /../secret.txt HTTP/1.1
Host: localhost
Connection: closeHTTP/1.1 404 Not Found
Date: Wed, 14 Oct 2026 06:39:57 GMT
Server: tinyhttp/1.0
Content-Type: text/plain; charset=utf-8
Content-Length: 14
Connection: close
404 Not FoundStep 6: The connection loop
Everything so far handles one request. This last part handles connections, and it is where asyncio earns its place:
def render(app, request):
"""Run the handler and turn its Response into bytes. Any bug becomes a 500."""
keep_alive = wants_keep_alive(request)
try:
response = app.dispatch(request)
data = serialize(response, request.method, keep_alive)
except Exception:
traceback.print_exc()
response = text_response("500 Internal Server Error\n", 500)
data = serialize(response, request.method, keep_alive)
return response, data, keep_alive
def log_access(client, request_line, status, size):
# json.dumps escapes control characters so a client cannot forge log lines.
print(f"{client} {json.dumps(request_line)} {status} {size}", flush=True)
async def send(writer, data, timeout):
"""Send data in pieces. A client that stops reading for `timeout` seconds is dropped."""
try:
for start in range(0, len(data), 65536):
writer.write(data[start:start + 65536])
await asyncio.wait_for(writer.drain(), timeout)
except asyncio.TimeoutError:
writer.transport.abort() # throw away whatever is still buffered and hang up
raise
async def handle_connection(reader, writer, app, timeout=10, quiet=False):
client = (writer.get_extra_info("peername") or ("-",))[0]
try:
while True: # one connection can carry many requests
try:
request = await asyncio.wait_for(read_request(reader, writer), timeout)
except HttpError as err:
response = text_response(f"{err.status} {err}\n", err.status)
await send(writer, serialize(response, "GET", keep_alive=False), timeout)
if not quiet:
log_access(client, "-", err.status, len(response.body))
break # after a bad request we cannot trust where the next one starts
except asyncio.TimeoutError:
break
if request is None:
break
response, data, keep_alive = render(app, request)
await send(writer, data, timeout)
if not quiet:
line = f"{request.method} {request.target} {request.version}"
log_access(client, line, response.status, len(response.body))
if not keep_alive:
break
except (ConnectionError, asyncio.TimeoutError):
pass # the client went away, or stopped reading
except Exception:
traceback.print_exc()
finally:
writer.close()
async def serve(app, host="127.0.0.1", port=8000, timeout=10, quiet=False):
server = await asyncio.start_server(
lambda reader, writer: handle_connection(reader, writer, app, timeout, quiet),
host, port,
)
host, port = server.sockets[0].getsockname()[:2]
print(f"Listening on http://{host}:{port}", flush=True)
async with server:
await server.serve_forever()if __name__ == "__main__":
parser = argparse.ArgumentParser(description="A tiny HTTP server")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--root", default="public", help="folder of static files to serve")
parser.add_argument("--timeout", type=float, default=10, help="seconds to wait for a request")
parser.add_argument("--quiet", action="store_true", help="do not print an access log")
options = parser.parse_args()
app = build_app(options.root if os.path.isdir(options.root) else None)
try:
asyncio.run(serve(app, options.host, options.port, options.timeout, options.quiet))
except KeyboardInterrupt:
passA few points on how it works:
One connection, many requests. The
while Trueloop is HTTP keep-alive. The connection stays open and the server reads the next request from the same stream, which saves a TCP handshake for every request. Clients can also send several requests without waiting for answers (pipelining), and the loop answers them in order.Timeouts. Reading a request is wrapped in
asyncio.wait_for, so a whole request has to arrive within the timeout. A client that connects and says nothing, sends half a request and stalls, or drips bytes one at a time is dropped. An idle keep-alive connection is closed the same way. Without this, a handful of idle sockets could hold the server's resources forever.A client that stops reading. Responses are sent in 64 KB pieces, and each piece has to be accepted within the timeout. A client that asks for a big file and then never reads it is dropped and its connection is aborted. Without that, one client could pin a connection, and a copy of the file in memory, for as long as it liked. A slow client that keeps making progress is left alone.
After a bad request, hang up. Once a request is malformed, we cannot trust where the next one starts, so the connection is closed after the error response.
Handler bugs become
500s.rendercatches any exception from a handler, logs the traceback on the server, and sends a generic500to the client. The client never sees internal details.Slow clients do not block others. Each connection is its own coroutine, so one stalled client does not hold up anyone else. One caveat: handlers are plain functions, so a handler that does slow blocking work will stall every connection.
The access log escapes control characters.
json.dumpsquotes the request line, so a client cannot forge fake log lines or smuggle terminal escape codes into your logs.
The access log looks like this (client, request line, status, body size):
127.0.0.1 "GET /hello?name=ada HTTP/1.1" 200 12
127.0.0.1 "POST /echo HTTP/1.1" 200 92
127.0.0.1 "GET /hello.txt HTTP/1.1" 200 18
127.0.0.1 "GET /hello.txt HTTP/1.1" 304 0
127.0.0.1 "GET /../secret.txt HTTP/1.1" 404 14
127.0.0.1 "GET /old HTTP/1.1" 301 0
127.0.0.1 "HEAD /hello HTTP/1.1" 200 14
127.0.0.1 "-" 400 46
127.0.0.1 "POST /hello HTTP/1.1" 405 23Try it
Create a folder with a file to serve, and start the server:
mkdir public
echo "hello from a file" > public/hello.txt
python tinyhttp.pyIt listens on port 8000 and serves files from public. Use --port, --root, --timeout and --quiet to change the defaults. Then try these from another terminal:
curl -i "http://127.0.0.1:8000/hello?name=ada"
curl -i http://127.0.0.1:8000/hello.txt
curl -I http://127.0.0.1:8000/hello
curl -i -X POST -H "Content-Type: application/json" -d '{"a": 1}' http://127.0.0.1:8000/echo
curl -iL http://127.0.0.1:8000/old
curl -i http://127.0.0.1:8000/status/418
curl -i --path-as-is http://127.0.0.1:8000/../secret.txtAdd -v to any of them to see the exact request that curl sends. You can also type a request by hand, which is the best way to feel how simple the protocol is:
printf 'GET /hello HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8000To see a rejection, send a request with no Host header:
GET /hello HTTP/1.1HTTP/1.1 400 Bad Request
Date: Wed, 14 Oct 2026 06:39:57 GMT
Server: tinyhttp/1.0
Content-Type: text/plain; charset=utf-8
Content-Length: 46
Connection: close
400 HTTP/1.1 requests must have a Host headerAnd here is what a method that exists, but not on that path, looks like:
HTTP/1.1 405 Method Not Allowed
Date: Wed, 14 Oct 2026 06:39:57 GMT
Server: tinyhttp/1.0
Content-Type: text/plain; charset=utf-8
Allow: GET, HEAD
Content-Length: 23
Connection: close
405 Method Not AllowedTesting it
I did not want to claim this works based on a few manual checks, so the suite has 105 tests. Most start the real server as a subprocess and send it raw bytes over TCP, using a small client I wrote separately from the server. In 12 of them, curl is the client, which checks the server against software I did not write. A few run the server inside the test process so I can register handlers that crash on purpose.
What they cover:
Routes and headers: query decoding, header case, repeated and padded headers, and a current
DateMethods and statuses:
HEAD,405withAllow,501, JSON and form bodies, redirects, and statuses with no bodyConnections: keep-alive,
Connection: close, HTTP/1.0, pipelining, tiny packets, bare\nline endings, and timeoutsConcurrency: a stalled client not blocking others, 40 clients at once, and a client that stops reading a 24 MB download being dropped
Bad input: about 20 malformed requests, oversized lines and headers, bad
Content-Lengthvalues, chunked bodies,Expect: 100-continue, and abrupt disconnectsFiles: content types, empty and large files, Unicode names, directory redirects,
ETagand304, and unreadable filesSecurity: 17 path traversal attempts, null bytes, header injection, an open-redirect attempt, and log forging
Failures: handlers that raise or return garbage produce a
500and leave the connection usable
Run everything with python test_tinyhttp.py. The tail of the output looks like this:
----------------------------------------------------------------------
Ran 105 tests in 17.310s
OKA separate script, extra_checks.py, throws harder cases at the server. It sends 6,300 mutated and garbage requests and checks that every reply is valid HTTP and the server survives. It fires more than 28,000 path traversal payloads and checks that none of them returns a file outside the web root.
It also runs 10,000 requests over 100 connections, then opens and drops 3,000 more, some of them abruptly, and checks that the server's open file count does not grow. It exits with an error if anything goes wrong. I confirmed that by putting the sibling-folder and symlink bugs back.
If curl is not installed, the 12 curl tests are skipped and the rest still run. I ran the suite several times in a row to make sure the timing-based tests were stable.
Where to read the rules
The message format is defined in RFC 9112, and the meaning of methods, statuses and headers is in RFC 9110. Several behaviors here come straight from them: a missing Host gets a 400, whitespace before a header's colon is rejected, a 204 has no Content-Length, and a 405 lists the allowed methods in Allow.
What I learned
HTTP is text, lines, and a length. There is a request line or status line, headers, a blank line, and a body whose size is stated up front. Once that clicks, a large part of web development stops feeling like magic.
Content-Length is the backbone. Keep-alive, pipelining, HEAD, 304, and half the security problems all come back to the question "where does this message end?"
Most of the work is at the edges. The happy path was a few dozen lines. The rest is limits, strict parsing, timeouts, and deciding what to do with input that is technically parseable but suspicious.
Resolve first, then check. Both the path traversal check and the header injection check work because they look at the final value instead of trying to filter the input.
What is missing
This is a teaching server, not a production one. It has no HTTPS, no chunked request bodies, no compression, no range requests, no cookies, no multipart uploads and no HTTP/2.
Files are read into memory in one go, which blocks the event loop. There is no authentication and no cap on connections per client. It uses str.removeprefix, so it needs Python 3.9 or newer, and I only ran it on Linux with Python 3.12. For real work, put a mature server such as nginx in front of your application.
If you want to extend it, good next steps are chunked request bodies, Range support, gzip compression, and a Last-Modified header with If-Modified-Since.
[Link to your repository with tinyhttp.py, test_tinyhttp.py and extra_checks.py goes here.]
Comments(0)
Join the discussion by logging into your account.
No comments yet. Be the first to comment!