httpware 0.3.0__tar.gz → 0.5.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.5.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,49 @@ 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
+ ### Streaming responses
93
+
94
+ For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager:
95
+
96
+ ```python
97
+ from httpware import AsyncClient
98
+
99
+
100
+ async def main() -> None:
101
+ async with AsyncClient(base_url="https://api.example.com") as client:
102
+ async with client.stream("GET", "/big-file") as response:
103
+ async for chunk in response.aiter_bytes():
104
+ process(chunk)
105
+ ```
106
+
107
+ `stream()` auto-raises `StatusError` subclasses on 4xx/5xx with the response body pre-read, so `exc.response.content` is accessible from the caught exception.
108
+
109
+ It does NOT pass through the middleware chain: `Retry`, `Bulkhead`, and any custom middleware are bypassed. (Retry separately refuses to retry any request — stream or non-stream — whose body was an async-iterable, since streams can't replay across attempts.)
110
+
111
+ ## Errors
112
+
113
+ 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`.
114
+
115
+ ## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
77
116
 
78
117
  ## 📦 [PyPI](https://pypi.org/project/httpware)
79
118
 
@@ -0,0 +1,95 @@
1
+ # httpware
2
+
3
+ [![Test](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
4
+ [![PyPI version](https://badge.fury.io/py/httpware.svg)](https://pypi.org/project/httpware/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ **Async HTTP client framework for Python.**
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. It also ships a small resilience suite — `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — under `httpware.middleware.resilience`.
11
+
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
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install httpware # core only — no decoder
18
+ pip install httpware[pydantic] # + PydanticDecoder (the default-decoder path)
19
+ pip install httpware[msgspec] # + MsgspecDecoder
20
+ pip install httpware[all] # everything declared above (pydantic, msgspec)
21
+ ```
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.
24
+
25
+ ## Quickstart
26
+
27
+ > Requires: `pip install httpware[pydantic]`
28
+
29
+ ```python
30
+ from httpware import AsyncClient
31
+ from pydantic import BaseModel
32
+
33
+
34
+ class User(BaseModel):
35
+ id: int
36
+ name: str
37
+
38
+
39
+ async def main() -> None:
40
+ async with AsyncClient(base_url="https://api.example.com") as client:
41
+ user = await client.get("/users/1", response_model=User)
42
+ print(user.name)
43
+ ```
44
+
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
+ ### Streaming responses
65
+
66
+ For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager:
67
+
68
+ ```python
69
+ from httpware import AsyncClient
70
+
71
+
72
+ async def main() -> None:
73
+ async with AsyncClient(base_url="https://api.example.com") as client:
74
+ async with client.stream("GET", "/big-file") as response:
75
+ async for chunk in response.aiter_bytes():
76
+ process(chunk)
77
+ ```
78
+
79
+ `stream()` auto-raises `StatusError` subclasses on 4xx/5xx with the response body pre-read, so `exc.response.content` is accessible from the caught exception.
80
+
81
+ It does NOT pass through the middleware chain: `Retry`, `Bulkhead`, and any custom middleware are bypassed. (Retry separately refuses to retry any request — stream or non-stream — whose body was an async-iterable, since streams can't replay across attempts.)
82
+
83
+ ## Errors
84
+
85
+ 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`.
86
+
87
+ ## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
88
+
89
+ ## 📦 [PyPI](https://pypi.org/project/httpware)
90
+
91
+ ## 📝 [License](./LICENSE)
92
+
93
+ ## Part of `modern-python`
94
+
95
+ Browse the full list of templates and libraries in [`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
@@ -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.5.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",
@@ -1,7 +1,8 @@
1
1
  """AsyncClient — the thin httpx2 wrapper."""
2
2
 
3
+ import contextlib
3
4
  import typing
4
- from collections.abc import Sequence
5
+ from collections.abc import AsyncIterator, Sequence
5
6
  from http import HTTPStatus
6
7
 
7
8
  import httpx2
@@ -11,6 +12,7 @@ from httpware.decoders import ResponseDecoder
11
12
  from httpware.errors import (
12
13
  STATUS_TO_EXCEPTION,
13
14
  ClientStatusError,
15
+ NetworkError,
14
16
  ServerStatusError,
15
17
  TimeoutError, # noqa: A004
16
18
  TransportError,
@@ -43,6 +45,48 @@ def _default_pydantic_decoder() -> ResponseDecoder:
43
45
  return PydanticDecoder()
44
46
 
45
47
 
48
+ @contextlib.asynccontextmanager
49
+ async def _httpx2_exception_mapper() -> AsyncIterator[None]:
50
+ """Map httpx2 exceptions to httpware exceptions. Shared by AsyncClient._terminal and stream()."""
51
+ try:
52
+ yield
53
+ except httpx2.TimeoutException as exc:
54
+ raise TimeoutError(str(exc)) from exc
55
+ except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
56
+ raise TransportError(str(exc)) from exc
57
+ except httpx2.NetworkError as exc:
58
+ raise NetworkError(str(exc)) from exc
59
+ except httpx2.HTTPError as exc:
60
+ raise TransportError(str(exc)) from exc
61
+
62
+
63
+ def _raise_on_status_error(response: httpx2.Response) -> None:
64
+ """Raise the appropriate StatusError subclass for a 4xx/5xx response. No-op for 2xx/3xx."""
65
+ status = response.status_code
66
+ if HTTPStatus.BAD_REQUEST <= status < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
67
+ exc_class = STATUS_TO_EXCEPTION.get(
68
+ status,
69
+ ClientStatusError if status < HTTPStatus.INTERNAL_SERVER_ERROR else ServerStatusError,
70
+ )
71
+ raise exc_class(response)
72
+
73
+
74
+ STREAMING_BODY_MARKER = "httpware.streaming_body"
75
+ """Key set on ``httpx2.Request.extensions`` by ``_request_with_body`` when content/data/files is an async-iterable.
76
+
77
+ ``Retry.__call__`` reads this marker to refuse retrying a streamed-body request
78
+ (the consumed iterator cannot replay across attempts)."""
79
+
80
+
81
+ def _is_streaming_body(value: typing.Any) -> bool:
82
+ """Return True if value is an async-iterable that cannot be safely replayed for retry."""
83
+ if value is None:
84
+ return False
85
+ if isinstance(value, (bytes, bytearray, memoryview, str, dict)):
86
+ return False
87
+ return hasattr(value, "__aiter__")
88
+
89
+
46
90
  class AsyncClient:
47
91
  """Async HTTP client: thin wrapper around httpx2 with typed decoding and middleware."""
48
92
 
@@ -105,24 +149,13 @@ class AsyncClient:
105
149
 
106
150
  async def _terminal(self, request: httpx2.Request) -> httpx2.Response:
107
151
  try:
108
- response = await self._httpx2_client.send(request)
109
- except httpx2.TimeoutException as exc:
110
- raise TimeoutError(str(exc)) from exc
111
- except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
112
- raise TransportError(str(exc)) from exc
113
- except httpx2.HTTPError as exc:
114
- raise TransportError(str(exc)) from exc
152
+ async with _httpx2_exception_mapper():
153
+ response = await self._httpx2_client.send(request)
115
154
  except RuntimeError as exc:
116
155
  if "closed" in str(exc):
117
156
  raise TransportError(str(exc)) from exc
118
157
  raise
119
- status = response.status_code
120
- if HTTPStatus.BAD_REQUEST <= status < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
121
- exc_class = STATUS_TO_EXCEPTION.get(
122
- status,
123
- ClientStatusError if status < HTTPStatus.INTERNAL_SERVER_ERROR else ServerStatusError,
124
- )
125
- raise exc_class(response)
158
+ _raise_on_status_error(response)
126
159
  return response
127
160
 
128
161
  @typing.overload
@@ -147,7 +180,7 @@ class AsyncClient:
147
180
  """Delegate request construction to the wrapped httpx2.AsyncClient."""
148
181
  return self._httpx2_client.build_request(method, url, **kwargs)
149
182
 
150
- async def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method signatures
183
+ async def _request_with_body( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
151
184
  self,
152
185
  method: str,
153
186
  url: str,
@@ -183,6 +216,8 @@ class AsyncClient:
183
216
  if files is not None:
184
217
  kwargs["files"] = files
185
218
  request = self._httpx2_client.build_request(method, url, **kwargs)
219
+ if _is_streaming_body(content) or _is_streaming_body(data) or _is_streaming_body(files):
220
+ request.extensions[STREAMING_BODY_MARKER] = True
186
221
  return await self.send(request, response_model=response_model)
187
222
 
188
223
  @typing.overload
@@ -660,6 +695,66 @@ class AsyncClient:
660
695
  response_model=response_model,
661
696
  )
662
697
 
698
+ @contextlib.asynccontextmanager
699
+ async def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
700
+ self,
701
+ method: str,
702
+ url: str,
703
+ *,
704
+ params: typing.Any | None = None,
705
+ headers: typing.Any | None = None,
706
+ cookies: typing.Any | None = None,
707
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
708
+ extensions: typing.Any | None = None,
709
+ json: typing.Any | None = None,
710
+ content: typing.Any | None = None,
711
+ data: typing.Any | None = None,
712
+ files: typing.Any | None = None,
713
+ ) -> AsyncIterator[httpx2.Response]:
714
+ """Stream an HTTP response. Bypasses the middleware chain.
715
+
716
+ Yields an httpx2.Response; consume the body via response.aiter_bytes(),
717
+ response.aiter_text(), response.aiter_lines(), or response.aiter_raw().
718
+ The body is NOT pre-read for 2xx/3xx (streaming preserved); the response
719
+ is closed when the context exits.
720
+
721
+ Bypasses the middleware chain (no Retry, no Bulkhead, no user-installed
722
+ middleware) for v1 — see planning/specs/2026-06-05-streaming-design.md.
723
+
724
+ Auto-raises StatusError subclasses on 4xx/5xx (NotFoundError,
725
+ ServiceUnavailableError, etc.) — consistent with client.get()/post()/etc.
726
+ On error the response body is pre-read so exc.response.content is
727
+ accessible. You lose the streaming property on errors; rare in practice.
728
+
729
+ Maps httpx2 exceptions raised during the request OR body consumption to
730
+ httpware exceptions via _httpx2_exception_mapper.
731
+ """
732
+ kwargs: dict[str, typing.Any] = {}
733
+ if params is not None:
734
+ kwargs["params"] = params
735
+ if headers is not None:
736
+ kwargs["headers"] = headers
737
+ if cookies is not None:
738
+ kwargs["cookies"] = cookies
739
+ if timeout is not httpx2.USE_CLIENT_DEFAULT:
740
+ kwargs["timeout"] = timeout
741
+ if extensions is not None:
742
+ kwargs["extensions"] = extensions
743
+ if json is not None:
744
+ kwargs["json"] = json
745
+ if content is not None:
746
+ kwargs["content"] = content
747
+ if data is not None:
748
+ kwargs["data"] = data
749
+ if files is not None:
750
+ kwargs["files"] = files
751
+
752
+ async with _httpx2_exception_mapper(), self._httpx2_client.stream(method, url, **kwargs) as response:
753
+ if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
754
+ await response.aread() # pre-read body so exc.response.content works
755
+ _raise_on_status_error(response)
756
+ yield response
757
+
663
758
  async def __aenter__(self) -> typing.Self:
664
759
  """Enter the async context manager; return self."""
665
760
  return self
@@ -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,174 @@
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.client import STREAMING_BODY_MARKER
20
+ from httpware.errors import NetworkError, RetryBudgetExhaustedError, StatusError, TimeoutError # noqa: A004
21
+ from httpware.middleware import Next
22
+ from httpware.middleware.resilience._backoff import full_jitter_delay
23
+ from httpware.middleware.resilience.budget import RetryBudget
24
+
25
+
26
+ DEFAULT_RETRY_STATUS_CODES = frozenset(
27
+ {
28
+ int(HTTPStatus.REQUEST_TIMEOUT),
29
+ int(HTTPStatus.TOO_MANY_REQUESTS),
30
+ int(HTTPStatus.BAD_GATEWAY),
31
+ int(HTTPStatus.SERVICE_UNAVAILABLE),
32
+ int(HTTPStatus.GATEWAY_TIMEOUT),
33
+ }
34
+ )
35
+
36
+ DEFAULT_IDEMPOTENT_METHODS = frozenset(
37
+ {
38
+ "GET",
39
+ "HEAD",
40
+ "OPTIONS",
41
+ "PUT",
42
+ "DELETE",
43
+ }
44
+ )
45
+
46
+ _MAX_ATTEMPTS_INVALID = "max_attempts must be >= 1"
47
+ _STREAMING_BODY_REFUSAL_NOTE = "httpware: not retrying — request body is a stream that cannot replay across attempts"
48
+
49
+
50
+ def _parse_retry_after(value: str) -> float | None:
51
+ """Parse a Retry-After header value. Returns None on malformed input."""
52
+ try:
53
+ return max(0.0, float(int(value))) # clamp: negative integers are malformed servers
54
+ except ValueError:
55
+ pass
56
+ try:
57
+ parsed = email.utils.parsedate_to_datetime(value)
58
+ except (TypeError, ValueError):
59
+ return None
60
+ if parsed is None: # pragma: no cover — parsedate_to_datetime raises rather than returning None in CPython 3.11+
61
+ return None
62
+ now = datetime.datetime.now(datetime.UTC)
63
+ delta = (parsed - now).total_seconds()
64
+ return max(0.0, delta)
65
+
66
+
67
+ class Retry:
68
+ """Retry middleware. See module docstring for default policy."""
69
+
70
+ def __init__( # noqa: PLR0913 — retry policy has many orthogonal knobs; a dataclass would be worse
71
+ self,
72
+ *,
73
+ max_attempts: int = 3,
74
+ base_delay: float = 0.1,
75
+ max_delay: float = 5.0,
76
+ attempt_timeout: float | None = None,
77
+ retry_status_codes: frozenset[int] = DEFAULT_RETRY_STATUS_CODES,
78
+ retry_methods: frozenset[str] = DEFAULT_IDEMPOTENT_METHODS,
79
+ respect_retry_after: bool = True,
80
+ budget: RetryBudget | None = None,
81
+ _sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
82
+ ) -> None:
83
+ if max_attempts < 1:
84
+ raise ValueError(_MAX_ATTEMPTS_INVALID)
85
+ self.max_attempts = max_attempts
86
+ self.base_delay = base_delay
87
+ self.max_delay = max_delay
88
+ self.attempt_timeout = attempt_timeout
89
+ self.retry_status_codes = retry_status_codes
90
+ self.retry_methods = retry_methods
91
+ self.respect_retry_after = respect_retry_after
92
+ self.budget = budget if budget is not None else RetryBudget()
93
+ self._sleep = _sleep
94
+
95
+ async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002, C901, PLR0912, PLR0915 — complexity budget: 3 error clauses + idempotency gate + streaming-body refusal + budget gate + Retry-After branch + backoff
96
+ """Process a request through the retry loop. See module docstring."""
97
+ method_eligible = request.method.upper() in self.retry_methods
98
+ last_exc: BaseException | None = None
99
+ last_response: httpx2.Response | None = None
100
+
101
+ for attempt in range(self.max_attempts):
102
+ is_last = attempt + 1 >= self.max_attempts
103
+ self.budget.deposit()
104
+ try:
105
+ if self.attempt_timeout is not None:
106
+ async with asyncio.timeout(self.attempt_timeout):
107
+ return await next(request)
108
+ else:
109
+ return await next(request)
110
+ except StatusError as exc:
111
+ retryable_status = exc.response.status_code in self.retry_status_codes
112
+ if not method_eligible or not retryable_status:
113
+ if retryable_status and request.extensions.get(STREAMING_BODY_MARKER):
114
+ exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
115
+ raise
116
+ last_exc = exc
117
+ last_response = exc.response
118
+ except (NetworkError, TimeoutError) as exc:
119
+ if not method_eligible:
120
+ if request.extensions.get(STREAMING_BODY_MARKER):
121
+ exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
122
+ raise
123
+ last_exc = exc
124
+ last_response = None
125
+ except builtins.TimeoutError as exc:
126
+ wrapped = TimeoutError("attempt timed out")
127
+ wrapped.__cause__ = exc # set now; the retry path (last_exc = wrapped) has no `from` clause
128
+ if not method_eligible:
129
+ if request.extensions.get(STREAMING_BODY_MARKER):
130
+ wrapped.add_note(_STREAMING_BODY_REFUSAL_NOTE)
131
+ raise wrapped from exc
132
+ last_exc = wrapped
133
+ last_response = None
134
+
135
+ # ---- retryable failure path
136
+ if request.extensions.get(STREAMING_BODY_MARKER):
137
+ if last_exc is None: # pragma: no cover — invariant from except branch
138
+ msg = "Retry: streaming-body refusal reached with no last_exc"
139
+ raise AssertionError(msg)
140
+ last_exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
141
+ raise last_exc
142
+
143
+ if is_last:
144
+ if last_exc is None: # pragma: no cover — structural invariant from except branch
145
+ msg = "Retry: last_exc unset on final attempt — unreachable"
146
+ raise AssertionError(msg)
147
+ last_exc.add_note(f"httpware: gave up after {attempt + 1} attempts")
148
+ raise last_exc
149
+
150
+ if not self.budget.try_withdraw():
151
+ raise RetryBudgetExhaustedError(
152
+ last_response=last_response,
153
+ last_exception=last_exc,
154
+ attempts=attempt + 1,
155
+ ) from last_exc
156
+
157
+ retry_after: float | None = None
158
+ if self.respect_retry_after and last_response is not None:
159
+ header = last_response.headers.get("Retry-After")
160
+ if header is not None:
161
+ retry_after = _parse_retry_after(header)
162
+
163
+ if retry_after is not None:
164
+ delay = min(retry_after, self.max_delay)
165
+ else:
166
+ delay = full_jitter_delay(
167
+ attempt,
168
+ base_delay=self.base_delay,
169
+ max_delay=self.max_delay,
170
+ )
171
+ await self._sleep(delay)
172
+
173
+ msg = "unreachable" # pragma: no cover
174
+ raise AssertionError(msg) # pragma: no cover
httpware-0.3.0/README.md DELETED
@@ -1,53 +0,0 @@
1
- # httpware
2
-
3
- [![Test](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
4
- [![PyPI version](https://badge.fury.io/py/httpware.svg)](https://pypi.org/project/httpware/)
5
- [![Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
-
8
- **Async HTTP client framework for Python.**
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.
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.
13
-
14
- ## Install
15
-
16
- ```bash
17
- pip install httpware # core only — no decoder
18
- pip install httpware[pydantic] # + PydanticDecoder (the default-decoder path)
19
- pip install httpware[msgspec] # + MsgspecDecoder
20
- pip install httpware[all] # everything declared above (pydantic, msgspec, otel)
21
- ```
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.
24
-
25
- ## Quickstart
26
-
27
- > Requires: `pip install httpware[pydantic]`
28
-
29
- ```python
30
- from httpware import AsyncClient
31
- from pydantic import BaseModel
32
-
33
-
34
- class User(BaseModel):
35
- id: int
36
- name: str
37
-
38
-
39
- async def main() -> None:
40
- async with AsyncClient(base_url="https://api.example.com") as client:
41
- user = await client.get("/users/1", response_model=User)
42
- print(user.name)
43
- ```
44
-
45
- ## 📚 [Documentation](https://httpware.readthedocs.io)
46
-
47
- ## 📦 [PyPI](https://pypi.org/project/httpware)
48
-
49
- ## 📝 [License](./LICENSE)
50
-
51
- ## Part of `modern-python`
52
-
53
- Browse the full list of templates and libraries in [`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
File without changes