Coverage for src/secchi/http.py: 68%
59 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 23:28 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 23:28 +0000
1"""Shared HTTP client defaults and safe transient retry behavior."""
3from __future__ import annotations
5import asyncio
6import random
7from datetime import UTC, datetime
8from email.utils import parsedate_to_datetime
9from typing import Any
11import httpx
13from secchi.diagnostics import (
14 DiagnosticLog,
15 DiagnosticStatus,
16 diagnostic_for_http_error,
17)
19RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
20RETRYABLE_METHODS = {"GET", "HEAD", "OPTIONS"}
23class SecchiAsyncClient(httpx.AsyncClient):
24 """AsyncClient with shared defaults and bounded retries for safe requests."""
26 def __init__(
27 self,
28 *,
29 max_retries: int = 2,
30 diagnostics: DiagnosticLog | None = None,
31 **kwargs: Any,
32 ) -> None:
33 self.max_retries = max_retries
34 self.diagnostics = diagnostics
35 super().__init__(
36 timeout=kwargs.pop("timeout", httpx.Timeout(10.0)),
37 follow_redirects=kwargs.pop("follow_redirects", True),
38 **kwargs,
39 )
41 async def request(
42 self, method: str, url: str, *args: Any, **kwargs: Any
43 ) -> httpx.Response:
44 method_upper = method.upper()
45 retries = self.max_retries if method_upper in RETRYABLE_METHODS else 0
46 for attempt in range(retries + 1):
47 try:
48 response = await super().request(method, url, *args, **kwargs)
49 except (httpx.TimeoutException, httpx.NetworkError) as exc:
50 if self.diagnostics is not None:
51 self.diagnostics.record(
52 DiagnosticStatus.FAILURE
53 if attempt >= retries
54 else DiagnosticStatus.WARN,
55 "HTTP",
56 f"{method_upper} request failed ({diagnostic_for_http_error(exc)})"
57 + (
58 f"; retry {attempt + 1}/{retries}"
59 if attempt < retries
60 else ""
61 ),
62 url=url,
63 )
64 if attempt >= retries:
65 raise
66 await asyncio.sleep(_backoff(attempt))
67 continue
69 if self.diagnostics is not None:
70 status = (
71 DiagnosticStatus.SUCCESS
72 if response.status_code < 400
73 else DiagnosticStatus.WARN
74 )
75 self.diagnostics.record(
76 status,
77 "HTTP",
78 f"{method_upper} response",
79 url=url,
80 status_code=response.status_code,
81 )
82 if response.status_code not in RETRYABLE_STATUS_CODES or attempt >= retries:
83 return response
84 if self.diagnostics is not None:
85 self.diagnostics.record(
86 DiagnosticStatus.WARN,
87 "HTTP",
88 f"Retrying {method_upper} response ({attempt + 1}/{retries})",
89 url=url,
90 status_code=response.status_code,
91 )
92 await asyncio.sleep(_retry_after(response) or _backoff(attempt))
93 raise AssertionError("HTTP retry loop did not return or raise")
96class HttpClientFactory:
97 """Create consistently configured clients for registry and GitHub calls."""
99 def __init__(
100 self, *, max_retries: int = 2, diagnostics: DiagnosticLog | None = None
101 ) -> None:
102 self.max_retries = max_retries
103 self.diagnostics = diagnostics
105 def create(
106 self,
107 *,
108 headers: dict[str, str] | None = None,
109 timeout: httpx.Timeout | float | None = None,
110 diagnostics: DiagnosticLog | None = None,
111 ) -> SecchiAsyncClient:
112 return SecchiAsyncClient(
113 headers=headers,
114 timeout=timeout or httpx.Timeout(10.0),
115 max_retries=self.max_retries,
116 diagnostics=diagnostics or self.diagnostics,
117 )
120def _backoff(attempt: int) -> float:
121 return min(2.0, 0.25 * (2**attempt)) + random.uniform(0, 0.05)
124def _retry_after(response: httpx.Response) -> float | None:
125 raw = response.headers.get("retry-after")
126 if not raw:
127 return None
128 try:
129 return max(0.0, min(10.0, float(raw)))
130 except ValueError:
131 try:
132 date = parsedate_to_datetime(raw)
133 if date.tzinfo is None:
134 date = date.replace(tzinfo=UTC)
135 return max(
136 0.0,
137 min(10.0, date.timestamp() - datetime.now(UTC).timestamp()),
138 )
139 except (TypeError, ValueError, OverflowError):
140 return None