Coverage for src/hush_hush/retry.py: 65%

84 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-10-16 16:01 +0000

1"""Retry transports: network failure or an HTTP 5xx/429 response is retried 

2with exponential backoff and jitter, honoring a Retry-After header ahead of 

3the computed backoff delay when present. Any other 4xx is never retried — 

4it won't succeed on a second attempt, and retrying only delays the real 

5error reaching the caller. 

6""" 

7 

8from __future__ import annotations 

9 

10import email.utils 

11import random 

12import time 

13from datetime import UTC, datetime 

14 

15import httpx 

16 

17DEFAULT_MAX_RETRIES = 3 

18 

19 

20def _is_retryable_status(status_code: int) -> bool: 

21 return status_code >= 500 or status_code == 429 

22 

23 

24def _retry_after_seconds(response: httpx.Response) -> float | None: 

25 value = response.headers.get("Retry-After") 

26 if not value: 

27 return None 

28 try: 

29 seconds = int(value) 

30 return float(seconds) if seconds >= 0 else None 

31 except ValueError: 

32 pass 

33 try: 

34 when = email.utils.parsedate_to_datetime(value) 

35 if when.tzinfo is None: 

36 when = when.replace(tzinfo=UTC) 

37 delta = (when - datetime.now(UTC)).total_seconds() 

38 return max(delta, 0.0) 

39 except (TypeError, ValueError): 

40 return None 

41 

42 

43def _backoff_seconds(attempt: int, response: httpx.Response | None) -> float: 

44 if response is not None: 

45 retry_after = _retry_after_seconds(response) 

46 if retry_after is not None: 

47 return retry_after 

48 base = 0.1 * (2 ** (attempt - 1)) 

49 return base + random.uniform(0, base) 

50 

51 

52class RetryTransport(httpx.BaseTransport): 

53 """Wraps a sync transport with hush-hush's retry policy.""" 

54 

55 def __init__( 

56 self, transport: httpx.BaseTransport, max_retries: int = DEFAULT_MAX_RETRIES 

57 ) -> None: 

58 self._transport = transport 

59 self._max_retries = max_retries 

60 

61 def handle_request(self, request: httpx.Request) -> httpx.Response: 

62 last_response: httpx.Response | None = None 

63 last_error: Exception | None = None 

64 

65 for attempt in range(self._max_retries + 1): 

66 if attempt > 0: 

67 time.sleep(_backoff_seconds(attempt, last_response)) 

68 try: 

69 response = self._transport.handle_request(request) 

70 except httpx.TransportError as exc: 

71 last_error, last_response = exc, None 

72 continue 

73 if not _is_retryable_status(response.status_code) or attempt == self._max_retries: 

74 return response 

75 response.close() 

76 last_response, last_error = response, None 

77 

78 if last_error is not None: 

79 raise last_error 

80 assert last_response is not None 

81 return last_response 

82 

83 def close(self) -> None: 

84 self._transport.close() 

85 

86 

87class AsyncRetryTransport(httpx.AsyncBaseTransport): 

88 """Wraps an async transport with hush-hush's retry policy.""" 

89 

90 def __init__( 

91 self, transport: httpx.AsyncBaseTransport, max_retries: int = DEFAULT_MAX_RETRIES 

92 ) -> None: 

93 self._transport = transport 

94 self._max_retries = max_retries 

95 

96 async def handle_async_request(self, request: httpx.Request) -> httpx.Response: 

97 import asyncio 

98 

99 last_response: httpx.Response | None = None 

100 last_error: Exception | None = None 

101 

102 for attempt in range(self._max_retries + 1): 

103 if attempt > 0: 

104 await asyncio.sleep(_backoff_seconds(attempt, last_response)) 

105 try: 

106 response = await self._transport.handle_async_request(request) 

107 except httpx.TransportError as exc: 

108 last_error, last_response = exc, None 

109 continue 

110 if not _is_retryable_status(response.status_code) or attempt == self._max_retries: 

111 return response 

112 await response.aclose() 

113 last_response, last_error = response, None 

114 

115 if last_error is not None: 

116 raise last_error 

117 assert last_response is not None 

118 return last_response 

119 

120 async def aclose(self) -> None: 

121 await self._transport.aclose()