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

1"""Shared HTTP client defaults and safe transient retry behavior.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import random 

7from datetime import datetime, timezone 

8from email.utils import parsedate_to_datetime 

9from typing import Any 

10 

11import httpx 

12 

13RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504} 

14RETRYABLE_METHODS = {"GET", "HEAD", "OPTIONS"} 

15 

16 

17class SecchiAsyncClient(httpx.AsyncClient): 

18 """AsyncClient with shared defaults and bounded retries for safe requests.""" 

19 

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 ) 

27 

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 

41 

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") 

46 

47 

48class HttpClientFactory: 

49 """Create consistently configured clients for registry and GitHub calls.""" 

50 

51 def __init__(self, *, max_retries: int = 2) -> None: 

52 self.max_retries = max_retries 

53 

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 ) 

65 

66 

67def _backoff(attempt: int) -> float: 

68 return min(2.0, 0.25 * (2**attempt)) + random.uniform(0, 0.05) 

69 

70 

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