diff --git a/src/openai/__init__.py b/src/openai/__init__.py index a3f3237c38..8edf87182e 100644 --- a/src/openai/__init__.py +++ b/src/openai/__init__.py @@ -22,6 +22,7 @@ NotFoundError, APIStatusError, RateLimitError, + InsufficientQuotaError, APITimeoutError, BadRequestError, APIConnectionError, @@ -67,6 +68,7 @@ "ConflictError", "UnprocessableEntityError", "RateLimitError", + "InsufficientQuotaError", "InternalServerError", "LengthFinishReasonError", "ContentFilterFinishReasonError", diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 216b36aabd..63633feb52 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -816,6 +816,18 @@ def _should_retry(self, response: httpx.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/src/openai/_client.py b/src/openai/_client.py index 66d03b23dd..5e5317de06 100644 --- a/src/openai/_client.py +++ b/src/openai/_client.py @@ -691,6 +691,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: @@ -1297,6 +1299,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 86f44b0e15..ec8cf89a3d 100644 --- a/src/openai/_exceptions.py +++ b/src/openai/_exceptions.py @@ -23,6 +23,7 @@ "ConflictError", "UnprocessableEntityError", "RateLimitError", + "InsufficientQuotaError", "InternalServerError", "LengthFinishReasonError", "ContentFilterFinishReasonError", @@ -159,6 +160,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 2d8955a58e..5d8b7bf40c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -26,7 +26,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, @@ -1385,6 +1385,98 @@ 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 + + @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.respx(base_url=base_url) @@ -2654,6 +2746,98 @@ 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 + + @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.respx()