Skip to content

HTTP/1.1

HTTP/1.1 is the default protocol: zttp.Connection(zttp.SERVER) with no protocol= argument speaks it. This page covers both directions - reading messages off the wire with receive_data + next_event, and writing them back with the send API.

Reading

The read side is receive_data + next_event. Once bodies, chunked encoding, and partial data enter the picture, here's what happens.

Bodies

A body shows up as one or more Data events between the head and EndOfMessage. You concatenate them yourself, which means you decide whether to buffer the whole body or stream it.

Accumulate into a bytearray, not b"". Appending to bytes reallocates the whole buffer on every Data event (quadratic); a bytearray grows in place.

import zttp

conn = zttp.Connection(zttp.SERVER)
conn.receive_data(
    b"POST / HTTP/1.1\r\n"
    b"Content-Length: 11\r\n"
    b"\r\n"
    b"hello world"
)

body = bytearray()
while True:
    event = conn.next_event()
    if isinstance(event, zttp.Data):
        body += event.data
    elif isinstance(event, zttp.EndOfMessage):
        break
    elif event is zttp.NEED_DATA:
        break

print(bytes(body))
#> b'hello world'

Tip

Each Data event's .data is a real bytes object, copied out of the parse buffer, so it's safe to keep. You're never handed a view that the next receive_data will overwrite.

Partial data

This is the whole point of sans-IO: the parser doesn't care how the bytes are split. Feed it a trickle and it resumes mid-anything: mid-header, mid-body, mid-chunk. Half a header line isn't a complete event, so you get NEED_DATA. No error, no lost state. Just feed the rest.

conn = zttp.Connection(zttp.SERVER)

conn.receive_data(b"GET / HTTP/1.1\r\nHo")
assert conn.next_event() is zttp.NEED_DATA

conn.receive_data(b"st: example.com\r\n\r\n")
request = conn.next_event()
print(request.headers)
#> [(b'Host', b'example.com')]

Chunked transfer encoding

You don't do anything special for Transfer-Encoding: chunked. zttp decodes the chunks for you and emits the decoded body as Data events, exactly like a fixed-length body. The chunk framing on the wire is <size>\r\n<data>\r\n, ending with a 0 chunk; you never see it, you get the decoded hello world:

conn = zttp.Connection(zttp.SERVER)
conn.receive_data(
    b"POST / HTTP/1.1\r\n"
    b"Transfer-Encoding: chunked\r\n"
    b"\r\n"
    b"5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"
)

body = bytearray()
while True:
    event = conn.next_event()
    if isinstance(event, zttp.Data):
        body += event.data
    elif isinstance(event, zttp.EndOfMessage):
        break
    elif event is zttp.NEED_DATA:
        break

print(bytes(body))
#> b'hello world'

Trailers

A chunked body can carry trailer headers after the final chunk. They arrive on the EndOfMessage event:

conn = zttp.Connection(zttp.SERVER)
conn.receive_data(
    b"POST / HTTP/1.1\r\n"
    b"Transfer-Encoding: chunked\r\n"
    b"\r\n"
    b"3\r\nabc\r\n"
    b"0\r\n"
    b"X-Checksum: 900150983cd24fb0\r\n"
    b"\r\n"
)

conn.next_event()  # Request
conn.next_event()  # Data b'abc'
end = conn.next_event()
print(end.trailers)
#> [(b'X-Checksum', b'900150983cd24fb0')]

A reusable drain helper

In practice you'll want a small helper that pulls events until it needs more data. Here's one, used by the examples that follow:

import zttp


def events(conn):
    """Yield every complete event currently available."""
    while True:
        event = conn.next_event()
        if event is zttp.NEED_DATA:
            return
        yield event
        if isinstance(event, zttp.EndOfMessage):
            return

Then a request handler reads naturally:

for event in events(conn):
    match event:
        case zttp.Request(method=method, target=target):
            ...
        case zttp.Data(data=chunk):
            ...
        case zttp.EndOfMessage():
            ...

Bodyless responses (client)

Some responses have no body no matter what their headers say: the response to a HEAD request, and any 1xx / 204 / 304. A HEAD response, for instance, carries the Content-Length the GET would have had, but sends no bytes.

You don't track this. Because you sent the request through the same connection, it remembers the method and frames the response correctly on its own. The connection saw the HEAD, so it yields EndOfMessage straight after the head instead of waiting for 1234 body bytes that never come (using the events helper above):

import zttp

conn = zttp.Connection(zttp.CLIENT)
conn.send_request(b"HEAD", b"/", b"1.1", [(b"Host", b"example.com")])
conn.data_to_send()
conn.receive_data(b"HTTP/1.1 200 OK\r\nContent-Length: 1234\r\n\r\n")

print([type(e).__name__ for e in events(conn)])
#> ['Response', 'EndOfMessage']

Sending

The read side turns bytes into events. The write side does the reverse: you describe a message, and zttp gives you the bytes to put on the wire. It frames the body for you (Content-Length or chunked) and refuses to serialize anything that would corrupt the wire.

There are four building blocks, plus one call to collect the output:

  • send_request(method, target, version, headers): a request head.
  • send_response(status, headers=None): a response head.
  • send_data(data): a run of body bytes.
  • end_message(trailers=None): finish the message.
  • data_to_send(): take and clear the bytes produced so far.

These same four building blocks carry over to HTTP/2: there, you call them on a Stream instead of the connection, because one connection carries many at once.

A response

As a server, you answer a request. The head is just the status code plus the headers as a list of (name, value) byte pairs - the reason phrase is derived from the status and the version is 1.1, so you pass neither. With a Content-Length the body bytes pass straight through. end_message marks the message complete, and data_to_send hands you the serialized bytes and clears the buffer, ready for the next message:

respond.py
import zttp

conn = zttp.Connection(zttp.SERVER)

conn.send_response(
    200,
    [(b"Content-Type", b"text/plain"), (b"Content-Length", b"5")],
)
conn.send_data(b"hello")
conn.end_message()

print(conn.data_to_send())
#> b'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhello'

A request

As a client, you build a request the same way:

import zttp

conn = zttp.Connection(zttp.CLIENT)
conn.send_request(b"GET", b"/", b"1.1", [(b"Host", b"example.com")])
conn.end_message()

print(conn.data_to_send())
#> b'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n'

Chunked output

Declare Transfer-Encoding: chunked in the head, and every send_data is chunk-framed for you, so you can stream a body of unknown length. Trailers go on end_message; they're only emitted for a chunked body:

import zttp

conn = zttp.Connection(zttp.SERVER)
conn.send_response(200, [(b"Transfer-Encoding", b"chunked")])
conn.send_data(b"Wiki")
conn.send_data(b"pedia")
conn.end_message([(b"X-Checksum", b"abc")])

print(conn.data_to_send())
#> b'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWiki\r\n5\r\npedia\r\n0\r\nX-Checksum: abc\r\n\r\n'

Bodyless responses

Some responses have no body no matter what (a 204, a 304, the reply to a HEAD). You don't flag this: zttp derives it from the response status and the request method it parsed, so the framing stays correct on its own:

conn.send_response(204)
conn.end_message()
print(conn.data_to_send())
#> b'HTTP/1.1 204 No Content\r\n\r\n'

A HEAD response is the subtle case: it carries the Content-Length the GET would have, but no bytes. Because the connection remembers the request was a HEAD, send_data is refused and no body is framed, so you don't track it.

It holds you to the Content-Length

When you declare a Content-Length, zttp counts the body bytes you send against it. Sending more than you promised, or ending the message with bytes still owed, is refused: either would put a malformed message on the wire:

import zttp

conn = zttp.Connection(zttp.SERVER)
conn.send_response(200, [(b"Content-Length", b"5")])
conn.send_data(b"too long")
#> zttp.LocalProtocolError: invalid send for current connection state

For a body of unknown length, use Transfer-Encoding: chunked instead: then send_data frames each run for you and there's nothing to count.

It won't let you split the response

zttp validates everything it serializes. A \r\n smuggled into a header value, reason phrase, or target (the classic response-splitting trick) is refused:

import zttp

conn = zttp.Connection(zttp.SERVER)
conn.send_response(200, [(b"X-Evil", b"a\r\nInjected: yes")])
#> zttp.LocalProtocolError: invalid field: a header/method/target/version/reason was malformed or contained CR/LF/control bytes

Local vs Remote

Misusing the send API raises LocalProtocolError: you did something wrong. Malformed bytes from the peer raise RemoteProtocolError. See Errors.

Where to go next

  • HTTP/2: the same four building blocks, multiplexed across streams.
  • Errors: what zttp rejects, and why.
  • Architecture: how the sans-IO core fits together.