From 2a8433e254d7da673423a618cba59bf8bc29236e Mon Sep 17 00:00:00 2001 From: pctablet505 Date: Wed, 15 Jul 2026 17:04:34 +0000 Subject: [PATCH 1/4] feat: add InsufficientQuotaError for insufficient_quota 429 responses Adds InsufficientQuotaError as a subclass of RateLimitError and maps 429 responses with code='insufficient_quota' to it so callers can distinguish quota exhaustion from retryable rate limits. Fixes #1671 --- src/openai/__init__.py | 2 + src/openai/_client.py | 4 ++ src/openai/_exceptions.py | 5 ++ tests/test_client.py | 126 +++++++++++++++++++++++++++++++++++++- 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/src/openai/__init__.py b/src/openai/__init__.py index 9b0b7badcc..ee317952b2 100644 --- a/src/openai/__init__.py +++ b/src/openai/__init__.py @@ -23,6 +23,7 @@ NotFoundError, APIStatusError, RateLimitError, + InsufficientQuotaError, APITimeoutError, BadRequestError, APIConnectionError, @@ -69,6 +70,7 @@ "ConflictError", "UnprocessableEntityError", "RateLimitError", + "InsufficientQuotaError", "InternalServerError", "LengthFinishReasonError", "ContentFilterFinishReasonError", diff --git a/src/openai/_client.py b/src/openai/_client.py index 9cf48b5d28..0e3864efe9 100644 --- a/src/openai/_client.py +++ b/src/openai/_client.py @@ -792,6 +792,8 @@ def _make_status_error( return _exceptions.UnprocessableEntityError(err_msg, response=response, body=data) if response.status_code == 429: + if is_mapping(data) and data.get("code") == "insufficient_quota": + return _exceptions.InsufficientQuotaError(err_msg, response=response, body=data) return _exceptions.RateLimitError(err_msg, response=response, body=data) if response.status_code >= 500: @@ -1488,6 +1490,8 @@ def _make_status_error( return _exceptions.UnprocessableEntityError(err_msg, response=response, body=data) if response.status_code == 429: + if is_mapping(data) and data.get("code") == "insufficient_quota": + return _exceptions.InsufficientQuotaError(err_msg, response=response, body=data) return _exceptions.RateLimitError(err_msg, response=response, body=data) if response.status_code >= 500: diff --git a/src/openai/_exceptions.py b/src/openai/_exceptions.py index 7a30e4a336..5cb1457132 100644 --- a/src/openai/_exceptions.py +++ b/src/openai/_exceptions.py @@ -21,6 +21,7 @@ "ConflictError", "UnprocessableEntityError", "RateLimitError", + "InsufficientQuotaError", "InternalServerError", "LengthFinishReasonError", "ContentFilterFinishReasonError", @@ -157,6 +158,10 @@ class RateLimitError(APIStatusError): status_code: Literal[429] = 429 # pyright: ignore[reportIncompatibleVariableOverride] +class InsufficientQuotaError(RateLimitError): + status_code: Literal[429] = 429 # pyright: ignore[reportIncompatibleVariableOverride] + + class InternalServerError(APIStatusError): pass diff --git a/tests/test_client.py b/tests/test_client.py index d82c39e616..c2033eca1f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,7 +23,7 @@ from openai._utils import asyncify from openai._models import BaseModel, FinalRequestOptions from openai._streaming import Stream, AsyncStream -from openai._exceptions import APIStatusError, APITimeoutError, APIResponseValidationError +from openai._exceptions import APIStatusError, APITimeoutError, APIResponseValidationError, InsufficientQuotaError, RateLimitError from openai._base_client import ( DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, @@ -1435,6 +1435,68 @@ def test_copy_auth(self) -> None: client._refresh_api_key() assert client.auth_headers == {"Authorization": "Bearer test_bearer_token_2"} + @pytest.mark.respx() + def test_429_insufficient_quota(self, respx_mock: MockRouter) -> None: + respx_mock.post(base_url + "/chat/completions").mock( + return_value=httpx.Response( + 429, + json={ + "error": { + "message": "You exceeded your current quota.", + "type": "insufficient_quota", + "code": "insufficient_quota", + } + }, + ) + ) + + with OpenAI( + base_url=base_url, + api_key="test-api-key", + max_retries=0, + _strict_response_validation=True, + ) as client: + with pytest.raises(InsufficientQuotaError) as exc_info: + client.chat.completions.create(messages=[], model="gpt-4") + + assert exc_info.value.status_code == 429 + assert exc_info.value.code == "insufficient_quota" + assert isinstance(exc_info.value, RateLimitError) + + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == 1 + + @pytest.mark.respx() + def test_429_rate_limit_without_insufficient_quota(self, respx_mock: MockRouter) -> None: + respx_mock.post(base_url + "/chat/completions").mock( + return_value=httpx.Response( + 429, + json={ + "error": { + "message": "Rate limit reached.", + "type": "rate_limit_error", + "code": "rate_limit_exceeded", + } + }, + ) + ) + + with OpenAI( + base_url=base_url, + api_key="test-api-key", + max_retries=0, + _strict_response_validation=True, + ) as client: + with pytest.raises(RateLimitError) as exc_info: + client.chat.completions.create(messages=[], model="gpt-4") + + assert exc_info.value.status_code == 429 + assert exc_info.value.code == "rate_limit_exceeded" + assert not isinstance(exc_info.value, InsufficientQuotaError) + + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == 1 + class TestAsyncOpenAI: @pytest.mark.respx2(base_url=base_url) @@ -2743,6 +2805,68 @@ async def token_provider_2() -> str: await client._refresh_api_key() assert client.auth_headers == {"Authorization": "Bearer test_bearer_token_2"} + @pytest.mark.respx() + async def test_429_insufficient_quota(self, respx_mock: MockRouter) -> None: + respx_mock.post(base_url + "/chat/completions").mock( + return_value=httpx.Response( + 429, + json={ + "error": { + "message": "You exceeded your current quota.", + "type": "insufficient_quota", + "code": "insufficient_quota", + } + }, + ) + ) + + async with AsyncOpenAI( + base_url=base_url, + api_key="test-api-key", + max_retries=0, + _strict_response_validation=True, + ) as client: + with pytest.raises(InsufficientQuotaError) as exc_info: + await client.chat.completions.create(messages=[], model="gpt-4") + + assert exc_info.value.status_code == 429 + assert exc_info.value.code == "insufficient_quota" + assert isinstance(exc_info.value, RateLimitError) + + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == 1 + + @pytest.mark.respx() + async def test_429_rate_limit_without_insufficient_quota(self, respx_mock: MockRouter) -> None: + respx_mock.post(base_url + "/chat/completions").mock( + return_value=httpx.Response( + 429, + json={ + "error": { + "message": "Rate limit reached.", + "type": "rate_limit_error", + "code": "rate_limit_exceeded", + } + }, + ) + ) + + async with AsyncOpenAI( + base_url=base_url, + api_key="test-api-key", + max_retries=0, + _strict_response_validation=True, + ) as client: + with pytest.raises(RateLimitError) as exc_info: + await client.chat.completions.create(messages=[], model="gpt-4") + + assert exc_info.value.status_code == 429 + assert exc_info.value.code == "rate_limit_exceeded" + assert not isinstance(exc_info.value, InsufficientQuotaError) + + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == 1 + class TestWorkloadIdentity401Retry: @pytest.mark.respx2() From 036eb866a830262b5269636c919513b4095ee3a3 Mon Sep 17 00:00:00 2001 From: pctablet505 Date: Sun, 19 Jul 2026 12:28:11 +0000 Subject: [PATCH 2/4] fix: don't retry insufficient_quota 429 responses _should_retry treated every 429 as retryable, so a deterministic insufficient_quota error still burned the whole retry budget before _make_status_error got a chance to raise InsufficientQuotaError, delaying the new error type behind unnecessary retries and backoff sleeps. Check the error code in _should_retry and skip retrying when it's insufficient_quota, mirroring the extraction already done in _make_status_error. --- src/openai/_base_client.py | 12 ++++++++ tests/test_client.py | 60 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index f195d04816..6bb87fece5 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -845,6 +845,18 @@ def _should_retry(self, response: httpx2.Response) -> bool: # Retry on rate limits. if response.status_code == 429: + # An `insufficient_quota` error means the account has run out of + # quota; retrying will not help, so don't burn the retry budget + # on a request that is guaranteed to fail again. + try: + body = response.json() + except Exception: + body = None + data = body.get("error", body) if is_mapping(body) else body + if is_mapping(data) and data.get("code") == "insufficient_quota": + log.debug("Not retrying as the error code is `insufficient_quota`") + return False + log.debug("Retrying due to status code %i", response.status_code) return True diff --git a/tests/test_client.py b/tests/test_client.py index c2033eca1f..244239751a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1497,6 +1497,36 @@ def test_429_rate_limit_without_insufficient_quota(self, respx_mock: MockRouter) calls = cast("list[MockRequestCall]", respx_mock.calls) assert len(calls) == 1 + @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx() + def test_429_insufficient_quota_is_not_retried(self, respx_mock: MockRouter) -> None: + respx_mock.post(base_url + "/chat/completions").mock( + return_value=httpx.Response( + 429, + json={ + "error": { + "message": "You exceeded your current quota.", + "type": "insufficient_quota", + "code": "insufficient_quota", + } + }, + ) + ) + + with OpenAI( + base_url=base_url, + api_key="test-api-key", + max_retries=3, + _strict_response_validation=True, + ) as client: + with pytest.raises(InsufficientQuotaError): + client.chat.completions.create(messages=[], model="gpt-4") + + # insufficient_quota is a deterministic failure, so retrying is pointless; + # the request should not be retried even though max_retries > 0. + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == 1 + class TestAsyncOpenAI: @pytest.mark.respx2(base_url=base_url) @@ -2867,6 +2897,36 @@ async def test_429_rate_limit_without_insufficient_quota(self, respx_mock: MockR calls = cast("list[MockRequestCall]", respx_mock.calls) assert len(calls) == 1 + @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx() + async def test_429_insufficient_quota_is_not_retried(self, respx_mock: MockRouter) -> None: + respx_mock.post(base_url + "/chat/completions").mock( + return_value=httpx.Response( + 429, + json={ + "error": { + "message": "You exceeded your current quota.", + "type": "insufficient_quota", + "code": "insufficient_quota", + } + }, + ) + ) + + async with AsyncOpenAI( + base_url=base_url, + api_key="test-api-key", + max_retries=3, + _strict_response_validation=True, + ) as client: + with pytest.raises(InsufficientQuotaError): + await client.chat.completions.create(messages=[], model="gpt-4") + + # insufficient_quota is a deterministic failure, so retrying is pointless; + # the request should not be retried even though max_retries > 0. + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == 1 + class TestWorkloadIdentity401Retry: @pytest.mark.respx2() From 63a19519febd4b3a0a8e8b685e7ea39b47b1aeae Mon Sep 17 00:00:00 2001 From: pctablet505 Date: Tue, 4 Aug 2026 11:34:24 +0530 Subject: [PATCH 3/4] fix: read response body before retry classification for streamed 429s _should_retry() calls response.json() to check for insufficient_quota, but for stream=True requests and .with_streaming_response the body is not read automatically. httpx raises ResponseNotRead in that case, which the broad except in _should_retry swallowed, silently falling back to the normal retry path and burning the whole retry budget on a 429 that could never succeed. Read the response body in both the sync and async request loops before calling _should_retry, so the insufficient_quota check works the same way for streaming and non-streaming responses. Also run the repo's ruff import sort and formatter to fix the unsorted import blocks in __init__.py and test_client.py. --- src/openai/__init__.py | 2 +- src/openai/_base_client.py | 14 ++++++++ tests/test_client.py | 74 +++++++++++++++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/openai/__init__.py b/src/openai/__init__.py index ee317952b2..d4b3bc897a 100644 --- a/src/openai/__init__.py +++ b/src/openai/__init__.py @@ -23,13 +23,13 @@ NotFoundError, APIStatusError, RateLimitError, - InsufficientQuotaError, APITimeoutError, BadRequestError, APIConnectionError, AuthenticationError, InternalServerError, PermissionDeniedError, + InsufficientQuotaError, LengthFinishReasonError, WebSocketQueueFullError, UnprocessableEntityError, diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 6bb87fece5..6fd8eda7db 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -1134,6 +1134,13 @@ def request( except status_exceptions() as err: # thrown on 4xx and 5xx status code log.debug("Encountered an HTTP status error: %i", response.status_code) + # The retry classification below may inspect the response body (e.g. to + # detect `insufficient_quota`), so make sure it has been read first. For + # `stream=True` requests the body is not read automatically and accessing + # it before this point raises `httpx.ResponseNotRead`. + if not err.response.is_closed: + err.response.read() + if remaining_retries > 0 and self._should_retry(err.response): err.response.close() self._sleep_for_retry( @@ -1757,6 +1764,13 @@ async def request( except status_exceptions() as err: # thrown on 4xx and 5xx status code log.debug("Encountered an HTTP status error: %i", response.status_code) + # The retry classification below may inspect the response body (e.g. to + # detect `insufficient_quota`), so make sure it has been read first. For + # `stream=True` requests the body is not read automatically and accessing + # it before this point raises `httpx.ResponseNotRead`. + if not err.response.is_closed: + await err.response.aread() + if remaining_retries > 0 and self._should_retry(err.response): await err.response.aclose() await self._sleep_for_retry( diff --git a/tests/test_client.py b/tests/test_client.py index 244239751a..f9b50c7540 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,7 +23,13 @@ from openai._utils import asyncify from openai._models import BaseModel, FinalRequestOptions from openai._streaming import Stream, AsyncStream -from openai._exceptions import APIStatusError, APITimeoutError, APIResponseValidationError, InsufficientQuotaError, RateLimitError +from openai._exceptions import ( + APIStatusError, + RateLimitError, + APITimeoutError, + InsufficientQuotaError, + APIResponseValidationError, +) from openai._base_client import ( DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, @@ -1527,6 +1533,38 @@ def test_429_insufficient_quota_is_not_retried(self, respx_mock: MockRouter) -> calls = cast("list[MockRequestCall]", respx_mock.calls) assert len(calls) == 1 + @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx() + def test_429_insufficient_quota_is_not_retried_streaming(self, respx_mock: MockRouter) -> None: + respx_mock.post(base_url + "/chat/completions").mock( + return_value=httpx.Response( + 429, + json={ + "error": { + "message": "You exceeded your current quota.", + "type": "insufficient_quota", + "code": "insufficient_quota", + } + }, + ) + ) + + with OpenAI( + base_url=base_url, + api_key="test-api-key", + max_retries=3, + _strict_response_validation=True, + ) as client: + with pytest.raises(InsufficientQuotaError): + with client.chat.completions.with_streaming_response.create(messages=[], model="gpt-4") as response: + response.read() + + # The error body is only available once the stream is read, but the + # `insufficient_quota` classification must still apply and the request + # should not be retried even though max_retries > 0. + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == 1 + class TestAsyncOpenAI: @pytest.mark.respx2(base_url=base_url) @@ -2927,6 +2965,40 @@ async def test_429_insufficient_quota_is_not_retried(self, respx_mock: MockRoute calls = cast("list[MockRequestCall]", respx_mock.calls) assert len(calls) == 1 + @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx() + async def test_429_insufficient_quota_is_not_retried_streaming(self, respx_mock: MockRouter) -> None: + respx_mock.post(base_url + "/chat/completions").mock( + return_value=httpx.Response( + 429, + json={ + "error": { + "message": "You exceeded your current quota.", + "type": "insufficient_quota", + "code": "insufficient_quota", + } + }, + ) + ) + + async with AsyncOpenAI( + base_url=base_url, + api_key="test-api-key", + max_retries=3, + _strict_response_validation=True, + ) as client: + with pytest.raises(InsufficientQuotaError): + async with client.chat.completions.with_streaming_response.create( + messages=[], model="gpt-4" + ) as response: + await response.read() + + # The error body is only available once the stream is read, but the + # `insufficient_quota` classification must still apply and the request + # should not be retried even though max_retries > 0. + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == 1 + class TestWorkloadIdentity401Retry: @pytest.mark.respx2() From 1376bd624e9c4d8a44d6cccb02fc6f47e17330b4 Mon Sep 17 00:00:00 2001 From: pctablet505 Date: Tue, 4 Aug 2026 21:36:32 +0530 Subject: [PATCH 4/4] fix: don't let a failed body read block retry classification err.response.read()/aread() were called unconditionally before _should_retry(). If reading the body itself fails (e.g. a dropped connection mid-stream), that exception propagated uncaught instead of letting a plain retriable status (429/5xx) be retried on its own merits. Guard the read so a failure there falls back to status/header-based retry classification instead of leaking a raw transport error. --- src/openai/_base_client.py | 20 ++++++++--- tests/test_client.py | 70 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 6fd8eda7db..4b6902efb2 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -1137,9 +1137,15 @@ def request( # The retry classification below may inspect the response body (e.g. to # detect `insufficient_quota`), so make sure it has been read first. For # `stream=True` requests the body is not read automatically and accessing - # it before this point raises `httpx.ResponseNotRead`. + # it before this point raises `httpx.ResponseNotRead`. Reading can itself + # fail (e.g. a dropped connection mid-stream) for errors that are still + # retriable on status/headers alone, so a read failure here must not stop + # `_should_retry` from running. if not err.response.is_closed: - err.response.read() + try: + err.response.read() + except Exception: + pass if remaining_retries > 0 and self._should_retry(err.response): err.response.close() @@ -1767,9 +1773,15 @@ async def request( # The retry classification below may inspect the response body (e.g. to # detect `insufficient_quota`), so make sure it has been read first. For # `stream=True` requests the body is not read automatically and accessing - # it before this point raises `httpx.ResponseNotRead`. + # it before this point raises `httpx.ResponseNotRead`. Reading can itself + # fail (e.g. a dropped connection mid-stream) for errors that are still + # retriable on status/headers alone, so a read failure here must not stop + # `_should_retry` from running. if not err.response.is_closed: - await err.response.aread() + try: + await err.response.aread() + except Exception: + pass if remaining_retries > 0 and self._should_retry(err.response): await err.response.aclose() diff --git a/tests/test_client.py b/tests/test_client.py index f9b50c7540..1fa2951417 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1565,6 +1565,40 @@ def test_429_insufficient_quota_is_not_retried_streaming(self, respx_mock: MockR calls = cast("list[MockRequestCall]", respx_mock.calls) assert len(calls) == 1 + @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_429_still_retried_if_streamed_body_read_fails(self, respx_mock: MockRouter, client: OpenAI) -> None: + respx_mock.post("/chat/completions").mock(return_value=httpx.Response(429)) + + # respx pre-reads its own canned response while resolving each mocked call + # (a distinct object each time), then our own retry-classification code reads + # the actual response object handed back — always the 2nd .read() of each pair. + # Fail that one to simulate the connection dropping mid-stream; that must not + # stop a plain 429 from being retried on status code alone. + original_read = httpx.Response.read + read_calls = [0] + + def flaky_read(self: httpx.Response) -> bytes: + read_calls[0] += 1 + if read_calls[0] % 2 == 0: + raise httpx.ReadError("mid-stream connection reset") + return original_read(self) + + with mock.patch.object(httpx.Response, "read", flaky_read): + with pytest.raises(RateLimitError): + client.chat.completions.with_streaming_response.create( + messages=[ + { + "content": "string", + "role": "developer", + } + ], + model="gpt-5.4", + ).__enter__() + + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == client.max_retries + 1 + class TestAsyncOpenAI: @pytest.mark.respx2(base_url=base_url) @@ -2965,6 +2999,42 @@ async def test_429_insufficient_quota_is_not_retried(self, respx_mock: MockRoute calls = cast("list[MockRequestCall]", respx_mock.calls) assert len(calls) == 1 + @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_429_still_retried_if_streamed_body_read_fails( + self, respx_mock: MockRouter, async_client: AsyncOpenAI + ) -> None: + respx_mock.post("/chat/completions").mock(return_value=httpx.Response(429)) + + # respx pre-reads its own canned response while resolving each mocked call + # (a distinct object each time), then our own retry-classification code reads + # the actual response object handed back — always the 2nd .aread() of each pair. + # Fail that one to simulate the connection dropping mid-stream; that must not + # stop a plain 429 from being retried on status code alone. + original_aread = httpx.Response.aread + aread_calls = [0] + + async def flaky_aread(self: httpx.Response) -> bytes: + aread_calls[0] += 1 + if aread_calls[0] % 2 == 0: + raise httpx.ReadError("mid-stream connection reset") + return await original_aread(self) + + with mock.patch.object(httpx.Response, "aread", flaky_aread): + with pytest.raises(RateLimitError): + await async_client.chat.completions.with_streaming_response.create( + messages=[ + { + "content": "string", + "role": "developer", + } + ], + model="gpt-5.4", + ).__aenter__() + + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == async_client.max_retries + 1 + @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx() async def test_429_insufficient_quota_is_not_retried_streaming(self, respx_mock: MockRouter) -> None: