Skip to content

Commit fcc4edf

Browse files
karlwaldmanclaude
andauthored
fix(retry): never replay a non-idempotent write; bound server retry signals (#104) (#115)
Three defects, all reproduced on origin/main with a mock transport. 1. Automatic retry replayed writes. A POST that timed out was sent three times, and so was a POST that got a 503: POST timeout -> attempts: 3 POST 503 -> attempts: 3 GET timeout -> attempts: 3 A timeout or transport error is an AMBIGUOUS outcome, not a failure: the server may have committed the write before the response was lost. A 5xx is equally ambiguous, because a gateway can return 502 after the origin committed. Subscription and webhook creates go through these helpers, so a lost response became two subscriptions. POST and PATCH are now sent exactly once. Idempotent methods (GET, HEAD, OPTIONS, TRACE, PUT, DELETE) retry exactly as before. A 429 still retries any method, because it is an outright refusal — the write definitively did not happen. A caller who knows a write is safe to repeat can pass idempotent=True to request(). When a write is not replayed the error says so and carries .ambiguous_write = True, so the caller knows to check whether it landed rather than blindly resending. 2. The constructor's `or` defaults discarded explicit configuration: max_retries=0 -> 3 retry_on=[] -> [429, 500, 502, 503, 504] Now explicit None checks. retry_on=[] is preserved and really does disable status-code retries. max_retries counts total ATTEMPTS — which is what the docstring always said and what `for attempt in range(self.max_retries)` does — so it must be >= 1; 0, a negative, or a non-int raises ConfigurationError naming the fix instead of silently becoming 3. 3. Retry-After was bounded above but not below: Retry-After: 31612 -> waits [60.0, 60.0] (already capped) Retry-After: -30 -> waits [-30.0, -30.0] (time.sleep raises ValueError) Now clamped to [0, 60] through one shared RetryStrategy.bounded_wait(), used by the sync client, request_with_headers() and the async client alike. The 60s cap matters: the keyless demo returns retry-after 31612, 8.8 hours. Durable-quota suppression (X-RateLimit-State: exhausted + a counter window) was already correct on main and is unchanged; a regression test pins it. RetryStrategy's public signatures are backward compatible: method/idempotent are optional, and omitting them means "unknown", which is treated as replay-safe. The SDK's own clients always pass the method. Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b7017b7 commit fcc4edf

4 files changed

Lines changed: 608 additions & 36 deletions

File tree

‎oilpriceapi/async_client.py‎

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
)
4545
from .models import HistoricalPrice, HistoricalResponse, MarketBrief, Price
4646
from .resource_validators import format_date
47-
from .retry import RetryStrategy
47+
from .retry import RetryStrategy, mark_ambiguous_write, validated_max_retries
4848

4949

5050
class AsyncOilPriceAPI:
@@ -56,7 +56,13 @@ class AsyncOilPriceAPI:
5656
api_key: API key for authentication
5757
base_url: Base URL for API
5858
timeout: Request timeout in seconds
59-
max_retries: Maximum request attempts
59+
max_retries: Total request ATTEMPTS, not retries after the first.
60+
Must be at least 1; anything less raises ConfigurationError.
61+
retry_on: Status codes to retry on. An explicit empty list is honoured.
62+
63+
Retry safety: POST and PATCH are sent exactly once -- a timeout, transport
64+
error or 5xx is ambiguous, so replaying could duplicate the write. Pass
65+
``idempotent=True`` to ``request()`` to opt back in.
6066
6167
Example:
6268
>>> async with AsyncOilPriceAPI() as client:
@@ -93,8 +99,13 @@ def __init__(
9399
# Configuration
94100
self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
95101
self.timeout = timeout or self.DEFAULT_TIMEOUT
96-
self.max_retries = max_retries or self.DEFAULT_MAX_RETRIES
97-
self.retry_on = retry_on or self.DEFAULT_RETRY_CODES
102+
# Explicit None checks, not `or` (#104) -- see OilPriceAPI.__init__.
103+
self.max_retries = (
104+
self.DEFAULT_MAX_RETRIES if max_retries is None else validated_max_retries(max_retries)
105+
)
106+
self.retry_on = (
107+
list(self.DEFAULT_RETRY_CODES) if retry_on is None else list(retry_on)
108+
)
98109
self.max_connections = max_connections
99110
self.max_keepalive_connections = max_keepalive_connections
100111
self.app_url = app_url
@@ -196,9 +207,16 @@ async def request(
196207
path: str,
197208
params: Optional[Dict[str, Any]] = None,
198209
json_data: Optional[Dict[str, Any]] = None,
210+
idempotent: Optional[bool] = None,
199211
**kwargs,
200212
) -> Union[Dict[str, Any], List[Any]]:
201-
"""Make async HTTP request to API."""
213+
"""Make async HTTP request to API.
214+
215+
Args:
216+
idempotent: Assert that repeating this request is safe. Without it,
217+
a non-idempotent method (POST, PATCH) is sent exactly once and
218+
never replayed after an ambiguous outcome (#104).
219+
"""
202220
await self._ensure_client()
203221
assert self._client is not None # set by _ensure_client
204222

@@ -239,19 +257,24 @@ async def request(
239257
)
240258

241259
# Auto-retry with Retry-After if we have attempts left
242-
if self._retry_strategy.should_retry(attempt, 429, response.headers):
243-
try:
244-
wait_time = min(float(retry_after), 60.0)
245-
except (TypeError, ValueError):
246-
wait_time = self._retry_strategy.calculate_wait_time(attempt)
260+
if self._retry_strategy.should_retry(
261+
attempt, 429, response.headers, method=method, idempotent=idempotent
262+
):
263+
wait_time = self._retry_strategy.bounded_wait(
264+
retry_after, self._retry_strategy.calculate_wait_time(attempt)
265+
)
247266
logger.info(
248267
f"Rate limited. Retrying in {wait_time}s (attempt {attempt + 1}/{self.max_retries})"
249268
)
250269
await asyncio.sleep(wait_time)
251270
continue
252271
elif response.status_code >= 500:
253272
if self._retry_strategy.should_retry(
254-
attempt, response.status_code, response.headers
273+
attempt,
274+
response.status_code,
275+
response.headers,
276+
method=method,
277+
idempotent=idempotent,
255278
):
256279
wait_time = self._retry_strategy.calculate_wait_time(attempt)
257280
self._retry_strategy.log_retry(
@@ -273,20 +296,26 @@ async def request(
273296
api_key=self.api_key,
274297
timeout=self.timeout,
275298
)
276-
if self._retry_strategy.should_retry_on_exception(attempt):
299+
if self._retry_strategy.should_retry_on_exception(
300+
attempt, method=method, idempotent=idempotent
301+
):
277302
wait_time = self._retry_strategy.calculate_wait_time(attempt)
278303
self._retry_strategy.log_retry(
279304
attempt, "Request timeout", wait_time, is_async=True
280305
)
281306
await asyncio.sleep(wait_time)
282307
continue
308+
if not self._retry_strategy.is_replay_safe(method, idempotent):
309+
raise mark_ambiguous_write(last_exception, method)
283310
raise last_exception
284311
except httpx.RequestError as error:
285312
last_exception = error_from_exception(
286313
error,
287314
api_key=self.api_key,
288315
)
289-
if self._retry_strategy.should_retry_on_exception(attempt):
316+
if self._retry_strategy.should_retry_on_exception(
317+
attempt, method=method, idempotent=idempotent
318+
):
290319
wait_time = self._retry_strategy.calculate_wait_time(attempt)
291320
self._retry_strategy.log_retry(
292321
attempt,
@@ -296,6 +325,8 @@ async def request(
296325
)
297326
await asyncio.sleep(wait_time)
298327
continue
328+
if not self._retry_strategy.is_replay_safe(method, idempotent):
329+
raise mark_ambiguous_write(last_exception, method)
299330
raise last_exception
300331

301332
if last_exception:

‎oilpriceapi/client.py‎

Lines changed: 80 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
from .resources.subscriptions import SubscriptionsResource
4646
from .resources.webhooks import WebhooksResource
4747
from .resources.well_production import WellProductionResource
48-
from .retry import RetryStrategy
48+
from .retry import RetryStrategy, mark_ambiguous_write, validated_max_retries
4949

5050

5151
class OilPriceAPI:
@@ -63,8 +63,20 @@ 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 request attempts for failed requests. Defaults to 3.
66+
max_retries: Total request ATTEMPTS, not retries after the first.
67+
Defaults to 3. Must be at least 1; pass 1 for a single attempt with
68+
no retries. Anything less, or a non-int, raises ConfigurationError
69+
rather than being silently replaced by the default.
6770
retry_on: Status codes to retry on. Defaults to [429, 500, 502, 503, 504].
71+
An explicit empty list is honoured and disables status-code retries.
72+
73+
Retry safety: a non-idempotent method (POST, PATCH) is sent exactly ONCE.
74+
A timeout, a transport error or a 5xx is an ambiguous outcome — the server
75+
may have committed the write before the response was lost — so replaying it
76+
could create a duplicate. Idempotent methods (GET, HEAD, OPTIONS, TRACE,
77+
PUT, DELETE) still retry as before, and a 429 still retries any method
78+
because it is an outright refusal. Pass ``idempotent=True`` to ``request()``
79+
to opt a specific write back into retrying.
6880
6981
Example:
7082
>>> # Recommended: Use context manager for automatic cleanup
@@ -108,8 +120,15 @@ def __init__(
108120
# Configuration
109121
self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
110122
self.timeout = timeout or self.DEFAULT_TIMEOUT
111-
self.max_retries = max_retries or self.DEFAULT_MAX_RETRIES
112-
self.retry_on = retry_on or self.DEFAULT_RETRY_CODES
123+
# Explicit None checks, not `or`: an explicit max_retries=0 used to
124+
# become 3 and an explicit retry_on=[] used to become the default status
125+
# list, silently discarding what the caller asked for (#104).
126+
self.max_retries = (
127+
self.DEFAULT_MAX_RETRIES if max_retries is None else validated_max_retries(max_retries)
128+
)
129+
self.retry_on = (
130+
list(self.DEFAULT_RETRY_CODES) if retry_on is None else list(retry_on)
131+
)
113132

114133
# Initialize retry strategy
115134
self._retry_strategy = RetryStrategy(max_retries=self.max_retries, retry_on=self.retry_on)
@@ -205,6 +224,7 @@ def request(
205224
params: Optional[Dict[str, Any]] = None,
206225
json_data: Optional[Dict[str, Any]] = None,
207226
timeout: Optional[float] = None,
227+
idempotent: Optional[bool] = None,
208228
**kwargs,
209229
) -> Dict[str, Any]:
210230
"""Make HTTP request to API.
@@ -218,6 +238,9 @@ def request(
218238
params: Query parameters
219239
json_data: JSON body data
220240
timeout: Request timeout in seconds. If None, uses client's default timeout.
241+
idempotent: Assert that repeating this request is safe. Without it,
242+
POST and PATCH are sent exactly once and never replayed after an
243+
ambiguous outcome (#104).
221244
**kwargs: Additional httpx request arguments
222245
223246
Returns:
@@ -274,18 +297,28 @@ def request(
274297
)
275298

276299
# Auto-retry with Retry-After if we have attempts left
277-
if self._retry_strategy.should_retry(attempt, 429, response.headers):
278-
try:
279-
wait_time = min(float(retry_after), 60.0)
280-
except (TypeError, ValueError):
281-
wait_time = self._retry_strategy.calculate_wait_time(attempt)
300+
if self._retry_strategy.should_retry(
301+
attempt, 429, response.headers, method=method, idempotent=idempotent
302+
):
303+
# Bounded in BOTH directions: a server Retry-After of
304+
# 31612 would park the process for 8.8 hours, and a
305+
# negative one makes time.sleep() raise (#104).
306+
wait_time = self._retry_strategy.bounded_wait(
307+
retry_after, self._retry_strategy.calculate_wait_time(attempt)
308+
)
282309
logger.info(
283310
f"Rate limited. Retrying in {wait_time}s (attempt {attempt + 1}/{self.max_retries})"
284311
)
285312
time.sleep(wait_time)
286313
continue
287314
elif response.status_code >= 500:
288-
if self._retry_strategy.should_retry(attempt, response.status_code, response.headers):
315+
if self._retry_strategy.should_retry(
316+
attempt,
317+
response.status_code,
318+
response.headers,
319+
method=method,
320+
idempotent=idempotent,
321+
):
289322
wait_time = self._retry_strategy.calculate_wait_time(attempt)
290323
self._retry_strategy.log_retry(
291324
attempt,
@@ -306,21 +339,27 @@ def request(
306339
api_key=self.api_key,
307340
timeout=effective_timeout,
308341
)
309-
if self._retry_strategy.should_retry_on_exception(attempt):
342+
if self._retry_strategy.should_retry_on_exception(
343+
attempt, method=method, idempotent=idempotent
344+
):
310345
wait_time = self._retry_strategy.calculate_wait_time(attempt)
311346
self._retry_strategy.log_retry(
312347
attempt, "Request timeout", wait_time, is_async=False
313348
)
314349
time.sleep(wait_time)
315350
continue
351+
if not self._retry_strategy.is_replay_safe(method, idempotent):
352+
raise mark_ambiguous_write(last_exception, method)
316353
logger.error(f"Request timed out after {self.max_retries} attempts")
317354
raise last_exception
318355
except httpx.RequestError as error:
319356
last_exception = error_from_exception(
320357
error,
321358
api_key=self.api_key,
322359
)
323-
if self._retry_strategy.should_retry_on_exception(attempt):
360+
if self._retry_strategy.should_retry_on_exception(
361+
attempt, method=method, idempotent=idempotent
362+
):
324363
wait_time = self._retry_strategy.calculate_wait_time(attempt)
325364
self._retry_strategy.log_retry(
326365
attempt,
@@ -330,6 +369,8 @@ def request(
330369
)
331370
time.sleep(wait_time)
332371
continue
372+
if not self._retry_strategy.is_replay_safe(method, idempotent):
373+
raise mark_ambiguous_write(last_exception, method)
333374
logger.error(
334375
f"Request failed after {self.max_retries} attempts: "
335376
f"{error.__class__.__name__}"
@@ -354,6 +395,7 @@ def request_with_headers(
354395
params: Optional[Dict[str, Any]] = None,
355396
json_data: Optional[Dict[str, Any]] = None,
356397
timeout: Optional[float] = None,
398+
idempotent: Optional[bool] = None,
357399
**kwargs,
358400
) -> Tuple[Dict[str, Any], httpx.Headers]:
359401
"""Make HTTP request and return (json_body, headers) tuple.
@@ -388,18 +430,28 @@ def request_with_headers(
388430
if response.status_code == 429:
389431
retry_after = response.headers.get("Retry-After")
390432

391-
if self._retry_strategy.should_retry(attempt, 429, response.headers):
392-
try:
393-
wait_time = min(float(retry_after), 60.0)
394-
except (TypeError, ValueError):
395-
wait_time = self._retry_strategy.calculate_wait_time(attempt)
433+
if self._retry_strategy.should_retry(
434+
attempt, 429, response.headers, method=method, idempotent=idempotent
435+
):
436+
# Bounded in BOTH directions: a server Retry-After of
437+
# 31612 would park the process for 8.8 hours, and a
438+
# negative one makes time.sleep() raise (#104).
439+
wait_time = self._retry_strategy.bounded_wait(
440+
retry_after, self._retry_strategy.calculate_wait_time(attempt)
441+
)
396442
logger.info(
397443
f"Rate limited. Retrying in {wait_time}s (attempt {attempt + 1}/{self.max_retries})"
398444
)
399445
time.sleep(wait_time)
400446
continue
401447
elif response.status_code >= 500:
402-
if self._retry_strategy.should_retry(attempt, response.status_code, response.headers):
448+
if self._retry_strategy.should_retry(
449+
attempt,
450+
response.status_code,
451+
response.headers,
452+
method=method,
453+
idempotent=idempotent,
454+
):
403455
wait_time = self._retry_strategy.calculate_wait_time(attempt)
404456
self._retry_strategy.log_retry(
405457
attempt,
@@ -420,20 +472,26 @@ def request_with_headers(
420472
api_key=self.api_key,
421473
timeout=effective_timeout,
422474
)
423-
if self._retry_strategy.should_retry_on_exception(attempt):
475+
if self._retry_strategy.should_retry_on_exception(
476+
attempt, method=method, idempotent=idempotent
477+
):
424478
wait_time = self._retry_strategy.calculate_wait_time(attempt)
425479
self._retry_strategy.log_retry(
426480
attempt, "Request timeout", wait_time, is_async=False
427481
)
428482
time.sleep(wait_time)
429483
continue
484+
if not self._retry_strategy.is_replay_safe(method, idempotent):
485+
raise mark_ambiguous_write(last_exception, method)
430486
raise last_exception
431487
except httpx.RequestError as error:
432488
last_exception = error_from_exception(
433489
error,
434490
api_key=self.api_key,
435491
)
436-
if self._retry_strategy.should_retry_on_exception(attempt):
492+
if self._retry_strategy.should_retry_on_exception(
493+
attempt, method=method, idempotent=idempotent
494+
):
437495
wait_time = self._retry_strategy.calculate_wait_time(attempt)
438496
self._retry_strategy.log_retry(
439497
attempt,
@@ -443,6 +501,8 @@ def request_with_headers(
443501
)
444502
time.sleep(wait_time)
445503
continue
504+
if not self._retry_strategy.is_replay_safe(method, idempotent):
505+
raise mark_ambiguous_write(last_exception, method)
446506
raise last_exception
447507

448508
if last_exception:

0 commit comments

Comments
 (0)