From d2c69241c0f638e678757fea8d6ca75afbc65d9c Mon Sep 17 00:00:00 2001 From: Matt Tescher Date: Thu, 30 Jul 2026 11:08:20 -0700 Subject: [PATCH 1/4] fix(client): honor long Retry-After delays --- src/openai/_base_client.py | 5 +++-- tests/test_client.py | 18 ++++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 3d7dda5afd..d5fe644513 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -2,6 +2,7 @@ import sys import json +import math import time import uuid import email @@ -796,9 +797,9 @@ def _calculate_retry_timeout( ) -> float: max_retries = options.get_max_retries(self.max_retries) - # If the API asks us to wait a certain amount of time (and it's a reasonable amount), just do what it says. + # If the API asks us to wait a certain amount of time, just do what it says. retry_after = self._parse_retry_after_header(response_headers) - if retry_after is not None and 0 < retry_after <= 60: + if retry_after is not None and math.isfinite(retry_after) and retry_after > 0: return retry_after # Also cap retry count to 1000 to avoid any potential overflows with `pow` diff --git a/tests/test_client.py b/tests/test_client.py index bdbc2ce26b..f79522f845 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1083,13 +1083,16 @@ class Model(BaseModel): [3, "0", 0.5], [3, "-10", 0.5], [3, "60", 60], - [3, "61", 0.5], + [3, "61", 61], + [3, "86400", 86400], [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], - [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5], - [3, "99999999999999999999999999999999999", 0.5], + [3, "Fri, 29 Sep 2023 16:27:38 GMT", 61], + [3, "99999999999999999999999999999999999", 1e35], + [3, "inf", 0.5], + [3, "nan", 0.5], [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], [3, "", 0.5], [2, "", 0.5 * 2.0], @@ -2339,13 +2342,16 @@ class Model(BaseModel): [3, "0", 0.5], [3, "-10", 0.5], [3, "60", 60], - [3, "61", 0.5], + [3, "61", 61], + [3, "86400", 86400], [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], - [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5], - [3, "99999999999999999999999999999999999", 0.5], + [3, "Fri, 29 Sep 2023 16:27:38 GMT", 61], + [3, "99999999999999999999999999999999999", 1e35], + [3, "inf", 0.5], + [3, "nan", 0.5], [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], [3, "", 0.5], [2, "", 0.5 * 2.0], From 6e55b90fed2b5b1dc64c05b9d802fcea2b18955b Mon Sep 17 00:00:00 2001 From: Matt Tescher Date: Thu, 30 Jul 2026 11:35:17 -0700 Subject: [PATCH 2/4] fix(client): bound Retry-After delays to one day --- src/openai/_base_client.py | 14 +++++++++-- src/openai/_constants.py | 1 + tests/test_client.py | 50 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index d5fe644513..2b88a12748 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -87,6 +87,7 @@ DEFAULT_MAX_RETRIES, INITIAL_RETRY_DELAY, RAW_RESPONSE_HEADER, + MAX_RETRY_AFTER_DELAY, OVERRIDE_CAST_TO_HEADER, DEFAULT_CONNECTION_LIMITS, ) @@ -797,9 +798,9 @@ def _calculate_retry_timeout( ) -> float: max_retries = options.get_max_retries(self.max_retries) - # If the API asks us to wait a certain amount of time, just do what it says. + # Honor server-directed delays up to one day. retry_after = self._parse_retry_after_header(response_headers) - if retry_after is not None and math.isfinite(retry_after) and retry_after > 0: + if retry_after is not None and math.isfinite(retry_after) and 0 < retry_after <= MAX_RETRY_AFTER_DELAY: return retry_after # Also cap retry count to 1000 to avoid any potential overflows with `pow` @@ -814,6 +815,15 @@ def _calculate_retry_timeout( return timeout if timeout >= 0 else 0 def _should_retry(self, response: httpx.Response) -> bool: + retry_after = self._parse_retry_after_header(response.headers) + if retry_after is not None and math.isfinite(retry_after) and retry_after > MAX_RETRY_AFTER_DELAY: + log.debug( + "Not retrying because `Retry-After` of %s seconds exceeds the maximum of %s seconds", + retry_after, + MAX_RETRY_AFTER_DELAY, + ) + return False + # Note: this is not a standard header should_retry_header = response.headers.get("x-should-retry") diff --git a/src/openai/_constants.py b/src/openai/_constants.py index 7029dc72b0..dd7f249165 100644 --- a/src/openai/_constants.py +++ b/src/openai/_constants.py @@ -12,3 +12,4 @@ INITIAL_RETRY_DELAY = 0.5 MAX_RETRY_DELAY = 8.0 +MAX_RETRY_AFTER_DELAY = 24 * 60 * 60 diff --git a/tests/test_client.py b/tests/test_client.py index f79522f845..dc39d0ff5f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1085,12 +1085,15 @@ class Model(BaseModel): [3, "60", 60], [3, "61", 61], [3, "86400", 86400], + [3, "86401", 0.5], [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], [3, "Fri, 29 Sep 2023 16:27:38 GMT", 61], - [3, "99999999999999999999999999999999999", 1e35], + [3, "Sat, 30 Sep 2023 16:26:37 GMT", 86400], + [3, "Sat, 30 Sep 2023 16:26:38 GMT", 0.5], + [3, "99999999999999999999999999999999999", 0.5], [3, "inf", 0.5], [3, "nan", 0.5], [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], @@ -1109,6 +1112,33 @@ def test_parse_retry_after_header( calculated = client._calculate_retry_timeout(remaining_retries, options, headers) assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + @pytest.mark.parametrize( + "headers,should_retry", + [ + [{"retry-after": "86400"}, True], + [{"retry-after": "86401"}, False], + [{"retry-after-ms": "86400000"}, True], + [{"retry-after-ms": "86400001"}, False], + [{"retry-after": "Sat, 30 Sep 2023 16:26:37 GMT"}, True], + [{"retry-after": "Sat, 30 Sep 2023 16:26:38 GMT"}, False], + ], + ) + @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) + def test_retry_after_max_delay(self, headers: dict[str, str], should_retry: bool, client: OpenAI) -> None: + response = httpx.Response(429, headers=headers) + assert client._should_retry(response) is should_retry + + @pytest.mark.respx(base_url=base_url) + def test_does_not_retry_retry_after_above_max(self, respx_mock: MockRouter, client: OpenAI) -> None: + route = respx_mock.get("/foo").mock( + return_value=httpx.Response(429, headers={"retry-after": "86401"}, json={"error": {}}) + ) + + with pytest.raises(APIStatusError): + client.get("/foo", cast_to=httpx.Response) + + assert route.call_count == 1 + @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: OpenAI) -> None: @@ -2344,12 +2374,15 @@ class Model(BaseModel): [3, "60", 60], [3, "61", 61], [3, "86400", 86400], + [3, "86401", 0.5], [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], [3, "Fri, 29 Sep 2023 16:27:38 GMT", 61], - [3, "99999999999999999999999999999999999", 1e35], + [3, "Sat, 30 Sep 2023 16:26:37 GMT", 86400], + [3, "Sat, 30 Sep 2023 16:26:38 GMT", 0.5], + [3, "99999999999999999999999999999999999", 0.5], [3, "inf", 0.5], [3, "nan", 0.5], [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], @@ -2368,6 +2401,19 @@ async def test_parse_retry_after_header( calculated = async_client._calculate_retry_timeout(remaining_retries, options, headers) assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + @pytest.mark.respx(base_url=base_url) + async def test_does_not_retry_retry_after_above_max( + self, respx_mock: MockRouter, async_client: AsyncOpenAI + ) -> None: + route = respx_mock.get("/foo").mock( + return_value=httpx.Response(429, headers={"retry-after": "86401"}, json={"error": {}}) + ) + + with pytest.raises(APIStatusError): + await async_client.get("/foo", cast_to=httpx.Response) + + assert route.call_count == 1 + @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None: From 8d149be8b0b2a2cd73ce991c00513455b44536c8 Mon Sep 17 00:00:00 2001 From: Matt Tescher Date: Thu, 30 Jul 2026 12:00:47 -0700 Subject: [PATCH 3/4] fix(client): ignore invalid Retry-After dates --- src/openai/_base_client.py | 10 +++++++--- tests/test_client.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 2b88a12748..354d01302e 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -783,11 +783,15 @@ def _parse_retry_after_header(self, response_headers: Optional[httpx.Headers] = pass # Last, try parsing `retry-after` as a date. - retry_date_tuple = email.utils.parsedate_tz(retry_header) - if retry_date_tuple is None: + try: + retry_date_tuple = email.utils.parsedate_tz(retry_header) + if retry_date_tuple is None: + return None + + retry_date = email.utils.mktime_tz(retry_date_tuple) + except (TypeError, ValueError, OverflowError, OSError): return None - retry_date = email.utils.mktime_tz(retry_date_tuple) return float(retry_date - time.time()) def _calculate_retry_timeout( diff --git a/tests/test_client.py b/tests/test_client.py index dc39d0ff5f..28ac9028c2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1097,6 +1097,7 @@ class Model(BaseModel): [3, "inf", 0.5], [3, "nan", 0.5], [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "Fri, 29 Sep 100000 16:26:57 GMT", 0.5], [3, "", 0.5], [2, "", 0.5 * 2.0], [1, "", 0.5 * 4.0], @@ -1139,6 +1140,23 @@ def test_does_not_retry_retry_after_above_max(self, respx_mock: MockRouter, clie assert route.call_count == 1 + @pytest.mark.respx(base_url=base_url) + def test_invalid_retry_after_date_does_not_mask_status_error( + self, respx_mock: MockRouter, client: OpenAI + ) -> None: + route = respx_mock.get("/foo").mock( + return_value=httpx.Response( + 400, + headers={"retry-after": "Fri, 29 Sep 100000 16:26:57 GMT"}, + json={"error": {}}, + ) + ) + + with pytest.raises(APIStatusError): + client.get("/foo", cast_to=httpx.Response) + + assert route.call_count == 1 + @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: OpenAI) -> None: @@ -2386,6 +2404,7 @@ class Model(BaseModel): [3, "inf", 0.5], [3, "nan", 0.5], [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "Fri, 29 Sep 100000 16:26:57 GMT", 0.5], [3, "", 0.5], [2, "", 0.5 * 2.0], [1, "", 0.5 * 4.0], @@ -2414,6 +2433,23 @@ async def test_does_not_retry_retry_after_above_max( assert route.call_count == 1 + @pytest.mark.respx(base_url=base_url) + async def test_invalid_retry_after_date_does_not_mask_status_error( + self, respx_mock: MockRouter, async_client: AsyncOpenAI + ) -> None: + route = respx_mock.get("/foo").mock( + return_value=httpx.Response( + 400, + headers={"retry-after": "Fri, 29 Sep 100000 16:26:57 GMT"}, + json={"error": {}}, + ) + ) + + with pytest.raises(APIStatusError): + await async_client.get("/foo", cast_to=httpx.Response) + + assert route.call_count == 1 + @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None: From 05b8ddf8b12555257df2b3cf7c25e5d309c10489 Mon Sep 17 00:00:00 2001 From: Matt Tescher Date: Thu, 30 Jul 2026 14:44:22 -0700 Subject: [PATCH 4/4] fix(client): cap Retry-After at two minutes --- src/openai/_base_client.py | 2 +- src/openai/_constants.py | 2 +- tests/test_client.py | 36 +++++++++++++++++------------------- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 354d01302e..10d7b9f7ca 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -802,7 +802,7 @@ def _calculate_retry_timeout( ) -> float: max_retries = options.get_max_retries(self.max_retries) - # Honor server-directed delays up to one day. + # Honor server-directed delays up to two minutes. retry_after = self._parse_retry_after_header(response_headers) if retry_after is not None and math.isfinite(retry_after) and 0 < retry_after <= MAX_RETRY_AFTER_DELAY: return retry_after diff --git a/src/openai/_constants.py b/src/openai/_constants.py index dd7f249165..fd73c6485d 100644 --- a/src/openai/_constants.py +++ b/src/openai/_constants.py @@ -12,4 +12,4 @@ INITIAL_RETRY_DELAY = 0.5 MAX_RETRY_DELAY = 8.0 -MAX_RETRY_AFTER_DELAY = 24 * 60 * 60 +MAX_RETRY_AFTER_DELAY = 2 * 60 diff --git a/tests/test_client.py b/tests/test_client.py index 28ac9028c2..33a5b1c224 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1084,15 +1084,15 @@ class Model(BaseModel): [3, "-10", 0.5], [3, "60", 60], [3, "61", 61], - [3, "86400", 86400], - [3, "86401", 0.5], + [3, "120", 120], + [3, "121", 0.5], [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], [3, "Fri, 29 Sep 2023 16:27:38 GMT", 61], - [3, "Sat, 30 Sep 2023 16:26:37 GMT", 86400], - [3, "Sat, 30 Sep 2023 16:26:38 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:28:37 GMT", 120], + [3, "Fri, 29 Sep 2023 16:28:38 GMT", 0.5], [3, "99999999999999999999999999999999999", 0.5], [3, "inf", 0.5], [3, "nan", 0.5], @@ -1116,12 +1116,12 @@ def test_parse_retry_after_header( @pytest.mark.parametrize( "headers,should_retry", [ - [{"retry-after": "86400"}, True], - [{"retry-after": "86401"}, False], - [{"retry-after-ms": "86400000"}, True], - [{"retry-after-ms": "86400001"}, False], - [{"retry-after": "Sat, 30 Sep 2023 16:26:37 GMT"}, True], - [{"retry-after": "Sat, 30 Sep 2023 16:26:38 GMT"}, False], + [{"retry-after": "120"}, True], + [{"retry-after": "121"}, False], + [{"retry-after-ms": "120000"}, True], + [{"retry-after-ms": "120001"}, False], + [{"retry-after": "Fri, 29 Sep 2023 16:28:37 GMT"}, True], + [{"retry-after": "Fri, 29 Sep 2023 16:28:38 GMT"}, False], ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) @@ -1132,7 +1132,7 @@ def test_retry_after_max_delay(self, headers: dict[str, str], should_retry: bool @pytest.mark.respx(base_url=base_url) def test_does_not_retry_retry_after_above_max(self, respx_mock: MockRouter, client: OpenAI) -> None: route = respx_mock.get("/foo").mock( - return_value=httpx.Response(429, headers={"retry-after": "86401"}, json={"error": {}}) + return_value=httpx.Response(429, headers={"retry-after": "121"}, json={"error": {}}) ) with pytest.raises(APIStatusError): @@ -1141,9 +1141,7 @@ def test_does_not_retry_retry_after_above_max(self, respx_mock: MockRouter, clie assert route.call_count == 1 @pytest.mark.respx(base_url=base_url) - def test_invalid_retry_after_date_does_not_mask_status_error( - self, respx_mock: MockRouter, client: OpenAI - ) -> None: + def test_invalid_retry_after_date_does_not_mask_status_error(self, respx_mock: MockRouter, client: OpenAI) -> None: route = respx_mock.get("/foo").mock( return_value=httpx.Response( 400, @@ -2391,15 +2389,15 @@ class Model(BaseModel): [3, "-10", 0.5], [3, "60", 60], [3, "61", 61], - [3, "86400", 86400], - [3, "86401", 0.5], + [3, "120", 120], + [3, "121", 0.5], [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], [3, "Fri, 29 Sep 2023 16:27:38 GMT", 61], - [3, "Sat, 30 Sep 2023 16:26:37 GMT", 86400], - [3, "Sat, 30 Sep 2023 16:26:38 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:28:37 GMT", 120], + [3, "Fri, 29 Sep 2023 16:28:38 GMT", 0.5], [3, "99999999999999999999999999999999999", 0.5], [3, "inf", 0.5], [3, "nan", 0.5], @@ -2425,7 +2423,7 @@ async def test_does_not_retry_retry_after_above_max( self, respx_mock: MockRouter, async_client: AsyncOpenAI ) -> None: route = respx_mock.get("/foo").mock( - return_value=httpx.Response(429, headers={"retry-after": "86401"}, json={"error": {}}) + return_value=httpx.Response(429, headers={"retry-after": "121"}, json={"error": {}}) ) with pytest.raises(APIStatusError):