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).
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:
The total is 1.0 s and the call returns after 4.01 s with a full body. Bare httpx with
timeout=1.0does 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 raisesHttpxDeadlineExceededErrorat 1.00 s, as expected.I know this is by design today: the engine sits in the transport,
handle_async_requestreturns at the headers, and the httpx capability record saysboundary=headers. ButTimeoutConfig.totalis 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 havingtotal.The place to fix it already exists.
run()installsAsyncTimedStreamMixinover 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'sDeadlineExceededError(kindtotal_timeout, so the metrics say what happened) instead of only timing it. Then the async httpx family could declareDurationBoundary.FULL, and thereadphase 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 atheaders, declared, the waydeadline_hardalready is.Repro:
01_read_timeout.pyin https://github.com/bedrock-python/bedrock-python.github.io/tree/docs/production-python-series/docs/blog/lab/2026-09-06-timeouts-are-not-deadlines (theclientwright total=1.0, body dripsline).