Skip to content

Commit faa072a

Browse files
committed
fix(retry): distinguish durable quota exhaustion
Stop retrying 429 responses only when the API pairs state=exhausted with a durable daily, monthly, or trial counter window. Preserve bounded retry behavior for the recoverable hourly circuit breaker and ambiguous metadata, and cover sync/async request counts at the quota wall.
1 parent fdcd39e commit faa072a

6 files changed

Lines changed: 201 additions & 16 deletions

File tree

oilpriceapi/async_client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class AsyncOilPriceAPI:
5555
api_key: API key for authentication
5656
base_url: Base URL for API
5757
timeout: Request timeout in seconds
58-
max_retries: Maximum retry attempts
58+
max_retries: Maximum request attempts
5959
6060
Example:
6161
>>> async with AsyncOilPriceAPI() as client:
@@ -238,7 +238,7 @@ async def request(
238238
)
239239

240240
# Auto-retry with Retry-After if we have attempts left
241-
if self._retry_strategy.should_retry(attempt, 429):
241+
if self._retry_strategy.should_retry(attempt, 429, response.headers):
242242
try:
243243
wait_time = min(float(retry_after), 60.0)
244244
except (TypeError, ValueError):
@@ -249,7 +249,7 @@ async def request(
249249
await asyncio.sleep(wait_time)
250250
continue
251251
elif response.status_code >= 500:
252-
if self._retry_strategy.should_retry(attempt, response.status_code):
252+
if self._retry_strategy.should_retry(attempt, response.status_code, response.headers):
253253
wait_time = self._retry_strategy.calculate_wait_time(attempt)
254254
self._retry_strategy.log_retry(
255255
attempt,

oilpriceapi/client.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ class OilPriceAPI:
6363
api_key: API key for authentication. If not provided, uses OILPRICEAPI_KEY env var.
6464
base_url: Base URL for API. Defaults to production.
6565
timeout: Request timeout in seconds. Defaults to 30.
66-
max_retries: Maximum retry attempts for failed requests. Defaults to 3.
66+
max_retries: Maximum request attempts for failed requests. Defaults to 3.
6767
retry_on: Status codes to retry on. Defaults to [429, 500, 502, 503, 504].
6868
6969
Example:
@@ -274,7 +274,7 @@ def request(
274274
)
275275

276276
# Auto-retry with Retry-After if we have attempts left
277-
if self._retry_strategy.should_retry(attempt, 429):
277+
if self._retry_strategy.should_retry(attempt, 429, response.headers):
278278
try:
279279
wait_time = min(float(retry_after), 60.0)
280280
except (TypeError, ValueError):
@@ -285,7 +285,7 @@ def request(
285285
time.sleep(wait_time)
286286
continue
287287
elif response.status_code >= 500:
288-
if self._retry_strategy.should_retry(attempt, response.status_code):
288+
if self._retry_strategy.should_retry(attempt, response.status_code, response.headers):
289289
wait_time = self._retry_strategy.calculate_wait_time(attempt)
290290
self._retry_strategy.log_retry(
291291
attempt,
@@ -388,7 +388,7 @@ def request_with_headers(
388388
if response.status_code == 429:
389389
retry_after = response.headers.get("Retry-After")
390390

391-
if self._retry_strategy.should_retry(attempt, 429):
391+
if self._retry_strategy.should_retry(attempt, 429, response.headers):
392392
try:
393393
wait_time = min(float(retry_after), 60.0)
394394
except (TypeError, ValueError):
@@ -399,7 +399,7 @@ def request_with_headers(
399399
time.sleep(wait_time)
400400
continue
401401
elif response.status_code >= 500:
402-
if self._retry_strategy.should_retry(attempt, response.status_code):
402+
if self._retry_strategy.should_retry(attempt, response.status_code, response.headers):
403403
wait_time = self._retry_strategy.calculate_wait_time(attempt)
404404
self._retry_strategy.log_retry(
405405
attempt,

oilpriceapi/retry.py

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import logging
44
import random
5-
from typing import List, Optional
5+
from typing import List, Mapping, Optional
66

77
logger = logging.getLogger(__name__)
88

@@ -24,29 +24,84 @@ def __init__(
2424
Initialize retry strategy.
2525
2626
Args:
27-
max_retries: Maximum number of retry attempts
27+
max_retries: Maximum number of request attempts
2828
retry_on: HTTP status codes to retry on (default: [500, 502, 503, 504])
2929
jitter: Add randomized jitter to backoff to prevent thundering herd (default: True)
3030
"""
3131
self.max_retries = max_retries
3232
self.retry_on = retry_on or [500, 502, 503, 504]
3333
self.jitter = jitter
3434

35-
def should_retry(self, attempt: int, status_code: int) -> bool:
35+
# A 429 means two completely different things, and retrying is only correct
36+
# for one of them:
37+
#
38+
# "you are bursting" -> wait and retry. Correct.
39+
# "you are out of quota" -> retrying CANNOT succeed until the billing
40+
# period resets. Two retries produce two more
41+
# refusals and nothing else.
42+
#
43+
# This method used to take the status code alone, so it could not tell them
44+
# apart and always retried. Measured against production over 30 days, free
45+
# accounts on this SDK were rate-limited on 26.2% of requests against 15.6%
46+
# for the Node SDK on the same tier -- 1.7x worse, self-inflicted.
47+
#
48+
# The API identifies durable quota exhaustion with both `state=exhausted`
49+
# and a counter-backed window. State or remaining alone are ambiguous: the
50+
# recoverable hourly circuit breaker also emits exhausted/0.
51+
PERSISTENT_QUOTA_WINDOWS = frozenset({"daily_counter", "monthly_counter", "trial_counter"})
52+
53+
def should_retry(
54+
self,
55+
attempt: int,
56+
status_code: int,
57+
headers: Optional[Mapping[str, str]] = None,
58+
) -> bool:
3659
"""
3760
Determine if request should be retried.
3861
3962
Args:
4063
attempt: Current attempt number (0-indexed)
4164
status_code: HTTP status code from response
65+
headers: Response headers. When they identify a durable counter
66+
window whose allowance is exhausted, the request is not
67+
retried because waiting briefly cannot help.
4268
4369
Returns:
4470
True if request should be retried, False otherwise
4571
"""
46-
return (
47-
status_code in self.retry_on
48-
and attempt < self.max_retries - 1
49-
)
72+
if attempt >= self.max_retries - 1:
73+
return False
74+
if status_code not in self.retry_on:
75+
return False
76+
77+
# Only 429 carries a remedy. Server errors are always worth a retry.
78+
if status_code == 429 and self.quota_exhausted(headers):
79+
return False
80+
81+
return True
82+
83+
@classmethod
84+
def quota_exhausted(cls, headers: Optional[Mapping[str, str]]) -> bool:
85+
"""
86+
Has the caller run out of allowance, as opposed to merely bursting?
87+
88+
Requires `X-RateLimit-State: exhausted` together with one of the API's
89+
durable counter windows. `state=exhausted` and `remaining=0` cannot be
90+
used independently because the recoverable hourly circuit breaker
91+
deliberately emits both values as well.
92+
93+
Returns False when headers are absent or unparseable -- an unknown state
94+
must behave exactly as before this change, so a missing header can never
95+
turn a retryable burst into a hard failure.
96+
"""
97+
if not headers:
98+
return False
99+
100+
lookup = {str(k).lower(): v for k, v in headers.items()}
101+
102+
state = str(lookup.get("x-ratelimit-state", "")).strip().lower()
103+
window = str(lookup.get("x-ratelimit-window", "")).strip().lower()
104+
return state == "exhausted" and window in cls.PERSISTENT_QUOTA_WINDOWS
50105

51106
def should_retry_on_exception(self, attempt: int) -> bool:
52107
"""

tests/test_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ def test_rate_limit_error(self, mock_request):
163163
"X-RateLimit-Limit": "1000",
164164
"X-RateLimit-Remaining": "0",
165165
"X-RateLimit-Reset": "1705320000",
166+
"X-RateLimit-State": "exhausted",
167+
"X-RateLimit-Window": "monthly_counter",
166168
}
167169
mock_response.json.return_value = {"error": "Rate limit exceeded"}
168170
mock_request.return_value = mock_response
@@ -175,6 +177,7 @@ def test_rate_limit_error(self, mock_request):
175177
assert error.status_code == 429
176178
assert error.limit == "1000"
177179
assert error.remaining == "0"
180+
assert mock_request.call_count == 1
178181

179182
@patch('httpx.Client.request')
180183
def test_data_not_found_error(self, mock_request):

tests/test_retry_remedy.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Retry must distinguish "you are bursting" from "you are out of quota".
2+
3+
Measured against production over 30 days: free accounts on this SDK were
4+
rate-limited on 26.2% of requests against 15.6% for the Node SDK on the same
5+
tier. `should_retry` took the status code alone, so a quota-exhausted 429 --
6+
which cannot succeed until the billing period resets -- was tried three times,
7+
turning one refusal into three.
8+
9+
The API distinguishes the two cases with the combination of
10+
`X-RateLimit-State` and `X-RateLimit-Window` (oilpriceapi-api#5664). The state
11+
alone is ambiguous: both a durable quota wall and the recoverable hourly
12+
circuit breaker use `exhausted`.
13+
"""
14+
15+
import pytest
16+
17+
from oilpriceapi.retry import RetryStrategy
18+
19+
20+
@pytest.fixture
21+
def strategy():
22+
return RetryStrategy(max_retries=3, retry_on=[429, 500, 502, 503, 504])
23+
24+
25+
class TestQuotaExhaustedIsNotRetried:
26+
@pytest.mark.parametrize("window", ["daily_counter", "monthly_counter", "trial_counter"])
27+
def test_durable_quota_windows_stop_the_retry(self, strategy, window):
28+
headers = {
29+
"X-RateLimit-State": "exhausted",
30+
"X-RateLimit-Window": window,
31+
"X-RateLimit-Remaining": "0",
32+
}
33+
assert strategy.should_retry(0, 429, headers) is False
34+
35+
def test_header_name_is_matched_case_insensitively(self, strategy):
36+
# HTTP header names are case-insensitive and clients normalise them
37+
# differently. Matching on exact case would silently disable this.
38+
headers = {
39+
"x-ratelimit-state": "EXHAUSTED",
40+
"x-ratelimit-window": "MONTHLY_COUNTER",
41+
}
42+
assert strategy.should_retry(0, 429, headers) is False
43+
44+
45+
class TestBurstingIsStillRetried:
46+
def test_hourly_circuit_breaker_preserves_retry_behavior(self, strategy):
47+
# The API deliberately emits `state=exhausted` for this recoverable
48+
# safety limit. Looking at state or remaining alone would suppress the
49+
# existing bounded retry path even though this is not a durable quota.
50+
headers = {
51+
"X-RateLimit-State": "exhausted",
52+
"X-RateLimit-Window": "hourly_circuit_breaker",
53+
"X-RateLimit-Remaining": "0",
54+
"Retry-After": "1050",
55+
}
56+
assert strategy.should_retry(0, 429, headers) is True
57+
58+
@pytest.mark.parametrize(
59+
"headers",
60+
[
61+
{"X-RateLimit-State": "exhausted"},
62+
{"X-RateLimit-Remaining": "0"},
63+
{
64+
"X-RateLimit-State": "unavailable",
65+
"X-RateLimit-Window": "enforcement_check",
66+
},
67+
{
68+
"X-RateLimit-State": "exhausted",
69+
"X-RateLimit-Window": "future_counter_contract",
70+
},
71+
],
72+
)
73+
def test_ambiguous_or_recoverable_metadata_fails_open(self, strategy, headers):
74+
assert strategy.should_retry(0, 429, headers) is True
75+
76+
def test_retries_when_headers_are_absent(self, strategy):
77+
# The critical safety property. An unknown state must behave exactly as
78+
# it did before this change, so a missing header can never convert a
79+
# retryable burst into a hard failure.
80+
assert strategy.should_retry(0, 429, None) is True
81+
assert strategy.should_retry(0, 429, {}) is True
82+
83+
def test_server_errors_retry_regardless_of_rate_limit_headers(self, strategy):
84+
# A 500 carries no remedy. Exhausted allowance must not suppress it.
85+
headers = {"X-RateLimit-State": "exhausted"}
86+
for code in (500, 502, 503, 504):
87+
assert strategy.should_retry(0, code, headers) is True
88+
89+
90+
class TestExistingBehaviourUnchanged:
91+
def test_attempt_budget_still_respected(self, strategy):
92+
assert strategy.should_retry(2, 429, None) is False
93+
94+
def test_non_retryable_status_still_not_retried(self, strategy):
95+
# 402 must never be retried: it is a payment problem, not a timing one.
96+
assert strategy.should_retry(0, 402, None) is False
97+
assert strategy.should_retry(0, 404, None) is False
98+
99+
def test_two_argument_calls_still_work(self, strategy):
100+
# `headers` is optional so third-party callers of this public method do
101+
# not break on upgrade.
102+
assert strategy.should_retry(0, 500) is True
103+
assert strategy.should_retry(0, 404) is False
104+
105+
106+
class TestTheProductionScenario:
107+
def test_a_free_account_out_of_quota_makes_exactly_one_request(self, strategy):
108+
"""The defect, stated as a test.
109+
110+
A free account that has spent its 200 daily requests previously issued
111+
1 request + 2 retries = 3 refusals per call. It must now issue 1.
112+
"""
113+
headers = {
114+
"X-RateLimit-Limit": "200",
115+
"X-RateLimit-Remaining": "0",
116+
"X-RateLimit-State": "exhausted",
117+
"X-RateLimit-Window": "daily_counter",
118+
}
119+
attempts = sum(
120+
1
121+
for attempt in range(strategy.max_retries)
122+
if strategy.should_retry(attempt, 429, headers)
123+
)
124+
assert attempts == 0, "a quota-exhausted 429 must not be retried at all"

tests/unit/test_async_client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,8 @@ async def test_rate_limit_error(self, mock_request, api_key):
240240
"X-RateLimit-Limit": "1000",
241241
"X-RateLimit-Remaining": "0",
242242
"X-RateLimit-Reset": "1705320000",
243+
"X-RateLimit-State": "exhausted",
244+
"X-RateLimit-Window": "monthly_counter",
243245
}
244246
mock_response.json = Mock(return_value={"error": "Rate limit exceeded"})
245247
mock_request.return_value = mock_response
@@ -249,6 +251,7 @@ async def test_rate_limit_error(self, mock_request, api_key):
249251
await client.prices.get("BRENT_CRUDE_USD")
250252

251253
assert exc_info.value.status_code == 429
254+
assert mock_request.call_count == 1
252255

253256
@pytest.mark.asyncio
254257
@patch('httpx.AsyncClient.request')
@@ -356,4 +359,4 @@ async def make_response(is_historical):
356359

357360
assert isinstance(current1, Price)
358361
assert isinstance(history, HistoricalResponse)
359-
assert isinstance(current2, Price)
362+
assert isinstance(current2, Price)

0 commit comments

Comments
 (0)