Coverage for src/forge_dashboard/retry.py: 100%
86 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-11 22:25 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-11 22:25 +0000
1"""Retry transport for httpx: exponential backoff with jitter on 429/5xx,
2honoring a server-sent ``Retry-After`` (seconds or an HTTP-date) ahead of
3the computed backoff. Never retries any other 4xx -- it won't succeed the
4second time either, and retrying only delays the real error reaching the
5caller (rules/sdk-generation.md's "Client shape").
7httpx's transport-level hooks (``httpx.BaseTransport``/``AsyncBaseTransport``)
8apply this uniformly to every request the client makes, rather than
9wrapping each generated call site in a retry loop by hand.
10"""
12from __future__ import annotations
14import asyncio
15import random
16import time
17from dataclasses import dataclass
18from datetime import UTC, datetime
19from email.utils import parsedate_to_datetime
21import httpx
23_RETRYABLE_5XX_AND_429 = 500
26@dataclass(frozen=True)
27class RetryConfig:
28 """Retry policy for :class:`RetryTransport`/:class:`AsyncRetryTransport`.
30 Attributes:
31 max_retries: Number of additional attempts after the first.
32 base_seconds: Starting backoff before jitter and any server-sent
33 ``Retry-After``.
34 """
36 max_retries: int = 3
37 base_seconds: float = 0.25
40DEFAULT_RETRY_CONFIG = RetryConfig()
43def _should_retry(
44 attempt: int,
45 config: RetryConfig,
46 response: httpx.Response | None,
47 exc: Exception | None,
48) -> bool:
49 if attempt >= config.max_retries:
50 return False
51 if exc is not None:
52 # A network-level failure (connection reset, timeout) is
53 # retry-worthy the same as a 5xx.
54 return True
55 if response is None:
56 # Every call site passes exactly one of response/exc -- not an
57 # assert, so this invariant still holds under -O and doesn't trip
58 # bandit's B101.
59 raise ValueError("_should_retry needs a response or an exception")
60 return response.status_code == httpx.codes.TOO_MANY_REQUESTS or response.status_code >= _RETRYABLE_5XX_AND_429
63def _retry_after_seconds(response: httpx.Response) -> float | None:
64 value = response.headers.get("retry-after") # pragma: no mutate - httpx.Headers lookups are case-insensitive
65 if not value:
66 return None
67 value = value.strip()
68 try:
69 return float(int(value))
70 except ValueError:
71 pass
72 try:
73 when = parsedate_to_datetime(value)
74 except (TypeError, ValueError):
75 return None
76 if when.tzinfo is None:
77 when = when.replace(tzinfo=UTC)
78 return max((when - datetime.now(UTC)).total_seconds(), 0.0)
81def _backoff_seconds(attempt: int, config: RetryConfig) -> float:
82 backoff = config.base_seconds * (2.0**attempt)
83 # Retry jitter, not security-sensitive -- a predictable PRNG is fine.
84 jitter = random.uniform(0, backoff) # noqa: S311 # nosec B311
85 return backoff / 2 + jitter / 2
88def _retry_delay(attempt: int, config: RetryConfig, response: httpx.Response | None) -> float:
89 if response is not None:
90 retry_after = _retry_after_seconds(response)
91 if retry_after is not None:
92 return retry_after
93 return _backoff_seconds(attempt, config)
96class RetryTransport(httpx.BaseTransport):
97 """Wraps a sync ``httpx.BaseTransport`` with the retry policy above."""
99 def __init__(
100 self,
101 *,
102 transport: httpx.BaseTransport | None = None,
103 retry: RetryConfig = DEFAULT_RETRY_CONFIG,
104 ) -> None:
105 self._transport = transport if transport is not None else httpx.HTTPTransport()
106 self._retry = retry
108 def handle_request(self, request: httpx.Request) -> httpx.Response:
109 # A bounded for-loop, not `while True` plus a hand-incremented
110 # counter: `_should_retry` already returns False once `attempt`
111 # reaches `max_retries`, so the last iteration always returns or
112 # raises from inside the loop -- but driving termination off
113 # `range()` too means a broken attempt counter can never turn this
114 # into an infinite retry loop, only a wrong retry *count*.
115 # The exact upper bound only has to be "enough" -- _should_retry's
116 # own attempt >= max_retries check is what actually ends the loop
117 # on the right iteration, so range()'s bound is unreachable as a
118 # binding constraint whenever _should_retry itself is correct.
119 for attempt in range(self._retry.max_retries + 1): # pragma: no mutate
120 try:
121 response = self._transport.handle_request(request)
122 except httpx.TransportError as exc:
123 if not _should_retry(attempt, self._retry, None, exc):
124 raise
125 time.sleep(_retry_delay(attempt, self._retry, None))
126 continue
128 if not _should_retry(attempt, self._retry, response, None):
129 return response
131 response.close()
132 time.sleep(_retry_delay(attempt, self._retry, response))
134 # Every loop iteration above returns or raises before falling
135 # through, so this is unreachable -- kept only so mypy sees an
136 # explicit return on every path.
137 raise AssertionError("unreachable") # pragma: no cover, no mutate
139 def close(self) -> None:
140 self._transport.close()
143class AsyncRetryTransport(httpx.AsyncBaseTransport):
144 """Wraps an async ``httpx.AsyncBaseTransport`` with the retry policy above."""
146 def __init__(
147 self,
148 *,
149 transport: httpx.AsyncBaseTransport | None = None,
150 retry: RetryConfig = DEFAULT_RETRY_CONFIG,
151 ) -> None:
152 self._transport = transport if transport is not None else httpx.AsyncHTTPTransport()
153 self._retry = retry
155 async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
156 # See RetryTransport.handle_request's comments: bounded by
157 # range(), not a hand-incremented counter under `while True`, and
158 # the exact bound is unreachable as a binding constraint whenever
159 # _should_retry itself is correct.
160 for attempt in range(self._retry.max_retries + 1): # pragma: no mutate
161 try:
162 response = await self._transport.handle_async_request(request)
163 except httpx.TransportError as exc:
164 if not _should_retry(attempt, self._retry, None, exc):
165 raise
166 await asyncio.sleep(_retry_delay(attempt, self._retry, None))
167 continue
169 if not _should_retry(attempt, self._retry, response, None):
170 return response
172 await response.aclose()
173 await asyncio.sleep(_retry_delay(attempt, self._retry, response))
175 raise AssertionError("unreachable") # pragma: no cover, no mutate
177 async def aclose(self) -> None:
178 await self._transport.aclose()