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

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

2 

3from __future__ import annotations 

4 

5import asyncio 

6import random 

7from datetime import UTC, datetime 

8from email.utils import parsedate_to_datetime 

9from typing import Any 

10 

11import httpx 

12 

13from secchi.diagnostics import ( 

14 DiagnosticLog, 

15 DiagnosticStatus, 

16 diagnostic_for_http_error, 

17) 

18 

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

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

21 

22 

23class SecchiAsyncClient(httpx.AsyncClient): 

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

25 

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 ) 

40 

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 

68 

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

94 

95 

96class HttpClientFactory: 

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

98 

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 

104 

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 ) 

118 

119 

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

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

122 

123 

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