0%

Chapter 5 · practice

Request Bodies and a JSON API

Read Exactly the Request Body

Every request your reading has handled ended at the blank line after its headers. A request that adds or changes a book has to carry the book, and it carries it the way a response carries a body: after the blank line, with Content-Length saying how many bytes follow. In Chapter 1 your client read a response body by that number; now your server reads a request body the same way.

Open http_adapter.py and find the respond you read in the first lesson of Chapter 4:

def respond(self):
    request: dict = request_record(self)
    if read_body is not None:
        error = read_body(request, self.rfile.read)
        if error is not None:
            write_response(self, error)
            return
    write_response(self, handle(request, state))

When make_handler_class is given a read_body , the adapter calls it before handle, with the request and self.rfile.read, the function that reads the bytes after the head from the connection. Your make_server passes no read_body yet, so every request reaches handle with "body": b"", whatever the client sent. This lesson writes read_body and passes it in.

What read(n) does

read(n) returns bytes from the connection, starting right after the head that http.server has already read. It returns exactly n bytes unless the client closes its sending side first; then it returns whatever arrived, possibly b"". Inside, it is the read_exact from Chapter 1, written for you. So after body = read(length), the check len(body) < length means the client stopped sending before the body was complete.

A client can also send fewer bytes and then keep the connection open, waiting for an answer that never comes. The adapter gives every connection a 2-second timeout, so after two seconds read raises TimeoutError, http.server writes Request timed out to its log, and the client gets no reply at all. Your function never sees that case.

Always pass the number. read() with no number reads until the client closes its sending side, and a client that has sent its request and is waiting for the reply never does.

Three refusals

Before reading a single byte, read_body decides from the headers whether there is a body and how big it is. request["headers"] holds lowercase names, each with a list of values, so the length is under "content-length".

No length. A request with no length header has no body, so a GET, HEAD, or DELETE without one is fine: return None and leave request["body"] as b"". POST and PUT are the requests this server expects to carry a book, and on those it insists on the length: a missing one gets 411 Length Required. The rule is simple: every POST and PUT to this server carries Content-Length, and a request with nothing to send says so with content-length: 0.

A malformed length. The must be exactly one value made of ASCII digits. Two values are ambiguous, and 12a or -5 is not a number of bytes; both get 400 Bad Request. Check digits with isascii() and isdigit(), as book_id_from does, then convert with int, which accepts leading zeros. isdigit() alone also accepts digits from other scripts and symbols such as the superscript ², which int() refuses; ² can arrive in a header, because http.server decodes as Latin-1.

Too long. MAX_BODY is 10000, and read_body takes the limit as its limit , which defaults to it. Compare the announced length with limit before calling read, and answer 413 Content Too Large when it is over. The notice board in Networks with Python checked a frame’s announced length the same way, for the same reason: a server that reads first has already waited for, and held, whatever the client chose to send.

Why compare the length with limit before calling read?

What does this print?

print(int("0012"), "12a".isdigit(), "١٢".isdigit(), "١٢".isascii())

Then read. If read returns fewer bytes than the length, the body ended early: 400 again. So the checks go in this order: is a length there, is it well formed, is it within the limit, did the whole body arrive?

None or the error

read_body has two kinds of result. When the body is fine, it stores the bytes in request["body"] and returns None, and the adapter goes on to call handle. When it is not, it returns a complete response dict, and the adapter sends that instead of calling handle. Build each refusal with json_error, so it is with a byte-counted content-length like every other reply. The functions you write in Lessons 2 and 5 work the same way: None, or the response to send.

One consequence: the adapter checks the framing before handle sees the request, so a POST /nowhere without a length gets 411, not 404. SERVICE_CONTRACT.md says so too.

A client can also send a body with Transfer-Encoding: chunked, in pieces that each carry their own size, and no Content-Length at all. An /1.1 server must be able to read that framing (RFC 9112, section 7.1). Yours answers in HTTP/1.0 and does not read it: such a POST has no length, so it gets 411.

Your task

Write read_body(request, read, *, limit=MAX_BODY) in reading_server.py with the four outcomes above, then change make_server to pass it to the adapter: http_adapter.make_handler_class(handle, state, read_body=read_body).

You can try the function in the Shell tab without a server. io.BytesIO holds bytes in memory, and its read(n) behaves like the connection’s:

python - <<'EOF'
import io
import reading_server

request = {"method": "POST", "target": "/books", "version": "HTTP/1.1", "headers": {"content-length": ["5"]}, "body": b""}
print(reading_server.read_body(request, io.BytesIO(b"hello, and more").read))
print(request["body"])
EOF

It prints None and then b'hello': five bytes, and nothing after them. Then run the helper:

python pl_http.py body

It sends four POST /books requests. Before your change, all four get HTTP/1.0 405 Method Not Allowed from handle. After it, the first, with a complete JSON body, still gets 405, because handle accepts no POST until Lesson 4, but the adapter has read its body first. The second has no length: HTTP/1.0 411 Length Required. The third announces 5000000 bytes and sends none: HTTP/1.0 413 Content Too Large, with nothing read. The fourth announces 40 bytes, sends 10, and closes its sending side: HTTP/1.0 400 Bad Request.

A POST /books arrives with no Content-Length. Which status?

Then press Check my work. Next, the server learns to ask what the bytes it read are supposed to be.

Task

Read request bodies by their length, and refuse the ones this server cannot frame.

In reading_server.py, write read_body(request, read, *, limit=MAX_BODY). Check, in this order:

  1. No "content-length" in request["headers"]: return a 411 response for a POST or PUT, and None for any other , leaving request["body"] as b"".

  2. Not exactly one , or a value that is not ASCII digits: return a 400 response.

  3. A length greater than limit: return a 413 response, without calling read.

  4. Call read(length) with the length. If it returns fewer bytes, return a 400 response. Otherwise store the bytes in request["body"] and return None.

Build every error reply with json_error and a sentence of your own. Then change make_server to pass the to the adapter: http_adapter.make_handler_class(handle, state, read_body=read_body).

In the , python pl_http.py body sends four POST requests to your server and shows what came back.

Then press Check my work. It calls read_body with request and a stand-in read it can watch, using bodies, lengths, and limits you have not seen: complete bodies, content-length: 0, missing and malformed lengths, lengths just over and exactly at the limit, and a body that ends early. It checks that read gets the length and is never called before a 411, 400, or 413. Then it builds your server with make_server without opening a and passes two requests through the handler class, one with a body and one without a length, to see that the body reaches handle and that the 411 goes out instead of calling it. Nothing from your terminal is graded.