The errors a server owes its clients
◈ 5 cardsA missing file is 404, a method you do not implement is 501, an unparseable request line is 400 — and each one is a complete response with a status line, a Content-Type, a blank line, and a body a human can read.
An error is a response, not a silence
When your server cannot do what was asked, the protocol still applies. An error response has exactly the same shape as a success: a status line, headers including a Content-Type, a blank line, and a body. Closing the socket without writing anything is not an error response — the client sees a connection reset and reports a network fault, which sends whoever is debugging you in entirely the wrong direction.
The body should be small, plain HTML, readable by a person, and uninformative about your filesystem. Never echo the resolved path into a 404: a body saying /srv/site/admin/keys.txt not found has told an attacker your directory layout and confirmed which of two guesses was closer.
The three your file server actually emits
404 Not Found — the file does not exist, is not readable, or resolved outside the document root. All three collapse to one answer on purpose: a client that can distinguish "forbidden" from "absent" can map your disk one request at a time.
501 Not Implemented — the request named a method your server does not support at all. A static file server supports GET (and HEAD if you implement it); POST, PUT and DELETE get 501. Do not reach for 405 Method Not Allowed: 405 means the method is implemented but not for this resource, and it obliges you to send an Allow header. 501 is the honest code for "this server does not do that".
400 Bad Request — the request line will not parse: fewer than two tokens, or a target that is not an absolute path. You cannot route it, so you say so rather than guess.
A fourth you should send but hope never to: 500 Internal Server Error, for an exception that escaped your handler. It is the only one that means you are wrong, which is why it goes in a different log — 400, 404 and 501 are ordinary traffic for the audit log, while a 500 belongs in the error log as a bug to fix.
Content-Length counts bytes, not characters
The commonest defect in a hand-written error path is body.length() — the String character count — used as Content-Length. Put one non-ASCII character in the message (a curly quote, an accented filename) and the byte count exceeds it, so the client stops short and hangs on a truncated body. Take the length from body.getBytes(StandardCharsets.UTF_8).length, the same array the encoder will actually produce.
And keep the HTTP/0.9 rule from lesson 1: if the request declared no version, send the body with no status line. That client cannot parse a header, and protocol text prepended to its document is worse than no diagnosis at all.
Worked example — one helper writes every error
private void sendError(Writer out, String version, String status, String detail)
throws IOException {
String page = "<!doctype html><html><head><title>" + status + "</title></head>"
+ "<body><h1>" + status + "</h1><p>" + detail + "</p></body></html>";
byte[] bytes = page.getBytes(StandardCharsets.UTF_8);
if (version.startsWith("HTTP/")) {
out.write(String.join("\r\n",
"HTTP/1.1 " + status,
"Content-Type: text/html; charset=UTF-8",
"Content-Length: " + bytes.length,
"Connection: close",
"", ""));
}
out.write(page);
out.flush();
}
// the three call sites
if (tokens.length < 2) {
sendError(out, "", "400 Bad Request", "The request line did not parse.");
} else if (!"GET".equals(method) && !"HEAD".equals(method)) {
sendError(out, version, "501 Not Implemented", "This server handles GET and HEAD only.");
} else if (!file.startsWith(root) || !Files.isReadable(file)) {
sendError(out, version, "404 Not Found", "No document is published at that path.");
}
One helper, three call sites, and the status argument lands in three places — the status line, the <title> and the <h1> — so what the browser shows and what an automated client parses cannot drift apart. The header block assembles with the same String.join you used in lesson 1, two empty elements and all, because an error is a response like any other and deserves no second mechanism. The detail sentence is a fixed literal at each call site, never a value taken from the request: the 501 branch is not the place to echo the method the client sent, because that string goes straight into your HTML and a method of <script>… would be reflected back to whoever sent it. This page is pure ASCII, so its byte and character counts happen to agree; taking the length from bytes anyway keeps it correct the day someone writes a friendlier sentence with an apostrophe in it.
Note the version argument on the 400 branch: an empty string, because a request line you could not parse is one whose version you do not know. Empty fails startsWith("HTTP/"), so that client gets the body alone — the safe choice when you cannot tell what it speaks.
Test all four by hand with telnet: GET /nope HTTP/1.1 for the 404, POST / HTTP/1.1 for the 501, a line of gibberish for the 400, and GET /../../etc/passwd HTTP/1.1 for the containment 404. Read the status lines off the wire, not out of the browser, which will happily hide them behind its own error page.