httpware 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpware
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Resilience-first async HTTP client framework for Python
5
5
  Keywords: http,async,client,resilience,retry,circuit-breaker,middleware,httpx,pydantic
6
6
  Author: Artur Shiriev
@@ -15,17 +15,14 @@ Classifier: Topic :: Software Development :: Libraries
15
15
  Classifier: Topic :: Internet :: WWW/HTTP
16
16
  Classifier: Framework :: AsyncIO
17
17
  Requires-Dist: httpx2>=2.0.0,<3.0
18
- Requires-Dist: httpware[pydantic,msgspec,otel] ; extra == 'all'
18
+ Requires-Dist: httpware[pydantic,msgspec] ; extra == 'all'
19
19
  Requires-Dist: msgspec>=0.18 ; extra == 'msgspec'
20
- Requires-Dist: opentelemetry-api>=1.20 ; extra == 'otel'
21
- Requires-Dist: opentelemetry-sdk>=1.20 ; extra == 'otel'
22
20
  Requires-Dist: pydantic>=2.0,<3.0 ; extra == 'pydantic'
23
21
  Requires-Python: >=3.11, <4
24
22
  Project-URL: repository, https://github.com/modern-python/httpware
25
23
  Project-URL: docs, https://httpware.readthedocs.io
26
24
  Provides-Extra: all
27
25
  Provides-Extra: msgspec
28
- Provides-Extra: otel
29
26
  Provides-Extra: pydantic
30
27
  Description-Content-Type: text/markdown
31
28
 
@@ -38,9 +35,9 @@ Description-Content-Type: text/markdown
38
35
 
39
36
  **Async HTTP client framework for Python.**
40
37
 
41
- `httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx.
38
+ `httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a small resilience suite — `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — under `httpware.middleware.resilience`.
42
39
 
43
- > **Status:** Pre-1.0 (0.3.0). Public API is subject to change between minor releases until v1.0. Resilience middleware (retry / timeout / bulkhead), streaming, and observability are not yet shipped.
40
+ > **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0. Streaming and observability are not yet shipped.
44
41
 
45
42
  ## Install
46
43
 
@@ -48,10 +45,10 @@ Description-Content-Type: text/markdown
48
45
  pip install httpware # core only — no decoder
49
46
  pip install httpware[pydantic] # + PydanticDecoder (the default-decoder path)
50
47
  pip install httpware[msgspec] # + MsgspecDecoder
51
- pip install httpware[all] # everything declared above (pydantic, msgspec, otel)
48
+ pip install httpware[all] # everything declared above (pydantic, msgspec)
52
49
  ```
53
50
 
54
- `AsyncClient()` with no `decoder=` argument defaults to constructing a `PydanticDecoder`; that path requires the `pydantic` extra and raises `ImportError` at `AsyncClient.__init__` if it is missing. The `otel` extra is declared but the OpenTelemetry middleware (Epic 5) has not shipped yet.
51
+ `AsyncClient()` with no `decoder=` argument defaults to constructing a `PydanticDecoder`; that path requires the `pydantic` extra and raises `ImportError` at `AsyncClient.__init__` if it is missing.
55
52
 
56
53
  ## Quickstart
57
54
 
@@ -73,7 +70,30 @@ async def main() -> None:
73
70
  print(user.name)
74
71
  ```
75
72
 
76
- ## 📚 [Documentation](https://httpware.readthedocs.io)
73
+ ### With resilience middleware
74
+
75
+ Compose resilience middleware at construction; `Bulkhead` goes outside `Retry` so one slot covers all retry attempts.
76
+
77
+ ```python
78
+ from httpware import AsyncClient, Bulkhead, Retry
79
+
80
+
81
+ async def main() -> None:
82
+ async with AsyncClient(
83
+ base_url="https://api.example.com",
84
+ middleware=[
85
+ Bulkhead(max_concurrent=10), # cap total in-flight
86
+ Retry(), # default: 3 attempts, full-jitter backoff
87
+ ],
88
+ ) as client:
89
+ user = await client.get("/users/1", response_model=User)
90
+ ```
91
+
92
+ ## Errors
93
+
94
+ All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `ServiceUnavailableError`, `RateLimitedError`, etc. — all subclasses of `httpware.StatusError`. Transport-layer transient failures raise `NetworkError`; the resilience middleware raise `RetryBudgetExhaustedError` and `BulkheadFullError`. Everything inherits `httpware.ClientError`.
95
+
96
+ ## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
77
97
 
78
98
  ## 📦 [PyPI](https://pypi.org/project/httpware)
79
99
 
@@ -7,9 +7,9 @@
7
7
 
8
8
  **Async HTTP client framework for Python.**
9
9
 
10
- `httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx.
10
+ `httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a small resilience suite — `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — under `httpware.middleware.resilience`.
11
11
 
12
- > **Status:** Pre-1.0 (0.3.0). Public API is subject to change between minor releases until v1.0. Resilience middleware (retry / timeout / bulkhead), streaming, and observability are not yet shipped.
12
+ > **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0. Streaming and observability are not yet shipped.
13
13
 
14
14
  ## Install
15
15
 
@@ -17,10 +17,10 @@
17
17
  pip install httpware # core only — no decoder
18
18
  pip install httpware[pydantic] # + PydanticDecoder (the default-decoder path)
19
19
  pip install httpware[msgspec] # + MsgspecDecoder
20
- pip install httpware[all] # everything declared above (pydantic, msgspec, otel)
20
+ pip install httpware[all] # everything declared above (pydantic, msgspec)
21
21
  ```
22
22
 
23
- `AsyncClient()` with no `decoder=` argument defaults to constructing a `PydanticDecoder`; that path requires the `pydantic` extra and raises `ImportError` at `AsyncClient.__init__` if it is missing. The `otel` extra is declared but the OpenTelemetry middleware (Epic 5) has not shipped yet.
23
+ `AsyncClient()` with no `decoder=` argument defaults to constructing a `PydanticDecoder`; that path requires the `pydantic` extra and raises `ImportError` at `AsyncClient.__init__` if it is missing.
24
24
 
25
25
  ## Quickstart
26
26
 
@@ -42,7 +42,30 @@ async def main() -> None:
42
42
  print(user.name)
43
43
  ```
44
44
 
45
- ## 📚 [Documentation](https://httpware.readthedocs.io)
45
+ ### With resilience middleware
46
+
47
+ Compose resilience middleware at construction; `Bulkhead` goes outside `Retry` so one slot covers all retry attempts.
48
+
49
+ ```python
50
+ from httpware import AsyncClient, Bulkhead, Retry
51
+
52
+
53
+ async def main() -> None:
54
+ async with AsyncClient(
55
+ base_url="https://api.example.com",
56
+ middleware=[
57
+ Bulkhead(max_concurrent=10), # cap total in-flight
58
+ Retry(), # default: 3 attempts, full-jitter backoff
59
+ ],
60
+ ) as client:
61
+ user = await client.get("/users/1", response_model=User)
62
+ ```
63
+
64
+ ## Errors
65
+
66
+ All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `ServiceUnavailableError`, `RateLimitedError`, etc. — all subclasses of `httpware.StatusError`. Transport-layer transient failures raise `NetworkError`; the resilience middleware raise `RetryBudgetExhaustedError` and `BulkheadFullError`. Everything inherits `httpware.ClientError`.
67
+
68
+ ## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
46
69
 
47
70
  ## 📦 [PyPI](https://pypi.org/project/httpware)
48
71
 
@@ -26,7 +26,7 @@ classifiers = [
26
26
  "Topic :: Internet :: WWW/HTTP",
27
27
  "Framework :: AsyncIO",
28
28
  ]
29
- version = "0.3.0"
29
+ version = "0.4.0"
30
30
  dependencies = [
31
31
  "httpx2>=2.0.0,<3.0",
32
32
  ]
@@ -34,11 +34,7 @@ dependencies = [
34
34
  [project.optional-dependencies]
35
35
  pydantic = ["pydantic>=2.0,<3.0"]
36
36
  msgspec = ["msgspec>=0.18"]
37
- otel = [
38
- "opentelemetry-api>=1.20",
39
- "opentelemetry-sdk>=1.20",
40
- ]
41
- all = ["httpware[pydantic,msgspec,otel]"]
37
+ all = ["httpware[pydantic,msgspec]"]
42
38
 
43
39
  [project.urls]
44
40
  repository = "https://github.com/modern-python/httpware"
@@ -5,13 +5,16 @@ from httpware.decoders import ResponseDecoder
5
5
  from httpware.errors import (
6
6
  STATUS_TO_EXCEPTION,
7
7
  BadRequestError,
8
+ BulkheadFullError,
8
9
  ClientError,
9
10
  ClientStatusError,
10
11
  ConflictError,
11
12
  ForbiddenError,
12
13
  InternalServerError,
14
+ NetworkError,
13
15
  NotFoundError,
14
16
  RateLimitedError,
17
+ RetryBudgetExhaustedError,
15
18
  ServerStatusError,
16
19
  ServiceUnavailableError,
17
20
  StatusError,
@@ -21,22 +24,29 @@ from httpware.errors import (
21
24
  UnprocessableEntityError,
22
25
  )
23
26
  from httpware.middleware import Middleware, Next, after_response, before_request, on_error
27
+ from httpware.middleware.resilience import Bulkhead, Retry, RetryBudget
24
28
 
25
29
 
26
30
  __all__ = [
27
31
  "STATUS_TO_EXCEPTION",
28
32
  "AsyncClient",
29
33
  "BadRequestError",
34
+ "Bulkhead",
35
+ "BulkheadFullError",
30
36
  "ClientError",
31
37
  "ClientStatusError",
32
38
  "ConflictError",
33
39
  "ForbiddenError",
34
40
  "InternalServerError",
35
41
  "Middleware",
42
+ "NetworkError",
36
43
  "Next",
37
44
  "NotFoundError",
38
45
  "RateLimitedError",
39
46
  "ResponseDecoder",
47
+ "Retry",
48
+ "RetryBudget",
49
+ "RetryBudgetExhaustedError",
40
50
  "ServerStatusError",
41
51
  "ServiceUnavailableError",
42
52
  "StatusError",
@@ -11,6 +11,7 @@ from httpware.decoders import ResponseDecoder
11
11
  from httpware.errors import (
12
12
  STATUS_TO_EXCEPTION,
13
13
  ClientStatusError,
14
+ NetworkError,
14
15
  ServerStatusError,
15
16
  TimeoutError, # noqa: A004
16
17
  TransportError,
@@ -110,6 +111,8 @@ class AsyncClient:
110
111
  raise TimeoutError(str(exc)) from exc
111
112
  except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
112
113
  raise TransportError(str(exc)) from exc
114
+ except httpx2.NetworkError as exc:
115
+ raise NetworkError(str(exc)) from exc
113
116
  except httpx2.HTTPError as exc:
114
117
  raise TransportError(str(exc)) from exc
115
118
  except RuntimeError as exc:
@@ -40,6 +40,14 @@ class TransportError(ClientError):
40
40
  """Connection / network / protocol failure raised before a response was received."""
41
41
 
42
42
 
43
+ class NetworkError(TransportError):
44
+ """Transient network-layer failure (connect/read/write/close). Safe to retry.
45
+
46
+ Pool-acquisition timeouts are NOT under this class; they raise ``TimeoutError``
47
+ via ``httpx2.PoolTimeout`` (a ``TimeoutException`` subclass).
48
+ """
49
+
50
+
43
51
  class TimeoutError(ClientError, builtins.TimeoutError): # noqa: A001
44
52
  """Client-side timeout (connect / read / write / pool).
45
53
 
@@ -136,3 +144,71 @@ STATUS_TO_EXCEPTION: Mapping[int, type[StatusError]] = {
136
144
  500: InternalServerError,
137
145
  503: ServiceUnavailableError,
138
146
  }
147
+
148
+
149
+ def _reconstruct_budget_exhausted(
150
+ cls: "type[RetryBudgetExhaustedError]",
151
+ last_response: httpx2.Response | None,
152
+ last_exception: BaseException | None,
153
+ attempts: int,
154
+ ) -> "RetryBudgetExhaustedError":
155
+ return cls(last_response=last_response, last_exception=last_exception, attempts=attempts)
156
+
157
+
158
+ class RetryBudgetExhaustedError(ClientError):
159
+ """Raised when a retry was needed but the RetryBudget refused to permit it.
160
+
161
+ Carries the last response and/or exception observed before the budget refused,
162
+ plus the number of attempts already completed.
163
+ """
164
+
165
+ last_response: httpx2.Response | None
166
+ last_exception: BaseException | None
167
+ attempts: int
168
+
169
+ def __init__(
170
+ self,
171
+ *,
172
+ last_response: httpx2.Response | None,
173
+ last_exception: BaseException | None,
174
+ attempts: int,
175
+ ) -> None:
176
+ self.last_response = last_response
177
+ self.last_exception = last_exception
178
+ self.attempts = attempts
179
+ super().__init__(f"retry budget exhausted after {attempts} attempt(s)")
180
+
181
+ def __reduce__(self) -> tuple[Any, ...]:
182
+ return (
183
+ _reconstruct_budget_exhausted,
184
+ (type(self), self.last_response, self.last_exception, self.attempts),
185
+ )
186
+
187
+
188
+ def _reconstruct_bulkhead_full(
189
+ cls: "type[BulkheadFullError]",
190
+ max_concurrent: int,
191
+ acquire_timeout: float | None,
192
+ ) -> "BulkheadFullError":
193
+ return cls(max_concurrent=max_concurrent, acquire_timeout=acquire_timeout)
194
+
195
+
196
+ class BulkheadFullError(ClientError):
197
+ """Raised when ``acquire_timeout`` elapses before a Bulkhead slot becomes available.
198
+
199
+ Carries the configured caps for caller logging/alerting.
200
+ """
201
+
202
+ max_concurrent: int
203
+ acquire_timeout: float | None
204
+
205
+ def __init__(self, *, max_concurrent: int, acquire_timeout: float | None) -> None:
206
+ self.max_concurrent = max_concurrent
207
+ self.acquire_timeout = acquire_timeout
208
+ super().__init__(f"bulkhead full (max_concurrent={max_concurrent}, acquire_timeout={acquire_timeout})")
209
+
210
+ def __reduce__(self) -> tuple[Any, ...]:
211
+ return (
212
+ _reconstruct_bulkhead_full,
213
+ (type(self), self.max_concurrent, self.acquire_timeout),
214
+ )
@@ -0,0 +1,8 @@
1
+ """Resilience primitives: Bulkhead, Retry middleware, and RetryBudget token bucket."""
2
+
3
+ from httpware.middleware.resilience.budget import RetryBudget
4
+ from httpware.middleware.resilience.bulkhead import Bulkhead
5
+ from httpware.middleware.resilience.retry import Retry
6
+
7
+
8
+ __all__ = ["Bulkhead", "Retry", "RetryBudget"]
@@ -0,0 +1,26 @@
1
+ """Full-jitter exponential backoff helper (private)."""
2
+
3
+ import random
4
+ from collections.abc import Callable
5
+
6
+
7
+ def full_jitter_delay(
8
+ attempt_index: int,
9
+ *,
10
+ base_delay: float,
11
+ max_delay: float,
12
+ _random_uniform: Callable[[float, float], float] = random.uniform,
13
+ ) -> float:
14
+ """Return a backoff delay using AWS's "full jitter" formulation.
15
+
16
+ sleep = uniform(0, min(max_delay, base_delay * 2.0 ** attempt_index))
17
+
18
+ `attempt_index` is 0 for the first retry, 1 for the second, etc.
19
+
20
+ Uses ``2.0 **`` (float exponentiation) rather than ``2 **`` so that
21
+ ``attempt_index >= 1024`` saturates to ``math.inf`` and ``min`` clamps to
22
+ ``max_delay`` — ``2 ** 1024`` would raise ``OverflowError`` during the
23
+ int→float conversion.
24
+ """
25
+ ceiling = min(max_delay, base_delay * (2.0**attempt_index))
26
+ return _random_uniform(0.0, ceiling)
@@ -0,0 +1,64 @@
1
+ """Finagle-style token-bucket retry budget.
2
+
3
+ See planning/specs/2026-06-05-retry-and-retry-budget-design.md for the contract.
4
+ No locking: asyncio runs coroutines cooperatively on a single thread, so deque
5
+ mutations between await points are atomic with respect to other coroutines on
6
+ the same event loop. Cross-thread use is out of scope.
7
+ """
8
+
9
+ import time
10
+ from collections import deque
11
+ from collections.abc import Callable
12
+
13
+
14
+ class RetryBudget:
15
+ """Token-bucket budget bounding retry rate to prevent retry storms.
16
+
17
+ Each request deposits a token; each retry attempts to withdraw one.
18
+ Available retries are bounded by `percent_can_retry` of recent deposits,
19
+ plus a `min_retries_per_sec * ttl` floor.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ *,
25
+ ttl: float = 10.0,
26
+ min_retries_per_sec: float = 10.0,
27
+ percent_can_retry: float = 0.2,
28
+ _now: Callable[[], float] = time.monotonic,
29
+ ) -> None:
30
+ self._ttl = ttl
31
+ self._min_retries_per_sec = min_retries_per_sec
32
+ self._percent_can_retry = percent_can_retry
33
+ self._now = _now
34
+ self._deposits: deque[float] = deque()
35
+ self._withdrawn: deque[float] = deque()
36
+
37
+ def _purge(self, now: float) -> None:
38
+ # Strict `< cutoff` keeps entries at exactly `now - ttl`: window is [now - ttl, now].
39
+ cutoff = now - self._ttl
40
+ while self._deposits and self._deposits[0] < cutoff:
41
+ self._deposits.popleft()
42
+ while self._withdrawn and self._withdrawn[0] < cutoff:
43
+ self._withdrawn.popleft()
44
+
45
+ def deposit(self) -> None:
46
+ """Record a request (success or failure attempt). Adds one token."""
47
+ now = self._now()
48
+ self._purge(now)
49
+ self._deposits.append(now)
50
+
51
+ def try_withdraw(self) -> bool:
52
+ """Atomically attempt to spend one retry token.
53
+
54
+ Returns True if a retry is permitted, False if the budget is exhausted.
55
+ Never blocks.
56
+ """
57
+ now = self._now()
58
+ self._purge(now)
59
+ floor = int(self._min_retries_per_sec * self._ttl)
60
+ ceiling = int(len(self._deposits) * self._percent_can_retry) + floor
61
+ if len(self._withdrawn) >= ceiling:
62
+ return False
63
+ self._withdrawn.append(now)
64
+ return True
@@ -0,0 +1,75 @@
1
+ """Bulkhead middleware — concurrency limiter via asyncio.Semaphore.
2
+
3
+ See planning/specs/2026-06-05-bulkhead-design.md for the contract.
4
+
5
+ The middleware owns an asyncio.Semaphore(max_concurrent). On each request,
6
+ it acquires a slot (bounded by acquire_timeout via asyncio.timeout) and
7
+ releases the slot in a try/finally so success, exceptions, and cancellation
8
+ all release deterministically.
9
+
10
+ Bulkhead is the sharable unit — pass the same instance to multiple
11
+ AsyncClient(middleware=[shared]) calls to enforce a joint cap across clients.
12
+ """
13
+
14
+ import asyncio
15
+
16
+ import httpx2
17
+
18
+ from httpware.errors import BulkheadFullError
19
+ from httpware.middleware import Next
20
+
21
+
22
+ _MAX_CONCURRENT_INVALID = "max_concurrent must be >= 1"
23
+ _ACQUIRE_TIMEOUT_INVALID = "acquire_timeout must be >= 0"
24
+
25
+
26
+ class Bulkhead:
27
+ """Concurrency limiter middleware backed by ``asyncio.Semaphore``.
28
+
29
+ Parameters
30
+ ----------
31
+ max_concurrent
32
+ Required. Maximum number of in-flight requests this Bulkhead permits.
33
+ Must be ``>= 1``. There is no default because no value is universally
34
+ correct — the right cap depends on downstream capacity and SLA.
35
+ acquire_timeout
36
+ Seconds to wait for a slot before raising ``BulkheadFullError``.
37
+ Defaults to ``1.0``. ``None`` waits forever; ``0`` fails fast. Must be
38
+ ``>= 0`` (or ``None``).
39
+
40
+ See the module docstring for the algorithm and middleware-ordering guidance.
41
+
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ max_concurrent: int,
48
+ acquire_timeout: float | None = 1.0,
49
+ ) -> None:
50
+ if max_concurrent < 1:
51
+ raise ValueError(_MAX_CONCURRENT_INVALID)
52
+ if acquire_timeout is not None and acquire_timeout < 0:
53
+ raise ValueError(_ACQUIRE_TIMEOUT_INVALID)
54
+ self._max_concurrent = max_concurrent
55
+ self._acquire_timeout = acquire_timeout
56
+ self._sem = asyncio.Semaphore(max_concurrent)
57
+
58
+ async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002
59
+ """Acquire a slot (bounded by acquire_timeout), invoke next, release."""
60
+ try:
61
+ if self._acquire_timeout is None:
62
+ await self._sem.acquire()
63
+ else:
64
+ async with asyncio.timeout(self._acquire_timeout):
65
+ await self._sem.acquire()
66
+ except TimeoutError as exc:
67
+ raise BulkheadFullError(
68
+ max_concurrent=self._max_concurrent,
69
+ acquire_timeout=self._acquire_timeout,
70
+ ) from exc
71
+
72
+ try:
73
+ return await next(request)
74
+ finally:
75
+ self._sem.release()
@@ -0,0 +1,158 @@
1
+ """Retry middleware — automatic retry of transient failures with budget control.
2
+
3
+ See planning/specs/2026-06-05-retry-and-retry-budget-design.md for the full contract.
4
+
5
+ Status-code retry: the AsyncClient terminal raises StatusError subclasses on 4xx/5xx,
6
+ so Retry catches StatusError and inspects exc.response.status_code. The original
7
+ StatusError subclass is re-raised unwrapped on exhaustion, with a PEP 678 note added.
8
+ """
9
+
10
+ import asyncio
11
+ import builtins
12
+ import datetime
13
+ import email.utils
14
+ from collections.abc import Awaitable, Callable
15
+ from http import HTTPStatus
16
+
17
+ import httpx2
18
+
19
+ from httpware.errors import NetworkError, RetryBudgetExhaustedError, StatusError, TimeoutError # noqa: A004
20
+ from httpware.middleware import Next
21
+ from httpware.middleware.resilience._backoff import full_jitter_delay
22
+ from httpware.middleware.resilience.budget import RetryBudget
23
+
24
+
25
+ DEFAULT_RETRY_STATUS_CODES = frozenset(
26
+ {
27
+ int(HTTPStatus.REQUEST_TIMEOUT),
28
+ int(HTTPStatus.TOO_MANY_REQUESTS),
29
+ int(HTTPStatus.BAD_GATEWAY),
30
+ int(HTTPStatus.SERVICE_UNAVAILABLE),
31
+ int(HTTPStatus.GATEWAY_TIMEOUT),
32
+ }
33
+ )
34
+
35
+ DEFAULT_IDEMPOTENT_METHODS = frozenset(
36
+ {
37
+ "GET",
38
+ "HEAD",
39
+ "OPTIONS",
40
+ "PUT",
41
+ "DELETE",
42
+ }
43
+ )
44
+
45
+ _MAX_ATTEMPTS_INVALID = "max_attempts must be >= 1"
46
+
47
+
48
+ def _parse_retry_after(value: str) -> float | None:
49
+ """Parse a Retry-After header value. Returns None on malformed input."""
50
+ try:
51
+ return max(0.0, float(int(value))) # clamp: negative integers are malformed servers
52
+ except ValueError:
53
+ pass
54
+ try:
55
+ parsed = email.utils.parsedate_to_datetime(value)
56
+ except (TypeError, ValueError):
57
+ return None
58
+ if parsed is None: # pragma: no cover — parsedate_to_datetime raises rather than returning None in CPython 3.11+
59
+ return None
60
+ now = datetime.datetime.now(datetime.UTC)
61
+ delta = (parsed - now).total_seconds()
62
+ return max(0.0, delta)
63
+
64
+
65
+ class Retry:
66
+ """Retry middleware. See module docstring for default policy."""
67
+
68
+ def __init__( # noqa: PLR0913 — retry policy has many orthogonal knobs; a dataclass would be worse
69
+ self,
70
+ *,
71
+ max_attempts: int = 3,
72
+ base_delay: float = 0.1,
73
+ max_delay: float = 5.0,
74
+ attempt_timeout: float | None = None,
75
+ retry_status_codes: frozenset[int] = DEFAULT_RETRY_STATUS_CODES,
76
+ retry_methods: frozenset[str] = DEFAULT_IDEMPOTENT_METHODS,
77
+ respect_retry_after: bool = True,
78
+ budget: RetryBudget | None = None,
79
+ _sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
80
+ ) -> None:
81
+ if max_attempts < 1:
82
+ raise ValueError(_MAX_ATTEMPTS_INVALID)
83
+ self.max_attempts = max_attempts
84
+ self.base_delay = base_delay
85
+ self.max_delay = max_delay
86
+ self.attempt_timeout = attempt_timeout
87
+ self.retry_status_codes = retry_status_codes
88
+ self.retry_methods = retry_methods
89
+ self.respect_retry_after = respect_retry_after
90
+ self.budget = budget if budget is not None else RetryBudget()
91
+ self._sleep = _sleep
92
+
93
+ async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002, C901, PLR0912 — complexity budget: 3 error clauses + idempotency gate + budget gate + Retry-After branch + backoff
94
+ """Process a request through the retry loop. See module docstring."""
95
+ method_eligible = request.method.upper() in self.retry_methods
96
+ last_exc: BaseException | None = None
97
+ last_response: httpx2.Response | None = None
98
+
99
+ for attempt in range(self.max_attempts):
100
+ is_last = attempt + 1 >= self.max_attempts
101
+ self.budget.deposit()
102
+ try:
103
+ if self.attempt_timeout is not None:
104
+ async with asyncio.timeout(self.attempt_timeout):
105
+ return await next(request)
106
+ else:
107
+ return await next(request)
108
+ except StatusError as exc:
109
+ if not method_eligible or exc.response.status_code not in self.retry_status_codes:
110
+ raise
111
+ last_exc = exc
112
+ last_response = exc.response
113
+ except (NetworkError, TimeoutError) as exc:
114
+ if not method_eligible:
115
+ raise
116
+ last_exc = exc
117
+ last_response = None
118
+ except builtins.TimeoutError as exc:
119
+ wrapped = TimeoutError("attempt timed out")
120
+ wrapped.__cause__ = exc # set now; the retry path (last_exc = wrapped) has no `from` clause
121
+ if not method_eligible:
122
+ raise wrapped from exc
123
+ last_exc = wrapped
124
+ last_response = None
125
+
126
+ # ---- retryable failure path
127
+ if is_last:
128
+ if last_exc is None: # pragma: no cover — structural invariant from except branch
129
+ msg = "Retry: last_exc unset on final attempt — unreachable"
130
+ raise AssertionError(msg)
131
+ last_exc.add_note(f"httpware: gave up after {attempt + 1} attempts")
132
+ raise last_exc
133
+
134
+ if not self.budget.try_withdraw():
135
+ raise RetryBudgetExhaustedError(
136
+ last_response=last_response,
137
+ last_exception=last_exc,
138
+ attempts=attempt + 1,
139
+ ) from last_exc
140
+
141
+ retry_after: float | None = None
142
+ if self.respect_retry_after and last_response is not None:
143
+ header = last_response.headers.get("Retry-After")
144
+ if header is not None:
145
+ retry_after = _parse_retry_after(header)
146
+
147
+ if retry_after is not None:
148
+ delay = min(retry_after, self.max_delay)
149
+ else:
150
+ delay = full_jitter_delay(
151
+ attempt,
152
+ base_delay=self.base_delay,
153
+ max_delay=self.max_delay,
154
+ )
155
+ await self._sleep(delay)
156
+
157
+ msg = "unreachable" # pragma: no cover
158
+ raise AssertionError(msg) # pragma: no cover
File without changes