{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"i-built-an-http-server-from-scratch-in-python-to-learn-how-http-really-works-7cmoe","url":"https://zyvop.com/i-built-an-http-server-from-scratch-in-python-to-learn-how-http-really-works-7cmoe","title":"I built an HTTP server from scratch in Python to learn how HTTP really works","subtitle":"A step-by-step build of an HTTP/1.1 server in standard library Python, covering requests, routing, keep-alive, caching, and common security traps.","tldr":"Frameworks hide what really travels between a browser and a server. So I built an HTTP server from scratch in Python. It reads raw requests, routes them, serves files with caching, and blocks path traversal. Here is how it works, with the code and 105 tests.","keywords":["webdev","Python","tutorial","Networking","http","Tutorial"],"entities":["Anshu Pathak","webdev","Python","tutorial","Networking","http","Tutorial","ZyVOP"],"keyTakeaways":["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."],"headings":["What HTTP looks like on the wire","The plan","Step 1: Reading a request","Step 2: Writing a response","Step 3: Routing","Step 4: A few endpoints that teach","Step 5: Serving files safely","Step 6: The connection loop","Try it","Testing it","Where to read the rules","What I learned","What is missing"],"outboundLinks":["https://www.rfc-editor.org/rfc/rfc9112.html","https://www.rfc-editor.org/rfc/rfc9110.html"],"contentText":"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\"[!#$%&amp;'*+.^_`|~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) &gt; 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 &gt; 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) &gt; 10 or int(raw) &gt; 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 &lt; 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-Length is added automatically, so handlers cannot forget it. HEAD requests get the headers but no body. The Content-Length still describes the body a GET would have returned. That is the whole point of HEAD. 204 No Content, 304 Not Modified and 1xx responses never have a body. A server must not send Content-Length on a 204 or a 1xx, and a 304 carries no content either, so serialize leaves 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\\n in it would let an attacker add their own headers or even a fake second response. This is called response splitting. serialize raises an error instead, and the connection handler turns that into a 500. 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 sends Connection: 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&lt;{part}&gt;[^/]+)\" 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 404 Not Found The path exists, but not for this method 405 Method Not Allowed, with an Allow header listing what works The method is not one we recognize at all 501 Not Implemented 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 &lt;= int(code) &lt;= 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 True loop 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. render catches any exception from a handler, logs the traceback on the server, and sends a generic 500 to 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.dumps quotes 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\" &gt; 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 Date Methods and statuses: HEAD, 405 with Allow, 501, JSON and form bodies, redirects, and statuses with no body Connections: keep-alive, Connection: close, HTTP/1.0, pipelining, tiny packets, bare \\n line endings, and timeouts Concurrency: 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-Length values, chunked bodies, Expect: 100-continue, and abrupt disconnects Files: content types, empty and large files, Unicode names, directory redirects, ETag and 304, and unreadable files Security: 17 path traversal attempts, null bytes, header injection, an open-redirect attempt, and log forging Failures: handlers that raise or return garbage produce a 500 and 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.]","contentHash":"sha256:c57bb2ad86251d49ba0f15bbd89912f45ee01786730766a7ce6002d2f420e2b4","authorName":"Anshu Pathak","authorUrl":"https://zyvop.com/author/anshu","authorSameAs":[],"category":"Tutorial","tags":["webdev","Python","tutorial","Networking","http"],"audience":"Developers, software engineers, and students learning Tutorial","tone":"Instructional, practical, code-first","readingTimeMinutes":24,"wordCount":5237,"faqs":null,"primaryTopic":"Tutorial","publishedAt":"2026-10-14T07:01:55.269Z","updatedAt":"2026-10-14T07:01:55.269Z","canonicalUrl":"https://zyvop.com/i-built-an-http-server-from-scratch-in-python-to-learn-how-http-really-works-7cmoe"}