Coverage for src/hush_hush/retry.py: 65%
84 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-10-16 16:01 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-10-16 16:01 +0000
1"""Retry transports: network failure or an HTTP 5xx/429 response is retried
2with exponential backoff and jitter, honoring a Retry-After header ahead of
3the computed backoff delay when present. Any other 4xx is never retried —
4it won't succeed on a second attempt, and retrying only delays the real
5error reaching the caller.
6"""
8from __future__ import annotations
10import email.utils
11import random
12import time
13from datetime import UTC, datetime
15import httpx
17DEFAULT_MAX_RETRIES = 3
20def _is_retryable_status(status_code: int) -> bool:
21 return status_code >= 500 or status_code == 429
24def _retry_after_seconds(response: httpx.Response) -> float | None:
25 value = response.headers.get("Retry-After")
26 if not value:
27 return None
28 try:
29 seconds = int(value)
30 return float(seconds) if seconds >= 0 else None
31 except ValueError:
32 pass
33 try:
34 when = email.utils.parsedate_to_datetime(value)
35 if when.tzinfo is None:
36 when = when.replace(tzinfo=UTC)
37 delta = (when - datetime.now(UTC)).total_seconds()
38 return max(delta, 0.0)
39 except (TypeError, ValueError):
40 return None
43def _backoff_seconds(attempt: int, response: httpx.Response | None) -> float:
44 if response is not None:
45 retry_after = _retry_after_seconds(response)
46 if retry_after is not None:
47 return retry_after
48 base = 0.1 * (2 ** (attempt - 1))
49 return base + random.uniform(0, base)
52class RetryTransport(httpx.BaseTransport):
53 """Wraps a sync transport with hush-hush's retry policy."""
55 def __init__(
56 self, transport: httpx.BaseTransport, max_retries: int = DEFAULT_MAX_RETRIES
57 ) -> None:
58 self._transport = transport
59 self._max_retries = max_retries
61 def handle_request(self, request: httpx.Request) -> httpx.Response:
62 last_response: httpx.Response | None = None
63 last_error: Exception | None = None
65 for attempt in range(self._max_retries + 1):
66 if attempt > 0:
67 time.sleep(_backoff_seconds(attempt, last_response))
68 try:
69 response = self._transport.handle_request(request)
70 except httpx.TransportError as exc:
71 last_error, last_response = exc, None
72 continue
73 if not _is_retryable_status(response.status_code) or attempt == self._max_retries:
74 return response
75 response.close()
76 last_response, last_error = response, None
78 if last_error is not None:
79 raise last_error
80 assert last_response is not None
81 return last_response
83 def close(self) -> None:
84 self._transport.close()
87class AsyncRetryTransport(httpx.AsyncBaseTransport):
88 """Wraps an async transport with hush-hush's retry policy."""
90 def __init__(
91 self, transport: httpx.AsyncBaseTransport, max_retries: int = DEFAULT_MAX_RETRIES
92 ) -> None:
93 self._transport = transport
94 self._max_retries = max_retries
96 async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
97 import asyncio
99 last_response: httpx.Response | None = None
100 last_error: Exception | None = None
102 for attempt in range(self._max_retries + 1):
103 if attempt > 0:
104 await asyncio.sleep(_backoff_seconds(attempt, last_response))
105 try:
106 response = await self._transport.handle_async_request(request)
107 except httpx.TransportError as exc:
108 last_error, last_response = exc, None
109 continue
110 if not _is_retryable_status(response.status_code) or attempt == self._max_retries:
111 return response
112 await response.aclose()
113 last_response, last_error = response, None
115 if last_error is not None:
116 raise last_error
117 assert last_response is not None
118 return last_response
120 async def aclose(self) -> None:
121 await self._transport.aclose()