From 52671f448f32d4ebb11a3708c5d2525e22340a97 Mon Sep 17 00:00:00 2001 From: Brent Date: Fri, 10 Jul 2026 18:10:30 -0400 Subject: [PATCH] SMOODEV-2513: Add default-off connect timeout to Python fetch SDK Parity with Rust fetch#88. Adds optional connect_timeout_ms to TimeoutOptions. When set, only the connect phase is capped (via httpx.Timeout(whole, connect=...)) so black-holed connects fail fast instead of stalling until the whole-request timeout; the remaining phases keep timeout_ms. Default None = byte-identical current behavior. Co-Authored-By: Claude Opus 4.8 --- python/src/smooai_fetch/_timeout.py | 4 +++- python/src/smooai_fetch/_types.py | 9 +++++++++ python/tests/test_timeout.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) 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)" + )