From 8e53f3da598216d47be2c852d5099407da040271 Mon Sep 17 00:00:00 2001 From: Azmi Muwahid Date: Wed, 21 Oct 2026 20:37:59 +0700 Subject: [PATCH] fix(custom_httpx): don't resend request on connection error when retries are disabled AsyncHTTPHandler.post/put/patch/delete retried once on a fresh client whenever the first send raised ConnectError/RemoteProtocolError. That resend sits beneath the user-facing retry budget, so max_retries=0 still sent the request twice, which can double-bill non-idempotent requests. Gate the new-session resend on the configured retry budget: when retries are disabled (litellm.num_retries == 0) the original error propagates and the request is sent at most once. Default behavior is unchanged. Fixes #47824 --- litellm/llms/custom_httpx/http_handler.py | 12 ++++++++ .../llms/custom_httpx/test_http_handler.py | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index cffeb44f12c..aa2a7f7c24d 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -278,6 +278,10 @@ def _prepare_request_data_and_content( return request_data, request_content +def _connection_error_resend_allowed() -> bool: + return litellm.num_retries is None or litellm.num_retries > 0 + + # Cache for SSL contexts to avoid creating duplicate contexts with the same configuration # Key: tuple of (cafile, ssl_security_level, ssl_ecdh_curve) # Value: ssl.SSLContext @@ -853,6 +857,8 @@ class AsyncHTTPHandler: response.raise_for_status() return response except httpx.ConnectError: + if not _connection_error_resend_allowed(): + raise # Retry the request with a new session if there is a connection error new_client: Final = self.create_client(timeout=timeout, event_hooks=self.event_hooks) try: @@ -923,6 +929,8 @@ class AsyncHTTPHandler: response.raise_for_status() return response except (httpx.RemoteProtocolError, httpx.ConnectError): + if not _connection_error_resend_allowed(): + raise # Retry the request with a new session if there is a connection error new_client: Final = self.create_client(timeout=timeout, event_hooks=self.event_hooks) try: @@ -995,6 +1003,8 @@ class AsyncHTTPHandler: response.raise_for_status() return response except httpx.ConnectError: + if not _connection_error_resend_allowed(): + raise # Retry the request with a new session if there is a connection error new_client: Final = self.create_client(timeout=timeout, event_hooks=self.event_hooks) try: @@ -1063,6 +1073,8 @@ class AsyncHTTPHandler: response.raise_for_status() return response except (httpx.RemoteProtocolError, httpx.ConnectError): + if not _connection_error_resend_allowed(): + raise # Retry the request with a new session if there is a connection error new_client: Final = self.create_client(timeout=timeout, event_hooks=self.event_hooks) try: diff --git a/tests/unit/llms/custom_httpx/test_http_handler.py b/tests/unit/llms/custom_httpx/test_http_handler.py index bd4ed4c99cc..bf3a2675b96 100644 --- a/tests/unit/llms/custom_httpx/test_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_http_handler.py @@ -1546,6 +1546,34 @@ async def test_idempotent_disconnect_is_replayed(method: str) -> None: await handler.close() +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["post", "put", "patch", "delete"]) +async def test_connection_error_not_resent_when_retries_disabled(method: str, monkeypatch) -> None: + first_attempts: Final[list[httpx.Request]] = [] # mutable-ok: async closure capture buffer + resent: Final[list[httpx.Request]] = [] # mutable-ok: async closure capture buffer + + async def raise_connection_error(request: httpx.Request) -> httpx.Response: + first_attempts.append(request) + raise httpx.ConnectError("connection reset after the request was written", request=request) + + async def capture_resend(request: httpx.Request) -> httpx.Response: + resent.append(request) + return httpx.Response(200, request=request) + + first: Final = httpx.AsyncClient(transport=httpx.MockTransport(raise_connection_error)) + retry: Final = httpx.AsyncClient(transport=httpx.MockTransport(capture_resend)) + monkeypatch.setattr(litellm, "num_retries", 0) + async with first, retry: + handler: Final = _RetryClientHandler(first=first, retry=retry) + + with pytest.raises(httpx.ConnectError): + await getattr(handler, method)("https://api.example.com/runs/batch", content=b'{"a":1}') + + assert len(first_attempts) == 1 + assert resent == [] + await handler.close() + + @pytest.fixture def forward_proxy_server(): """Plain HTTP forward proxy that records the absolute URIs it is asked to fetch."""