Handling Errors
Answer broken requests, unsupported methods and server failures with the right status codes, and keep the server alive.
So far we have tested only the happy path. Real clients don't stay on it. A request can be broken, a method can be unsupported, our own code can fail, and a client can connect and never say anything.
Right now, each of these ends badly:
- A broken request gets no reply at all.
- A
POSTrequest is treated like aGET. - If our code raises an error, the whole server stops for everyone.
- A silent client blocks the server, because we wait for its request forever.
In this lesson, we fix all these four problems. Status codes help us here. Codes starting with 4 mean "the client did something wrong", and codes starting with 5 mean "the server failed".
We already use 404. Here is what we will add:
- 400 Bad Request: The request is malformed or broken.
- 501 Not Implemented: The method is not GET or HEAD.
- 500 Internal Server Error: Something fails in our server code.
- Timeout: If the client sends nothing for 5 seconds, we close the connection.
Add an Error Helper
Every error response has the same shape: a status, a reason and a short text. Let's write that once.
Add error_response() below build_response():
def error_response(status: int, reason: str) -> bytes:
return build_response(status, reason, f"{status} {reason}".encode("utf-8"))It uses the status and reason as the body too, so error_response(400, "Bad Request") sends the text 400 Bad Request.
Handle One Request in a Function
The code inside main() is getting long, and now it needs to answer errors in several places.
Let's move the work for a single request into its own function, handle().
Add it below serve():
def handle(conn: socket.socket) -> None:
raw = read_request(conn)
try:
method, target, version, headers = parse_request(raw)
except ValueError as exc:
print(f"bad request: {exc}")
conn.sendall(error_response(400, "Bad Request"))
return
print(f"{method} {target} {version}")
for name, value in headers.items():
print(f" {name}: {value}")
if method not in ("GET", "HEAD"):
print(" -> 501 Not Implemented")
conn.sendall(error_response(501, "Not Implemented"))
return
status, reason, body, content_type = serve(target)
print(f" -> {status} {reason}")
response = build_response(
status, reason, body, content_type, head_only=(method == "HEAD")
)
conn.sendall(response)It is the same code as before, with two changes:
- Broken requests get a reply. : When
parse_request()raises aValueError, we now send400 Bad Requestinstead of only printing a message. - Unsupported methods are rejected. : Our server only knows how to
GETandHEAD. For anything else, we answer501 Not Implemented.
Each return ends the function early, so we never reach serve() for a request we have already answered.
Catch Everything Else in main
handle() deals with problems we expect. But some problems we don't expect, such as a bug in our code or a file we cannot read. We catch those in main(), around the call to handle().
Replace the contents of the with conn: block in main() with this:
conn, addr = server.accept()
with conn:
print(f"connection from {addr}")
conn.settimeout(5) # give up on a silent client after 5 seconds
try:
handle(conn)
except TimeoutError:
print(" timed out waiting for the request")
except ConnectionError:
print(" client disconnected")
except Exception as exc:
print(f" server error: {exc!r}")
conn.sendall(error_response(500, "Internal Server Error"))Let's walk through it:
- Set a timeout. :
settimeout(5)makesrecv()raise an error if nothing arrives for 5 seconds, instead of waiting forever. Browsers sometimes open a connection and never send anything, and without a timeout, one of those would block every other client. - Silent client. : The
TimeoutErrormeans the client never sent anything. We print a message, andwith conn:closes the connection. There is nobody waiting for an answer. - Client left. : A
ConnectionErrormeans the client closed the connection while we were talking to it. There is nobody to answer, so we move on. - Anything else. : Any other error is a failure on our side, so we send
500 Internal Server Errorand the server keeps running.
The order matters. Python checks the except clauses from top to bottom, and except Exception catches almost everything, so it must come last.
Run The Server
Here is the full file:
import socket
from pathlib import Path
HOST = "127.0.0.1"
PORT = 8000
PUBLIC_DIR = (Path(__file__).parent / "public").resolve()
CONTENT_TYPES = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
}
def read_request(conn: socket.socket) -> bytes:
data = b""
while b"\r\n\r\n" not in data:
chunk = conn.recv(1024)
if not chunk: # the client closed the connection
break
data += chunk
return data
def parse_request(raw: bytes) -> tuple[str, str, str, dict[str, str]]:
if b"\r\n\r\n" not in raw:
raise ValueError("incomplete request")
head = raw.split(b"\r\n\r\n", 1)[0].decode("iso-8859-1")
lines = head.split("\r\n")
parts = lines[0].split(" ")
if len(parts) != 3:
raise ValueError("malformed request line")
method, target, version = parts
headers = {}
for line in lines[1:]:
name, sep, value = line.partition(":")
if not sep:
raise ValueError("malformed header")
headers[name.strip().lower()] = value.strip()
return method, target, version, headers
def build_response(
status: int,
reason: str,
body: bytes,
content_type: str = "text/plain; charset=utf-8",
head_only: bool = False,
) -> bytes:
head = (
f"HTTP/1.1 {status} {reason}\r\n"
f"Content-Type: {content_type}\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n"
"\r\n"
).encode("iso-8859-1")
return head if head_only else head + body
def error_response(status: int, reason: str) -> bytes:
return build_response(status, reason, f"{status} {reason}".encode("utf-8"))
def serve(target: str) -> tuple[int, str, bytes, str]:
path = target.split("?", 1)[0]
if path == "/":
path = "/index.html"
file = (PUBLIC_DIR / path.lstrip("/")).resolve()
if PUBLIC_DIR not in file.parents or not file.is_file():
return 404, "Not Found", b"404 Not Found", "text/plain; charset=utf-8"
content_type = CONTENT_TYPES.get(file.suffix, "application/octet-stream")
return 200, "OK", file.read_bytes(), content_type
def handle(conn: socket.socket) -> None:
raw = read_request(conn)
try:
method, target, version, headers = parse_request(raw)
except ValueError as exc:
print(f"bad request: {exc}")
conn.sendall(error_response(400, "Bad Request"))
return
print(f"{method} {target} {version}")
for name, value in headers.items():
print(f" {name}: {value}")
if method not in ("GET", "HEAD"):
print(" -> 501 Not Implemented")
conn.sendall(error_response(501, "Not Implemented"))
return
status, reason, body, content_type = serve(target)
print(f" -> {status} {reason}")
response = build_response(
status, reason, body, content_type, head_only=(method == "HEAD")
)
conn.sendall(response)
def main() -> None:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.listen()
print(f"listening on http://{HOST}:{PORT} (Press Ctrl+c to stop)")
try:
while True:
conn, addr = server.accept()
with conn:
print(f"connection from {addr}")
conn.settimeout(5) # give up on a silent client after 5 seconds
try:
handle(conn)
except TimeoutError:
print(" timed out waiting for the request")
except ConnectionError:
print(" client disconnected")
except Exception as exc:
print(f" server error: {exc!r}")
conn.sendall(error_response(500, "Internal Server Error"))
except KeyboardInterrupt:
print("\nbye")
if __name__ == "__main__":
main()Start the server and test the four cases one by one.
A broken request. - This sends a request line with only one part:
python3 -c "import socket; s=socket.create_connection(('127.0.0.1',8000)); s.sendall(b'hello\r\n\r\n'); print(s.recv(1024).decode())"Expected output:
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 15
Connection: close
400 Bad RequestAn unsupported method. - The -X POST option makes curl use POST:
curl -i -X POST http://127.0.0.1:8000/Expected output:
HTTP/1.1 501 Not Implemented
Content-Type: text/plain; charset=utf-8
Content-Length: 19
Connection: close
501 Not ImplementedA silent client. - This connects and says nothing for 8 seconds:
python3 -c "import socket, time; s = socket.create_connection(('127.0.0.1', 8000)); time.sleep(8)"After 5 seconds, the server's terminal shows:
connection from ('127.0.0.1', 44962)
timed out waiting for the requestA failure in our own code. - To cause one on purpose, add this line as the first line of serve(), then restart the server:
raise RuntimeError("boom")Now ask for any page:
curl -i http://127.0.0.1:8000/Expected output:
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
Content-Length: 25
Connection: close
500 Internal Server ErrorThe server's terminal shows the real reason, which the client never sees:
connection from ('127.0.0.1', 54313)
GET / HTTP/1.1
...
server error: RuntimeError('boom')Run the curl command again. The server answers again. It did not stop.
Remove the raise line and restart the server before you continue.
What You Built
You started with an empty file, and you now have a working HTTP server. It opens a socket, accepts connections, reads and parses requests, sends well-formed responses, serves files from a folder, and answers errors with the right status codes. All of it fits in one Python file, and every part is something you wrote and understand.
It is still a teaching server. A real one would also handle many clients at the same time, limit the size of requests, reuse connections, and more. Congratulations! You have built your own simple HTTP server. Next, let's look at where you can take it from here.