Skip to content

attempt timeout escapes as a bare TimeoutError and is never retried #24

Description

@AlexeyShalaev

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.

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