Skip to content

total deadline stops at the response headers on httpx, a dripping body escapes it #25

Description

@AlexeyShalaev

Same measuring session as #24, clientwright 0.2.0, httpx 0.28.1, Python 3.13.

A server that sends the status line and headers at once and then one body byte every 0.5 s, eight times:

async def drip(reader, writer):
    await reader.readuntil(b"\r\n\r\n")
    writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\n")
    for _ in range(8):
        await asyncio.sleep(0.5)
        writer.write(b"x")
        await writer.drain()
    writer.close()
config = ClientConfig(service_name="lab", timeout=TimeoutConfig(total=1.0), retry=None, on_unsupported="strict")
client = build("httpx", config)
response = await client.get(url)   # 200, 8 bytes, 4.01 s

The total is 1.0 s and the call returns after 4.01 s with a full body. Bare httpx with timeout=1.0 does the same (its read timeout resets on every chunk), so the total buys nothing here. Against a server that stalls for 4 s before the headers the same config raises HttpxDeadlineExceededError at 1.00 s, as expected.

I know this is by design today: the engine sits in the transport, handle_async_request returns at the headers, and the httpx capability record says boundary=headers. But TimeoutConfig.total is documented as "wall clock for the whole logical call", and a wall clock that stops at the headers is the one thing a caller cannot tell from the outside. asyncio.timeout() around the call is the only way to actually bound the call, which defeats the point of having total.

The place to fix it already exists. run() installs AsyncTimedStreamMixin over the response stream for the body-duration metric, so the body read is already wrapped by clientwright code with a clock in hand. If that wrapper also knew the call's deadline it could bound each chunk to the remaining total and raise the adapter's DeadlineExceededError (kind total_timeout, so the metrics say what happened) instead of only timing it. Then the async httpx family could declare DurationBoundary.FULL, and the read phase clamp would stop being the only thing standing between a slow body and an unbounded call. The sync side cannot cancel a blocked read, so I'd leave it at headers, declared, the way deadline_hard already is.

Repro: 01_read_timeout.py in https://github.com/bedrock-python/bedrock-python.github.io/tree/docs/production-python-series/docs/blog/lab/2026-09-06-timeouts-are-not-deadlines (the clientwright total=1.0, body drips line).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions