Ran into this while measuring timeouts for a blog post, clientwright 0.2.0, httpx 0.28.1, Python 3.13.
config = ClientConfig(
service_name="lab",
timeout=TimeoutConfig(total=1.0, attempt=0.4),
retry=RetryConfig(max_attempts=3, initial_backoff=0.01),
)
client = build("httpx", config)
await client.get(url) # server accepts and never answers
I expected the first attempt to be cut at 0.4 s, retried, and the whole thing to end at 1.0 s with an HttpxDeadlineExceededError (or a retry-worthy timeout kind after the third attempt). What happens: after 0.4 s a bare asyncio.TimeoutError escapes client.get, one request reached the server, nothing was retried, and the except httpx.TimeoutException I had around the call did not catch it.
The cause is in core/engine/aio.py, _attempts: the except TimeoutError after asyncio.timeout(timeouts.attempt) classifies the outcome as FailureKind.TOTAL_TIMEOUT. That kind is not in DEFAULT_RETRYABLE_KINDS, so the retry policy declines, and DeadlineExceededError is only raised when deadline.expired is true, which it is not yet. So run() re-raises outcome.exception as is, and that is the raw stdlib TimeoutError, not translated through the adapter's error family. Same code on master.
I think an attempt ceiling that fires before the total should be its own kind (attempt_timeout, retryable by default like read_timeout), and when it is the final outcome it should leave through the adapter translator the way every other CallError does. The total_timeout classification would then be reserved for the case where deadline.expired is true.
Full repro:
import asyncio, time
import httpx
from clientwright import ClientConfig, RetryConfig, TimeoutConfig, build
async def hang(reader, writer):
await reader.readuntil(b"\r\n\r\n")
await asyncio.sleep(30)
async def main():
server = await asyncio.start_server(hang, "127.0.0.1", 0)
url = f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}/"
config = ClientConfig(
service_name="lab",
timeout=TimeoutConfig(total=1.0, attempt=0.4),
retry=RetryConfig(max_attempts=3, initial_backoff=0.01),
)
client = build("httpx", config)
started = time.perf_counter()
try:
await client.get(url)
except httpx.TimeoutException as e:
print("httpx family:", type(e).__name__, f"{time.perf_counter() - started:.2f}s")
except Exception as e:
print("escaped:", type(e).__module__, type(e).__name__, f"{time.perf_counter() - started:.2f}s")
await client.aclose()
asyncio.run(main())
prints escaped: builtins TimeoutError 0.40s.
The same run is the attempt=0.4 case behind 02_retry_multiplies.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. Related: #25.
Ran into this while measuring timeouts for a blog post, clientwright 0.2.0, httpx 0.28.1, Python 3.13.
I expected the first attempt to be cut at 0.4 s, retried, and the whole thing to end at 1.0 s with an
HttpxDeadlineExceededError(or a retry-worthy timeout kind after the third attempt). What happens: after 0.4 s a bareasyncio.TimeoutErrorescapesclient.get, one request reached the server, nothing was retried, and theexcept httpx.TimeoutExceptionI had around the call did not catch it.The cause is in
core/engine/aio.py,_attempts: theexcept TimeoutErrorafterasyncio.timeout(timeouts.attempt)classifies the outcome asFailureKind.TOTAL_TIMEOUT. That kind is not inDEFAULT_RETRYABLE_KINDS, so the retry policy declines, andDeadlineExceededErroris only raised whendeadline.expiredis true, which it is not yet. Sorun()re-raisesoutcome.exceptionas is, and that is the raw stdlibTimeoutError, not translated through the adapter's error family. Same code on master.I think an attempt ceiling that fires before the total should be its own kind (
attempt_timeout, retryable by default likeread_timeout), and when it is the final outcome it should leave through the adapter translator the way every otherCallErrordoes. Thetotal_timeoutclassification would then be reserved for the case wheredeadline.expiredis true.Full repro:
prints
escaped: builtins TimeoutError 0.40s.The same run is the
attempt=0.4case behind02_retry_multiplies.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. Related: #25.