Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/openai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
NotFoundError,
APIStatusError,
RateLimitError,
InsufficientQuotaError,
APITimeoutError,
BadRequestError,
APIConnectionError,
Expand Down Expand Up @@ -67,6 +68,7 @@
"ConflictError",
"UnprocessableEntityError",
"RateLimitError",
"InsufficientQuotaError",
"InternalServerError",
"LengthFinishReasonError",
"ContentFilterFinishReasonError",
Expand Down
12 changes: 12 additions & 0 deletions src/openai/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid retrying streamed quota errors

Fresh evidence after the retry fix: for stream=True/.with_streaming_response calls, _base_client.request() sends the request with streaming enabled, so the 429 body has not been read when this line calls response.json(); httpx raises ResponseNotRead, the broad except turns the body into None, and the code falls through to retry. Streaming chat/responses requests that receive code="insufficient_quota" will therefore still burn max_retries before raising, unlike the non-streaming path covered by the new tests.

Useful? React with 👍 / 👎.

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

Expand Down
4 changes: 4 additions & 0 deletions src/openai/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines 693 to +695

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip retries for insufficient_quota responses

With the default max_retries, _base_client calls _should_retry() and retries every 429 before _make_status_error() is reached, so this new branch only runs after the retry budget is exhausted; the async path has the same flow. For deterministic code="insufficient_quota" responses this still sends multiple doomed requests and delays surfacing the non-retryable quota error, so the quota code needs to be classified before retrying or handled in _should_retry.

Useful? React with 👍 / 👎.

return _exceptions.RateLimitError(err_msg, response=response, body=data)

if response.status_code >= 500:
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions src/openai/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"ConflictError",
"UnprocessableEntityError",
"RateLimitError",
"InsufficientQuotaError",
"InternalServerError",
"LengthFinishReasonError",
"ContentFilterFinishReasonError",
Expand Down Expand Up @@ -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

Expand Down
186 changes: 185 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down