diff --git a/python/src/smooai_fetch/_timeout.py b/python/src/smooai_fetch/_timeout.py index 76b2489..35f5461 100644 --- a/python/src/smooai_fetch/_timeout.py +++ b/python/src/smooai_fetch/_timeout.py @@ -17,4 +17,6 @@ def create_timeout(options: TimeoutOptions) -> httpx.Timeout: An httpx.Timeout configured with the specified timeout in seconds. """ timeout_seconds = options.timeout_ms / 1000.0 - return httpx.Timeout(timeout_seconds) + if options.connect_timeout_ms is None: + return httpx.Timeout(timeout_seconds) + return httpx.Timeout(timeout_seconds, connect=options.connect_timeout_ms / 1000.0) diff --git a/python/src/smooai_fetch/_types.py b/python/src/smooai_fetch/_types.py index bd8e9a9..c321cac 100644 --- a/python/src/smooai_fetch/_types.py +++ b/python/src/smooai_fetch/_types.py @@ -141,6 +141,15 @@ class TimeoutOptions: timeout_ms: float = 30000 """Timeout duration in milliseconds.""" + connect_timeout_ms: float | None = None + """Optional bounded connect-phase timeout in milliseconds. + + When set, only the connect phase is capped at this value while the remaining + phases keep `timeout_ms`. Fails fast on black-holed connects (dead pod IPs + lingering in a ClusterIP's iptables) instead of stalling until the whole + timeout. Default None leaves behavior unchanged. Mirrors the Rust + `connect_timeout_ms` (SMOODEV-2513 / fetch#88).""" + @dataclass class RateLimitOptions: diff --git a/python/tests/test_timeout.py b/python/tests/test_timeout.py index c9710cc..2b29ec6 100644 --- a/python/tests/test_timeout.py +++ b/python/tests/test_timeout.py @@ -1,5 +1,7 @@ """Tests for timeout functionality.""" +import time + import httpx import pytest import respx @@ -77,3 +79,30 @@ async def test_connect_timeout(): with pytest.raises(FetchTimeoutError): await fetch(URL, options) + + +async def test_connect_timeout_fails_fast_on_black_hole(): + """A bounded connect timeout fails fast on a black-holed connect instead of + stalling until the (much larger) whole-request timeout. + + Mirrors the Rust `connect_timeout_fails_fast_on_black_hole` test + (SMOODEV-2513 / fetch#88). 10.255.255.1 is a non-routable address whose SYN + is dropped, so without a connect timeout the request hangs until the whole + timeout. With connect_timeout_ms set, it fails in ~the connect window. + """ + connect_timeout_ms = 500 + options = FetchOptions( + # Whole timeout is 10x the connect timeout — if the connect timeout is + # not honored this would take ~5s and the elapsed assertion fails. + timeout=TimeoutOptions(timeout_ms=5000, connect_timeout_ms=connect_timeout_ms), + retry=RetryOptions(attempts=0), + ) + + start = time.monotonic() + with pytest.raises(FetchTimeoutError): + await fetch("http://10.255.255.1:80/anything", options) + elapsed = time.monotonic() - start + + assert elapsed < 3.0, ( + f"connect timeout did not fire fast: elapsed {elapsed:.2f}s (connect was {connect_timeout_ms}ms)" + )