Skip to content

Commit 1a6c672

Browse files
authored
feat(client): opt-in retry with backoff honoring Retry-After (#58)
Implements plans/refactor/07-retry-support.md (final ticket): - RetryConfig frozen dataclass (max_retries=0, backoff_factor=0.5, retry_statuses={429,502,503,504}, retry_methods={GET,HEAD,PUT,DELETE}), exported from the top-level package and accepted by CodesphereSDK(retry=...) - Retry loop in APIHttpClient.request: idempotent methods only by default (POST opt-in via retry_methods), retries on the configured statuses and on httpx timeout/connect errors; delay honors Retry-After (delta-seconds or HTTP-date, past dates clamp to 0) and falls back to exponential backoff with jitter; exhausted retries raise exactly what the non-retry path would (RateLimitError with retry_after populated, TimeoutError, ...); each retry is logged at debug level - Plain ~40-line loop, no retry dependency reintroduced - Default max_retries=0 means zero behavior change unless opted in (tested: single request on failure with the default config) - 17 respx tests: Retry-After honored (429->200 with exactly 2 requests and >=1s sleep), exhaustion, exponential backoff bounds, non-retryable 500, POST gating and opt-in, transport-error retries, SDK wire-through, Retry-After parsing matrix - README documents RetryConfig
1 parent a2c5edf commit 1a6c672

6 files changed

Lines changed: 310 additions & 20 deletions

File tree

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,30 @@ If no token is provided at all, `CodesphereSDK()` raises an
5858
`AuthenticationError` at construction (importing the package never requires
5959
any environment variable).
6060

61+
### Retries
62+
63+
Retries are off by default. Opt in with a `RetryConfig`:
64+
65+
```python
66+
from codesphere import CodesphereSDK, RetryConfig
67+
68+
sdk = CodesphereSDK(
69+
token="your-api-token",
70+
retry=RetryConfig(max_retries=3),
71+
)
72+
```
73+
74+
Requests using an idempotent method (`GET`, `HEAD`, `PUT`, `DELETE`) are then
75+
retried on `429`, `502`, `503`, and `504` responses as well as on
76+
connect/timeout errors. The delay honors the server's `Retry-After` header
77+
(seconds or HTTP-date) when present, otherwise exponential backoff with
78+
jitter (`backoff_factor * 2**attempt`). `POST` is never retried unless you
79+
add it to `retry_methods` explicitly:
80+
81+
```python
82+
RetryConfig(max_retries=3, retry_methods=frozenset({"GET", "POST"}))
83+
```
84+
6185
## Quick Start
6286

6387
```python

src/codesphere/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import logging
2323

2424
from .client import CodesphereSDK
25+
from .config import RetryConfig
2526
from .exceptions import (
2627
APIError,
2728
AuthenticationError,
@@ -75,6 +76,7 @@
7576
"NetworkError",
7677
"NotFoundError",
7778
"RateLimitError",
79+
"RetryConfig",
7880
# Resources
7981
"Team",
8082
"TeamBase",

src/codesphere/client.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import httpx
44
from pydantic import SecretStr
55

6-
from .config import Settings
6+
from .config import RetryConfig, Settings
77
from .exceptions import AuthenticationError
88
from .http_client import APIHttpClient
99
from .resources.metadata import MetadataResource
@@ -30,6 +30,7 @@ def __init__(
3030
token: str | SecretStr | None = None,
3131
base_url: str | None = None,
3232
timeout: httpx.Timeout | None = None,
33+
retry: RetryConfig | None = None,
3334
http_client: APIHttpClient | None = None,
3435
):
3536
if http_client is not None:
@@ -53,6 +54,7 @@ def __init__(
5354
token=resolved_token,
5455
base_url=resolved_base_url,
5556
timeout=resolved_timeout,
57+
retry=retry,
5658
)
5759

5860
self.teams = TeamsResource(self._http_client)

src/codesphere/config.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,30 @@
1+
from dataclasses import dataclass
2+
13
from pydantic import HttpUrl, SecretStr
24
from pydantic_settings import BaseSettings, SettingsConfigDict
35

46
DEFAULT_BASE_URL = "https://codesphere.com/api"
57

68

9+
@dataclass(frozen=True, slots=True)
10+
class RetryConfig:
11+
"""Opt-in retry behavior for transient failures.
12+
13+
Disabled by default (``max_retries=0``). When enabled, requests using a
14+
method in ``retry_methods`` are retried on the statuses in
15+
``retry_statuses`` and on connect/timeout errors. The delay honors a
16+
``Retry-After`` response header (seconds or HTTP-date) when present,
17+
otherwise exponential backoff with jitter is used. Only idempotent
18+
methods are retried by default; add ``"POST"`` to ``retry_methods``
19+
explicitly if your endpoints tolerate it.
20+
"""
21+
22+
max_retries: int = 0
23+
backoff_factor: float = 0.5
24+
retry_statuses: frozenset[int] = frozenset({429, 502, 503, 504})
25+
retry_methods: frozenset[str] = frozenset({"GET", "HEAD", "PUT", "DELETE"})
26+
27+
728
class Settings(BaseSettings):
829
"""Environment-driven defaults (``CS_*`` variables, optional ``.env``).
930

src/codesphere/http_client.py

Lines changed: 83 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,54 @@
1+
import asyncio
12
import logging
3+
import random
24
from contextlib import AbstractAsyncContextManager
5+
from datetime import UTC, datetime
6+
from email.utils import parsedate_to_datetime
37
from types import TracebackType
48
from typing import Any, cast
59

610
import httpx
711
from pydantic import SecretStr
812

13+
from .config import RetryConfig
914
from .exceptions import NetworkError, TimeoutError, raise_for_status
1015

1116
log = logging.getLogger(__name__)
1217

1318

19+
def _parse_retry_after(value: str | None) -> float | None:
20+
"""Parse a Retry-After header: delta-seconds or an HTTP-date."""
21+
if not value:
22+
return None
23+
if value.isdigit():
24+
return float(value)
25+
try:
26+
retry_at = parsedate_to_datetime(value)
27+
except (TypeError, ValueError):
28+
return None
29+
return max(0.0, (retry_at - datetime.now(UTC)).total_seconds())
30+
31+
1432
class APIHttpClient:
1533
"""Thin async transport around httpx.
1634
1735
Receives its full configuration from the caller (the SDK resolves
1836
arguments, environment variables, and defaults) and owns nothing but
19-
the connection lifecycle and error mapping.
37+
the connection lifecycle, error mapping, and opt-in retries.
2038
"""
2139

22-
def __init__(self, *, token: SecretStr, base_url: str, timeout: httpx.Timeout):
40+
def __init__(
41+
self,
42+
*,
43+
token: SecretStr,
44+
base_url: str,
45+
timeout: httpx.Timeout,
46+
retry: RetryConfig | None = None,
47+
):
2348
self._token = token.get_secret_value()
2449
self._base_url = base_url
2550
self._client: httpx.AsyncClient | None = None
51+
self._retry = retry if retry is not None else RetryConfig()
2652

2753
self._timeout_config = timeout
2854
self._client_config: dict[str, Any] = {
@@ -92,28 +118,66 @@ async def request(
92118
log.debug(f"Request: {method} {endpoint}")
93119
log.debug(f"Request kwargs: {kwargs}")
94120

95-
try:
96-
response = await client.request(method, endpoint, **kwargs)
121+
retryable_method = method.upper() in self._retry.retry_methods
122+
attempts = self._retry.max_retries + 1 if retryable_method else 1
123+
124+
for attempt in range(attempts):
125+
last_attempt = attempt == attempts - 1
126+
try:
127+
response = await client.request(method, endpoint, **kwargs)
128+
except httpx.TimeoutException as e:
129+
if not last_attempt:
130+
await self._sleep_before_retry(attempt, None, method, endpoint)
131+
continue
132+
log.error(f"Request timeout for {method} {endpoint}: {e}")
133+
raise TimeoutError(f"Request to {endpoint} timed out.") from e
134+
except httpx.ConnectError as e:
135+
if not last_attempt:
136+
await self._sleep_before_retry(attempt, None, method, endpoint)
137+
continue
138+
log.error(f"Connection error for {method} {endpoint}: {e}")
139+
raise NetworkError(
140+
f"Failed to connect to the API: {e}",
141+
original_error=e,
142+
) from e
143+
except httpx.RequestError as e:
144+
log.error(f"Network error for {method} {endpoint}: {e}")
145+
raise NetworkError(
146+
f"A network error occurred: {e}",
147+
original_error=e,
148+
) from e
149+
97150
log.debug(
98151
f"Response: {response.status_code} {response.reason_phrase} "
99152
f"for {method} {endpoint}"
100153
)
101154

155+
if response.status_code in self._retry.retry_statuses and not last_attempt:
156+
await self._sleep_before_retry(attempt, response, method, endpoint)
157+
continue
158+
102159
raise_for_status(response)
103160
return response
104161

105-
except httpx.TimeoutException as e:
106-
log.error(f"Request timeout for {method} {endpoint}: {e}")
107-
raise TimeoutError(f"Request to {endpoint} timed out.") from e
108-
except httpx.ConnectError as e:
109-
log.error(f"Connection error for {method} {endpoint}: {e}")
110-
raise NetworkError(
111-
f"Failed to connect to the API: {e}",
112-
original_error=e,
113-
) from e
114-
except httpx.RequestError as e:
115-
log.error(f"Network error for {method} {endpoint}: {e}")
116-
raise NetworkError(
117-
f"A network error occurred: {e}",
118-
original_error=e,
119-
) from e
162+
raise AssertionError("unreachable: retry loop must return or raise")
163+
164+
async def _sleep_before_retry(
165+
self,
166+
attempt: int,
167+
response: httpx.Response | None,
168+
method: str,
169+
endpoint: str,
170+
) -> None:
171+
delay = None
172+
if response is not None:
173+
delay = _parse_retry_after(response.headers.get("Retry-After"))
174+
if delay is None:
175+
delay = self._retry.backoff_factor * (2**attempt)
176+
# Jitter to avoid thundering herds; not security-relevant.
177+
delay += random.uniform(0, self._retry.backoff_factor / 2) # nosec B311
178+
179+
log.debug(
180+
f"Retrying {method} {endpoint} in {delay:.2f}s "
181+
f"(attempt {attempt + 1}/{self._retry.max_retries})"
182+
)
183+
await asyncio.sleep(delay)

0 commit comments

Comments
 (0)