Skip to content

Commit ed3c131

Browse files
authored
Merge pull request #30 from G-ELM/feat/exponential-backoff
feat: add retry backoff for transient failures
2 parents b1a883e + ef4062a commit ed3c131

2 files changed

Lines changed: 225 additions & 13 deletions

File tree

src/shade/http.py

Lines changed: 163 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,30 @@
1010
from __future__ import annotations
1111

1212
import json
13+
import logging
1314
import math
15+
import random
1416
import time
1517
import urllib.error
1618
import urllib.parse
1719
import urllib.request
1820
from typing import Any, Dict, Optional, Tuple
1921

20-
from .errors import HTTPError, RateLimitError
22+
from .errors import (
23+
AuthenticationError,
24+
HTTPError,
25+
InvalidRequestError,
26+
NetworkError,
27+
NotFoundError,
28+
RateLimitError,
29+
)
30+
31+
logger = logging.getLogger(__name__)
32+
33+
try: # pragma: no cover - optional dependency
34+
import httpx
35+
except ImportError: # pragma: no cover - optional dependency
36+
httpx = None
2137

2238
# ---------------------------------------------------------------------------
2339
# Constants
@@ -62,6 +78,72 @@ def _backoff_seconds(attempt: int) -> float:
6278
return min(_BASE_BACKOFF * math.pow(2, attempt), _MAX_BACKOFF)
6379

6480

81+
def _retry_delay(attempt: int, base_delay: float) -> float:
82+
"""Return a capped exponential delay with randomized jitter."""
83+
return min(base_delay * (2**attempt) + random.uniform(0, 0.5), _MAX_BACKOFF)
84+
85+
86+
def _is_retryable_transport_error(exc: Exception) -> bool:
87+
"""Return True for transient network failures that should be retried."""
88+
if httpx is not None and isinstance(exc, (httpx.ConnectError, httpx.TimeoutException)):
89+
return True
90+
91+
try:
92+
import aiohttp
93+
except ImportError:
94+
aiohttp = None
95+
96+
if aiohttp is not None and isinstance(
97+
exc,
98+
(
99+
aiohttp.ClientConnectionError,
100+
aiohttp.ClientConnectorError,
101+
aiohttp.ClientOSError,
102+
aiohttp.ServerDisconnectedError,
103+
),
104+
):
105+
return True
106+
107+
if isinstance(exc, (ConnectionResetError, TimeoutError, urllib.error.URLError)):
108+
return True
109+
return False
110+
111+
112+
def _is_retryable_status(status: int) -> bool:
113+
return status in {502, 503, 504}
114+
115+
116+
def _retry_with_backoff(fn, max_retries: int, base_delay: float):
117+
"""Execute *fn* and retry transient failures with exponential back-off."""
118+
for attempt in range(max_retries + 1):
119+
try:
120+
return fn()
121+
except Exception as exc:
122+
if attempt >= max_retries or not _is_retryable_error(exc):
123+
raise
124+
delay = _retry_delay(attempt, base_delay)
125+
logger.debug(
126+
"Retrying request after transient failure (attempt %s/%s) in %.3fs",
127+
attempt + 1,
128+
max_retries + 1,
129+
delay,
130+
)
131+
time.sleep(delay)
132+
133+
134+
def _is_retryable_error(exc: Exception) -> bool:
135+
if _is_retryable_transport_error(exc):
136+
return True
137+
138+
if httpx is not None and isinstance(exc, httpx.HTTPStatusError):
139+
return _is_retryable_status(exc.response.status_code)
140+
141+
if isinstance(exc, HTTPError):
142+
return _is_retryable_status(exc.status_code or 0)
143+
144+
return False
145+
146+
65147
def _raise_for_status(
66148
status: int,
67149
headers: Any,
@@ -81,6 +163,14 @@ def _raise_for_status(
81163
------
82164
RateLimitError
83165
If HTTP 429 and retries are exhausted (or auto-retry is off).
166+
InvalidRequestError
167+
For HTTP 400 responses.
168+
AuthenticationError
169+
For HTTP 401/403 responses.
170+
NotFoundError
171+
For HTTP 404 responses.
172+
NetworkError
173+
For transient 502/503/504 responses after retries are exhausted.
84174
HTTPError
85175
For any other non-2xx status.
86176
"""
@@ -100,6 +190,32 @@ def _raise_for_status(
100190
msg = f"Rate limit exceeded. {detail}".strip()
101191
raise RateLimitError(msg, retry_after=retry_after)
102192

193+
if status == 400:
194+
raise InvalidRequestError("Invalid request", status_code=status)
195+
196+
if status in {401, 403}:
197+
raise AuthenticationError("Authentication failed", status_code=status)
198+
199+
if status == 404:
200+
response_body = body.decode("utf-8", errors="replace")
201+
raise NotFoundError(
202+
"Resource not found",
203+
status_code=status,
204+
response_body=response_body,
205+
)
206+
207+
if status in {502, 503, 504}:
208+
if attempt < max_retries:
209+
wait = _retry_delay(attempt, _BASE_BACKOFF)
210+
logger.debug(
211+
"Retrying request after server error (attempt %s/%s) in %.3fs",
212+
attempt + 1,
213+
max_retries + 1,
214+
wait,
215+
)
216+
return wait
217+
raise NetworkError(f"Request failed with transient server error: {status}", status_code=status)
218+
103219
try:
104220
detail = json.loads(body).get("error", {}).get("message", "")
105221
except Exception:
@@ -176,12 +292,29 @@ def request(
176292
attempt = 0
177293
while True:
178294
req = self._build_request(method, path, payload)
179-
status, headers, body = self._execute(req)
295+
try:
296+
status, headers, body = self._execute(req)
297+
except Exception as exc:
298+
if _is_retryable_transport_error(exc):
299+
if attempt >= self.max_retries:
300+
raise NetworkError(
301+
"Request failed after exhausting retries",
302+
status_code=None,
303+
) from exc
304+
delay = _retry_delay(attempt, _BASE_BACKOFF)
305+
logger.debug(
306+
"Retrying request after transient failure (attempt %s/%s) in %.3fs",
307+
attempt + 1,
308+
self.max_retries + 1,
309+
delay,
310+
)
311+
time.sleep(delay)
312+
attempt += 1
313+
continue
314+
raise
180315
wait = _raise_for_status(status, headers, body, attempt, self.max_retries)
181316
if wait is None:
182-
# success
183317
return json.loads(body) if body else {}
184-
# 429 — sleep and retry
185318
time.sleep(wait)
186319
attempt += 1
187320

@@ -269,18 +402,36 @@ async def request(
269402
) as session:
270403
while True:
271404
url = f"{url_base}/{path.lstrip('/')}"
272-
resp = await session.request(
273-
method.upper(),
274-
url,
275-
json=payload,
276-
headers=headers,
277-
)
278-
body = await resp.read()
405+
try:
406+
resp = await session.request(
407+
method.upper(),
408+
url,
409+
json=payload,
410+
headers=headers,
411+
)
412+
body = await resp.read()
413+
except Exception as exc:
414+
if _is_retryable_transport_error(exc):
415+
if attempt >= self.max_retries:
416+
raise NetworkError(
417+
"Request failed after exhausting retries",
418+
status_code=None,
419+
) from exc
420+
delay = _retry_delay(attempt, _BASE_BACKOFF)
421+
logger.debug(
422+
"Retrying request after transient failure (attempt %s/%s) in %.3fs",
423+
attempt + 1,
424+
self.max_retries + 1,
425+
delay,
426+
)
427+
await asyncio.sleep(delay)
428+
attempt += 1
429+
continue
430+
raise
279431
wait = _raise_for_status(
280432
resp.status, resp.headers, body, attempt, self.max_retries
281433
)
282434
if wait is None:
283435
return json.loads(body) if body else {}
284-
# 429 — non-blocking sleep
285436
await asyncio.sleep(wait)
286437
attempt += 1

tests/test_rate_limit.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import pytest
2525

2626
from shade import RateLimitError
27-
from shade.errors import HTTPError
27+
from shade.errors import HTTPError, InvalidRequestError, NetworkError
2828
from shade.http import (
2929
DEFAULT_MAX_RETRIES,
3030
AsyncHTTPClient,
@@ -267,6 +267,67 @@ def fake_execute(req):
267267

268268
assert exc_info.value.retry_after is None
269269

270+
def test_retries_on_transient_5xx_then_succeeds(self):
271+
client = self._client(max_retries=2)
272+
responses = [
273+
(503, {}, b"{}"),
274+
(503, {}, b"{}"),
275+
(200, {}, _fake_200_body()),
276+
]
277+
idx = 0
278+
279+
def fake_execute(req):
280+
nonlocal idx
281+
status, headers, body = responses[idx]
282+
idx += 1
283+
return status, headers, body
284+
285+
sleep_calls: List[float] = []
286+
with patch.object(client, "_execute", side_effect=fake_execute), \
287+
patch("time.sleep", side_effect=lambda s: sleep_calls.append(s)), \
288+
patch("shade.http.random.uniform", side_effect=[0.0, 0.0]):
289+
result = client.request("GET", "/payments")
290+
291+
assert result == {"id": "pay_123", "status": "ok"}
292+
assert sleep_calls == [1.0, 2.0]
293+
294+
def test_400_raises_invalid_request_error_immediately(self):
295+
client = self._client(max_retries=3)
296+
297+
def fake_execute(req):
298+
return 400, {}, b'{"error": {"message": "bad request"}}'
299+
300+
with patch.object(client, "_execute", side_effect=fake_execute), \
301+
patch("time.sleep") as mock_sleep:
302+
with pytest.raises(InvalidRequestError) as exc_info:
303+
client.request("GET", "/payments")
304+
305+
mock_sleep.assert_not_called()
306+
assert exc_info.value.status_code == 400
307+
308+
def test_retries_exhausted_raise_network_error(self):
309+
client = self._client(max_retries=1)
310+
responses = [
311+
(503, {}, b"{}"),
312+
(503, {}, b"{}"),
313+
(503, {}, b"{}"),
314+
]
315+
idx = 0
316+
317+
def fake_execute(req):
318+
nonlocal idx
319+
status, headers, body = responses[idx]
320+
idx += 1
321+
return status, headers, body
322+
323+
with patch.object(client, "_execute", side_effect=fake_execute), \
324+
patch("time.sleep") as mock_sleep:
325+
with pytest.raises(NetworkError) as exc_info:
326+
client.request("GET", "/payments")
327+
328+
assert exc_info.value.status_code == 503
329+
assert mock_sleep.call_count == 1
330+
270331

271332
# ---------------------------------------------------------------------------
272333
# AsyncHTTPClient

0 commit comments

Comments
 (0)