Coverage for src/secchi/http.py: 67%
49 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-04 22:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-04 22:15 +0000
1"""Shared HTTP client defaults and safe transient retry behavior."""
3from __future__ import annotations
5import asyncio
6import random
7from datetime import datetime, timezone
8from email.utils import parsedate_to_datetime
9from typing import Any
11import httpx
13RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
14RETRYABLE_METHODS = {"GET", "HEAD", "OPTIONS"}
17class SecchiAsyncClient(httpx.AsyncClient):
18 """AsyncClient with shared defaults and bounded retries for safe requests."""
20 def __init__(self, *, max_retries: int = 2, **kwargs: Any) -> None:
21 self.max_retries = max_retries
22 super().__init__(
23 timeout=kwargs.pop("timeout", httpx.Timeout(10.0)),
24 follow_redirects=kwargs.pop("follow_redirects", True),
25 **kwargs,
26 )
28 async def request(
29 self, method: str, url: str, *args: Any, **kwargs: Any
30 ) -> httpx.Response:
31 method_upper = method.upper()
32 retries = self.max_retries if method_upper in RETRYABLE_METHODS else 0
33 for attempt in range(retries + 1):
34 try:
35 response = await super().request(method, url, *args, **kwargs)
36 except (httpx.TimeoutException, httpx.NetworkError):
37 if attempt >= retries:
38 raise
39 await asyncio.sleep(_backoff(attempt))
40 continue
42 if response.status_code not in RETRYABLE_STATUS_CODES or attempt >= retries:
43 return response
44 await asyncio.sleep(_retry_after(response) or _backoff(attempt))
45 raise AssertionError("HTTP retry loop did not return or raise")
48class HttpClientFactory:
49 """Create consistently configured clients for registry and GitHub calls."""
51 def __init__(self, *, max_retries: int = 2) -> None:
52 self.max_retries = max_retries
54 def create(
55 self,
56 *,
57 headers: dict[str, str] | None = None,
58 timeout: httpx.Timeout | float | None = None,
59 ) -> SecchiAsyncClient:
60 return SecchiAsyncClient(
61 headers=headers,
62 timeout=timeout or httpx.Timeout(10.0),
63 max_retries=self.max_retries,
64 )
67def _backoff(attempt: int) -> float:
68 return min(2.0, 0.25 * (2**attempt)) + random.uniform(0, 0.05)
71def _retry_after(response: httpx.Response) -> float | None:
72 raw = response.headers.get("retry-after")
73 if not raw:
74 return None
75 try:
76 return max(0.0, min(10.0, float(raw)))
77 except ValueError:
78 try:
79 date = parsedate_to_datetime(raw)
80 if date.tzinfo is None:
81 date = date.replace(tzinfo=timezone.utc)
82 return max(
83 0.0,
84 min(10.0, date.timestamp() - datetime.now(timezone.utc).timestamp()),
85 )
86 except (TypeError, ValueError, OverflowError):
87 return None