From a6ec33d3d74945bcede2864a9ffa8d416acc2c83 Mon Sep 17 00:00:00 2001 From: eeshsaxena Date: Fri, 10 Jul 2026 16:28:43 +0530 Subject: [PATCH 1/5] fix: honor custom timeout on every attempt in request_with_retry The inner retried function popped `timeout` from the shared `kwargs` dict, so after the first attempt it was gone and every retry silently fell back to the default of 10 seconds. Pop `timeout` once, before the retry loop, so it is captured for all attempts. Applies to both `request_with_retry` and `async_request_with_retry`. Adds regression tests asserting the caller's timeout is used on the retry too. --- haystack/utils/requests_utils.py | 12 ++++- ...try-timeout-on-retry-2e1437ea8cbba7ad.yaml | 6 +++ test/utils/test_requests_utils.py | 51 +++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/fix-request-with-retry-timeout-on-retry-2e1437ea8cbba7ad.yaml diff --git a/haystack/utils/requests_utils.py b/haystack/utils/requests_utils.py index c5270cc055a..1fedbb0696f 100644 --- a/haystack/utils/requests_utils.py +++ b/haystack/utils/requests_utils.py @@ -58,6 +58,11 @@ def request_with_retry( if status_codes_to_retry is None: status_codes_to_retry = [408, 418, 429, 503] + # Pop `timeout` once, before the retry loop. `run` is retried, so popping inside it would + # drop `timeout` from `kwargs` after the first attempt and silently fall back to the default + # on every subsequent retry. + timeout = kwargs.pop("timeout", 10) + @retry( reraise=True, wait=wait_exponential(), @@ -67,7 +72,6 @@ def request_with_retry( after=after_log(logger, logging.DEBUG), ) def run() -> httpx.Response: - timeout = kwargs.pop("timeout", 10) with httpx.Client() as client: res = client.request(**kwargs, timeout=timeout) @@ -168,6 +172,11 @@ async def example_5xx(): if status_codes_to_retry is None: status_codes_to_retry = [408, 418, 429, 503] + # Pop `timeout` once, before the retry loop. `run` is retried, so popping inside it would + # drop `timeout` from `kwargs` after the first attempt and silently fall back to the default + # on every subsequent retry. + timeout = kwargs.pop("timeout", 10) + @retry( reraise=True, wait=wait_exponential(), @@ -177,7 +186,6 @@ async def example_5xx(): after=after_log(logger, logging.DEBUG), ) async def run() -> httpx.Response: - timeout = kwargs.pop("timeout", 10) async with httpx.AsyncClient() as client: res = await client.request(**kwargs, timeout=timeout) diff --git a/releasenotes/notes/fix-request-with-retry-timeout-on-retry-2e1437ea8cbba7ad.yaml b/releasenotes/notes/fix-request-with-retry-timeout-on-retry-2e1437ea8cbba7ad.yaml new file mode 100644 index 00000000000..ce5f4e48158 --- /dev/null +++ b/releasenotes/notes/fix-request-with-retry-timeout-on-retry-2e1437ea8cbba7ad.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + `request_with_retry` and `async_request_with_retry` now honor a custom `timeout` on every + retry attempt. Previously the `timeout` was consumed on the first attempt and any subsequent + retry silently fell back to the default of 10 seconds. diff --git a/test/utils/test_requests_utils.py b/test/utils/test_requests_utils.py index 9bf621268ae..c011ec964bf 100644 --- a/test/utils/test_requests_utils.py +++ b/test/utils/test_requests_utils.py @@ -121,6 +121,30 @@ def raise_for_status(): assert mock_request.call_count == 2 mock_sleep.assert_called() + def test_request_with_retry_custom_timeout_preserved_across_retries(self): + """A custom timeout must be honored on every attempt, not just the first one. + + Regression test: ``timeout`` used to be popped from the shared ``kwargs`` inside the + retried inner function, so after the first attempt it was gone and every subsequent + retry silently fell back to the default of 10 seconds. + """ + with patch("time.sleep", return_value=None): + success_response = httpx.Response(status_code=200, request=httpx.Request("GET", "https://example.com")) + + with patch("httpx.Client.request") as mock_request: + # First attempt fails with a retryable error, second attempt succeeds. + mock_request.side_effect = [ + httpx.RequestError("boom", request=httpx.Request("GET", "https://example.com")), + success_response, + ] + + response = request_with_retry(method="GET", url="https://example.com", attempts=2, timeout=5) + + assert response == success_response + assert mock_request.call_count == 2 + # Both the initial attempt and the retry must use the caller's timeout, not the default. + assert [call.kwargs["timeout"] for call in mock_request.call_args_list] == [5, 5] + class TestAsyncRequestWithRetry: @pytest.mark.asyncio @@ -234,3 +258,30 @@ def raise_for_status(): assert response == success_response assert mock_request.call_count == 2 mock_sleep.assert_called() + + @pytest.mark.asyncio + async def test_async_request_with_retry_custom_timeout_preserved_across_retries(self): + """A custom timeout must be honored on every attempt, not just the first one. + + Regression test: ``timeout`` used to be popped from the shared ``kwargs`` inside the + retried inner function, so after the first attempt it was gone and every subsequent + retry silently fell back to the default of 10 seconds. + """ + with patch("asyncio.sleep", return_value=None): + success_response = httpx.Response(status_code=200, request=httpx.Request("GET", "https://example.com")) + + with patch("httpx.AsyncClient.request") as mock_request: + # First attempt fails with a retryable error, second attempt succeeds. + mock_request.side_effect = [ + httpx.RequestError("boom", request=httpx.Request("GET", "https://example.com")), + success_response, + ] + + response = await async_request_with_retry( + method="GET", url="https://example.com", attempts=2, timeout=5 + ) + + assert response == success_response + assert mock_request.call_count == 2 + # Both the initial attempt and the retry must use the caller's timeout, not the default. + assert [call.kwargs["timeout"] for call in mock_request.call_args_list] == [5, 5] From 265b59a8fb92b8cef9a58254c5afb29a4d3bd9e6 Mon Sep 17 00:00:00 2001 From: eeshsaxena Date: Tue, 14 Jul 2026 10:05:47 +0530 Subject: [PATCH 2/5] docs: use double backticks in release note for reno reST check --- ...-request-with-retry-timeout-on-retry-2e1437ea8cbba7ad.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/releasenotes/notes/fix-request-with-retry-timeout-on-retry-2e1437ea8cbba7ad.yaml b/releasenotes/notes/fix-request-with-retry-timeout-on-retry-2e1437ea8cbba7ad.yaml index ce5f4e48158..04af94f40bf 100644 --- a/releasenotes/notes/fix-request-with-retry-timeout-on-retry-2e1437ea8cbba7ad.yaml +++ b/releasenotes/notes/fix-request-with-retry-timeout-on-retry-2e1437ea8cbba7ad.yaml @@ -1,6 +1,6 @@ --- fixes: - | - `request_with_retry` and `async_request_with_retry` now honor a custom `timeout` on every - retry attempt. Previously the `timeout` was consumed on the first attempt and any subsequent + ``request_with_retry`` and ``async_request_with_retry`` now honor a custom ``timeout`` on every + retry attempt. Previously the ``timeout`` was consumed on the first attempt and any subsequent retry silently fell back to the default of 10 seconds. From c1b3fe4a3cf9c75a3c296390cecdfd378f603bee Mon Sep 17 00:00:00 2001 From: Sebastian Husch Lee <10526848+sjrl@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:35:45 +0200 Subject: [PATCH 3/5] Update haystack/utils/requests_utils.py --- haystack/utils/requests_utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/haystack/utils/requests_utils.py b/haystack/utils/requests_utils.py index 1fedbb0696f..8ae1bf7af51 100644 --- a/haystack/utils/requests_utils.py +++ b/haystack/utils/requests_utils.py @@ -58,9 +58,7 @@ def request_with_retry( if status_codes_to_retry is None: status_codes_to_retry = [408, 418, 429, 503] - # Pop `timeout` once, before the retry loop. `run` is retried, so popping inside it would - # drop `timeout` from `kwargs` after the first attempt and silently fall back to the default - # on every subsequent retry. + # Pop `timeout` once, before the retry loop. timeout = kwargs.pop("timeout", 10) @retry( From 044490749c28ca12c08059c78aac9e1a1303e5b8 Mon Sep 17 00:00:00 2001 From: Sebastian Husch Lee <10526848+sjrl@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:35:54 +0200 Subject: [PATCH 4/5] Update haystack/utils/requests_utils.py --- haystack/utils/requests_utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/haystack/utils/requests_utils.py b/haystack/utils/requests_utils.py index 8ae1bf7af51..b7a4c19c824 100644 --- a/haystack/utils/requests_utils.py +++ b/haystack/utils/requests_utils.py @@ -170,9 +170,7 @@ async def example_5xx(): if status_codes_to_retry is None: status_codes_to_retry = [408, 418, 429, 503] - # Pop `timeout` once, before the retry loop. `run` is retried, so popping inside it would - # drop `timeout` from `kwargs` after the first attempt and silently fall back to the default - # on every subsequent retry. + # Pop `timeout` once, before the retry loop. timeout = kwargs.pop("timeout", 10) @retry( From 2f72ae2ee998b5cfca747660a42d33c118fd5967 Mon Sep 17 00:00:00 2001 From: Sebastian Husch Lee <10526848+sjrl@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:38:21 +0200 Subject: [PATCH 5/5] Apply suggestion from @sjrl --- haystack/utils/requests_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haystack/utils/requests_utils.py b/haystack/utils/requests_utils.py index b7a4c19c824..050228bda2f 100644 --- a/haystack/utils/requests_utils.py +++ b/haystack/utils/requests_utils.py @@ -170,7 +170,7 @@ async def example_5xx(): if status_codes_to_retry is None: status_codes_to_retry = [408, 418, 429, 503] - # Pop `timeout` once, before the retry loop. + # Pop `timeout` once, before the retry loop. timeout = kwargs.pop("timeout", 10) @retry(