Skip to content

Commit 89aad2d

Browse files
committed
Skip retry on non-idempotent methods for 5xx, parse HTTP-date Retry-After
POST requests are only retried on 429 (rate limit), not 5xx, to prevent duplicate job submission. Connection errors before send (ConnectError) are still retried for all methods. Post-send errors (ReadError, TimeoutException) on non-idempotent methods raise immediately. Also adds RFC 7231 HTTP-date parsing for Retry-After header and truncates error body to 500 chars.
1 parent 633c6d1 commit 89aad2d

2 files changed

Lines changed: 86 additions & 6 deletions

File tree

ionq_core/_transport.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Retry transport for httpx with exponential backoff."""
22

33
import asyncio
4+
import email.utils
45
import logging
56
import random
67
import time
@@ -20,6 +21,7 @@
2021
RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, *range(520, 530)})
2122
DEFAULT_MAX_RETRIES = 2
2223
_MAX_RETRY_AFTER = 60.0
24+
_IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "PUT", "DELETE", "OPTIONS"})
2325

2426

2527
def _parse_retry_after(response: httpx.Response) -> float | None:
@@ -29,7 +31,11 @@ def _parse_retry_after(response: httpx.Response) -> float | None:
2931
try:
3032
return float(header)
3133
except ValueError:
32-
return None
34+
pass
35+
parsed = email.utils.parsedate(header)
36+
if parsed is not None:
37+
return max(0.0, time.mktime(parsed) - time.time())
38+
return None
3339

3440

3541
def _backoff_delays(max_retries: int) -> Generator[float]:
@@ -44,7 +50,8 @@ def _parse_error_body(response: httpx.Response) -> dict | str | None:
4450
response.read()
4551
return response.json()
4652
except Exception:
47-
return response.text or None
53+
text = response.text
54+
return text[:500] if text else None
4855

4956

5057
def _raise_for_response(response: httpx.Response) -> None:
@@ -73,6 +80,19 @@ def _raise_exhausted(last_response: httpx.Response | None, last_exc: Exception |
7380
raise APIConnectionError("Request failed with no response")
7481

7582

83+
def _should_retry(request: httpx.Request, response: httpx.Response, retryable: frozenset[int]) -> bool:
84+
"""Return True if this response should be retried.
85+
86+
Non-idempotent methods (POST) are only retried on 429 (rate limit)
87+
since the server may have already processed the request on 5xx.
88+
"""
89+
if response.status_code not in retryable:
90+
return False
91+
if request.method in _IDEMPOTENT_METHODS:
92+
return True
93+
return response.status_code == 429
94+
95+
7696
class RetryTransport(httpx.BaseTransport):
7797
"""Wraps an httpx transport with retry logic and error raising."""
7898

@@ -98,12 +118,18 @@ def handle_request(self, request: httpx.Request) -> httpx.Response:
98118

99119
try:
100120
response = self._transport.handle_request(request)
121+
except httpx.ConnectError as exc:
122+
last_exc = exc
123+
last_response = None
124+
continue
101125
except (httpx.TimeoutException, httpx.NetworkError) as exc:
126+
if request.method not in _IDEMPOTENT_METHODS:
127+
raise APIConnectionError(str(exc)) from exc
102128
last_exc = exc
103129
last_response = None
104130
continue
105131

106-
if response.status_code in self._retryable:
132+
if _should_retry(request, response, self._retryable):
107133
last_response = response
108134
last_exc = None
109135
continue
@@ -142,12 +168,18 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
142168

143169
try:
144170
response = await self._transport.handle_async_request(request)
171+
except httpx.ConnectError as exc:
172+
last_exc = exc
173+
last_response = None
174+
continue
145175
except (httpx.TimeoutException, httpx.NetworkError) as exc:
176+
if request.method not in _IDEMPOTENT_METHODS:
177+
raise APIConnectionError(str(exc)) from exc
146178
last_exc = exc
147179
last_response = None
148180
continue
149181

150-
if response.status_code in self._retryable:
182+
if _should_retry(request, response, self._retryable):
151183
last_response = response
152184
last_exc = None
153185
continue

tests/test_transport.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ def _response(status_code, headers=None, json_body=None):
3535
return httpx.Response(status_code, headers=dict(headers or {}), json=json_body)
3636

3737

38-
def _request():
39-
return httpx.Request("GET", "https://api.ionq.co/v0.4/backends")
38+
def _request(method="GET"):
39+
return httpx.Request(method, "https://api.ionq.co/v0.4/backends")
4040

4141

4242
class TestRetryTransport:
@@ -202,3 +202,51 @@ async def test_timeout_retried(self):
202202
transport, fake = self._make_transport([httpx.ReadTimeout("timed out"), _response(200)])
203203
resp = await transport.handle_async_request(_request())
204204
assert resp.status_code == 200
205+
206+
207+
class TestIdempotencyAwareRetry:
208+
def _make_transport(self, responses, max_retries=2):
209+
fake = FakeTransport(responses)
210+
return RetryTransport(fake, max_retries=max_retries), fake
211+
212+
def test_post_503_not_retried(self):
213+
transport, fake = self._make_transport([_response(503)])
214+
with pytest.raises(ServerError):
215+
transport.handle_request(_request("POST"))
216+
assert fake._call_count == 1
217+
218+
def test_post_429_retried(self):
219+
transport, fake = self._make_transport([_response(429), _response(200)])
220+
resp = transport.handle_request(_request("POST"))
221+
assert resp.status_code == 200
222+
assert fake._call_count == 2
223+
224+
def test_get_503_retried(self):
225+
transport, fake = self._make_transport([_response(503), _response(200)])
226+
resp = transport.handle_request(_request("GET"))
227+
assert resp.status_code == 200
228+
assert fake._call_count == 2
229+
230+
def test_put_503_retried(self):
231+
transport, fake = self._make_transport([_response(503), _response(200)])
232+
resp = transport.handle_request(_request("PUT"))
233+
assert resp.status_code == 200
234+
assert fake._call_count == 2
235+
236+
def test_delete_503_retried(self):
237+
transport, fake = self._make_transport([_response(503), _response(200)])
238+
resp = transport.handle_request(_request("DELETE"))
239+
assert resp.status_code == 200
240+
assert fake._call_count == 2
241+
242+
def test_post_connect_error_retried(self):
243+
transport, fake = self._make_transport([httpx.ConnectError("refused"), _response(200)])
244+
resp = transport.handle_request(_request("POST"))
245+
assert resp.status_code == 200
246+
assert fake._call_count == 2
247+
248+
def test_post_read_error_not_retried(self):
249+
transport, fake = self._make_transport([httpx.ReadError("broken pipe")])
250+
with pytest.raises(APIConnectionError):
251+
transport.handle_request(_request("POST"))
252+
assert fake._call_count == 1

0 commit comments

Comments
 (0)