httpware 0.2.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.
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: httpware
3
+ Version: 0.4.0
4
+ Summary: Resilience-first async HTTP client framework for Python
5
+ Keywords: http,async,client,resilience,retry,circuit-breaker,middleware,httpx,pydantic
6
+ Author: Artur Shiriev
7
+ Author-email: Artur Shiriev <me@shiriev.ru>
8
+ License-Expression: MIT
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Typing :: Typed
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Classifier: Topic :: Internet :: WWW/HTTP
16
+ Classifier: Framework :: AsyncIO
17
+ Requires-Dist: httpx2>=2.0.0,<3.0
18
+ Requires-Dist: httpware[pydantic,msgspec] ; extra == 'all'
19
+ Requires-Dist: msgspec>=0.18 ; extra == 'msgspec'
20
+ Requires-Dist: pydantic>=2.0,<3.0 ; extra == 'pydantic'
21
+ Requires-Python: >=3.11, <4
22
+ Project-URL: repository, https://github.com/modern-python/httpware
23
+ Project-URL: docs, https://httpware.readthedocs.io
24
+ Provides-Extra: all
25
+ Provides-Extra: msgspec
26
+ Provides-Extra: pydantic
27
+ Description-Content-Type: text/markdown
28
+
29
+ # httpware
30
+
31
+ [![Test](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
32
+ [![PyPI version](https://badge.fury.io/py/httpware.svg)](https://pypi.org/project/httpware/)
33
+ [![Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
35
+
36
+ **Async HTTP client framework for Python.**
37
+
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`.
39
+
40
+ > **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0. Streaming and observability are not yet shipped.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install httpware # core only — no decoder
46
+ pip install httpware[pydantic] # + PydanticDecoder (the default-decoder path)
47
+ pip install httpware[msgspec] # + MsgspecDecoder
48
+ pip install httpware[all] # everything declared above (pydantic, msgspec)
49
+ ```
50
+
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.
52
+
53
+ ## Quickstart
54
+
55
+ > Requires: `pip install httpware[pydantic]`
56
+
57
+ ```python
58
+ from httpware import AsyncClient
59
+ from pydantic import BaseModel
60
+
61
+
62
+ class User(BaseModel):
63
+ id: int
64
+ name: str
65
+
66
+
67
+ async def main() -> None:
68
+ async with AsyncClient(base_url="https://api.example.com") as client:
69
+ user = await client.get("/users/1", response_model=User)
70
+ print(user.name)
71
+ ```
72
+
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)
97
+
98
+ ## 📦 [PyPI](https://pypi.org/project/httpware)
99
+
100
+ ## 📝 [License](./LICENSE)
101
+
102
+ ## Part of `modern-python`
103
+
104
+ Browse the full list of templates and libraries in [`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
@@ -0,0 +1,76 @@
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
+ ## 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)
69
+
70
+ ## 📦 [PyPI](https://pypi.org/project/httpware)
71
+
72
+ ## 📝 [License](./LICENSE)
73
+
74
+ ## Part of `modern-python`
75
+
76
+ 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,19 +26,15 @@ classifiers = [
26
26
  "Topic :: Internet :: WWW/HTTP",
27
27
  "Framework :: AsyncIO",
28
28
  ]
29
- version = "0.2.0"
29
+ version = "0.4.0"
30
30
  dependencies = [
31
31
  "httpx2>=2.0.0,<3.0",
32
- "pydantic>=2.0,<3.0",
33
32
  ]
34
33
 
35
34
  [project.optional-dependencies]
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[msgspec,otel]"]
37
+ all = ["httpware[pydantic,msgspec]"]
42
38
 
43
39
  [project.urls]
44
40
  repository = "https://github.com/modern-python/httpware"
@@ -2,17 +2,19 @@
2
2
 
3
3
  from httpware.client import AsyncClient
4
4
  from httpware.decoders import ResponseDecoder
5
- from httpware.decoders.pydantic import PydanticDecoder
6
5
  from httpware.errors import (
7
6
  STATUS_TO_EXCEPTION,
8
7
  BadRequestError,
8
+ BulkheadFullError,
9
9
  ClientError,
10
10
  ClientStatusError,
11
11
  ConflictError,
12
12
  ForbiddenError,
13
13
  InternalServerError,
14
+ NetworkError,
14
15
  NotFoundError,
15
16
  RateLimitedError,
17
+ RetryBudgetExhaustedError,
16
18
  ServerStatusError,
17
19
  ServiceUnavailableError,
18
20
  StatusError,
@@ -22,23 +24,29 @@ from httpware.errors import (
22
24
  UnprocessableEntityError,
23
25
  )
24
26
  from httpware.middleware import Middleware, Next, after_response, before_request, on_error
27
+ from httpware.middleware.resilience import Bulkhead, Retry, RetryBudget
25
28
 
26
29
 
27
30
  __all__ = [
28
31
  "STATUS_TO_EXCEPTION",
29
32
  "AsyncClient",
30
33
  "BadRequestError",
34
+ "Bulkhead",
35
+ "BulkheadFullError",
31
36
  "ClientError",
32
37
  "ClientStatusError",
33
38
  "ConflictError",
34
39
  "ForbiddenError",
35
40
  "InternalServerError",
36
41
  "Middleware",
42
+ "NetworkError",
37
43
  "Next",
38
44
  "NotFoundError",
39
- "PydanticDecoder",
40
45
  "RateLimitedError",
41
46
  "ResponseDecoder",
47
+ "Retry",
48
+ "RetryBudget",
49
+ "RetryBudgetExhaustedError",
42
50
  "ServerStatusError",
43
51
  "ServiceUnavailableError",
44
52
  "StatusError",
@@ -4,3 +4,4 @@ from importlib.util import find_spec
4
4
 
5
5
 
6
6
  is_msgspec_installed = find_spec("msgspec") is not None
7
+ is_pydantic_installed = find_spec("pydantic") is not None
@@ -6,11 +6,12 @@ from http import HTTPStatus
6
6
 
7
7
  import httpx2
8
8
 
9
+ from httpware._internal import import_checker
9
10
  from httpware.decoders import ResponseDecoder
10
- from httpware.decoders.pydantic import PydanticDecoder
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,
@@ -28,6 +29,20 @@ _HTTPX2_CLIENT_CONFLICT_MESSAGE = (
28
29
  f"{_FORWARDED_KWARG_NAMES}; configure the httpx2.AsyncClient you pass instead."
29
30
  )
30
31
 
32
+ _DEFAULT_DECODER_MISSING_MESSAGE = (
33
+ "AsyncClient(decoder=None) defaults to PydanticDecoder, which requires the "
34
+ "'pydantic' extra. Either install it (`pip install httpware[pydantic]`) or "
35
+ "pass an explicit decoder=..."
36
+ )
37
+
38
+
39
+ def _default_pydantic_decoder() -> ResponseDecoder:
40
+ if not import_checker.is_pydantic_installed:
41
+ raise ImportError(_DEFAULT_DECODER_MISSING_MESSAGE)
42
+ from httpware.decoders.pydantic import PydanticDecoder # noqa: PLC0415 — lazy by design
43
+
44
+ return PydanticDecoder()
45
+
31
46
 
32
47
  class AsyncClient:
33
48
  """Async HTTP client: thin wrapper around httpx2 with typed decoding and middleware."""
@@ -85,7 +100,7 @@ class AsyncClient:
85
100
  self._httpx2_client = httpx2.AsyncClient(**kwargs)
86
101
  self._owns_client = True
87
102
 
88
- self._decoder = decoder if decoder is not None else PydanticDecoder()
103
+ self._decoder = decoder if decoder is not None else _default_pydantic_decoder()
89
104
  self._user_middleware = tuple(middleware)
90
105
  self._dispatch = compose(self._user_middleware, self._terminal)
91
106
 
@@ -96,6 +111,8 @@ class AsyncClient:
96
111
  raise TimeoutError(str(exc)) from exc
97
112
  except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
98
113
  raise TransportError(str(exc)) from exc
114
+ except httpx2.NetworkError as exc:
115
+ raise NetworkError(str(exc)) from exc
99
116
  except httpx2.HTTPError as exc:
100
117
  raise TransportError(str(exc)) from exc
101
118
  except RuntimeError as exc:
@@ -0,0 +1,44 @@
1
+ """PydanticDecoder — module-level cached TypeAdapter adapter for ResponseDecoder.
2
+
3
+ Requires the `pydantic` extra: `pip install httpware[pydantic]`. Importing this
4
+ module without the extra works (the `pydantic` import is guarded by a
5
+ `find_spec` check), but instantiating the decoder raises `ImportError` with the
6
+ install hint.
7
+ """
8
+
9
+ import functools
10
+ from typing import TypeVar
11
+
12
+ from httpware._internal import import_checker
13
+
14
+
15
+ if import_checker.is_pydantic_installed:
16
+ from pydantic import TypeAdapter
17
+
18
+
19
+ MISSING_DEPENDENCY_MESSAGE = (
20
+ "PydanticDecoder requires the 'pydantic' extra. Install with: pip install httpware[pydantic]"
21
+ )
22
+
23
+ T = TypeVar("T")
24
+
25
+
26
+ @functools.lru_cache(maxsize=1024)
27
+ def _get_adapter(model: type[T]) -> "TypeAdapter[T]":
28
+ return TypeAdapter(model)
29
+
30
+
31
+ class PydanticDecoder:
32
+ """Decode raw response bytes into `model` via a cached `pydantic.TypeAdapter`."""
33
+
34
+ def __init__(self) -> None:
35
+ if not import_checker.is_pydantic_installed:
36
+ raise ImportError(MISSING_DEPENDENCY_MESSAGE)
37
+
38
+ def decode(self, content: bytes, model: type[T]) -> T:
39
+ """Validate `content` as JSON against `model` in a single parse pass."""
40
+ try:
41
+ adapter = _get_adapter(model)
42
+ except TypeError:
43
+ adapter = TypeAdapter(model)
44
+ return adapter.validate_json(content)
@@ -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
httpware-0.2.0/PKG-INFO DELETED
@@ -1,84 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: httpware
3
- Version: 0.2.0
4
- Summary: Resilience-first async HTTP client framework for Python
5
- Keywords: http,async,client,resilience,retry,circuit-breaker,middleware,httpx,pydantic
6
- Author: Artur Shiriev
7
- Author-email: Artur Shiriev <me@shiriev.ru>
8
- License-Expression: MIT
9
- Classifier: Programming Language :: Python :: 3.11
10
- Classifier: Programming Language :: Python :: 3.12
11
- Classifier: Programming Language :: Python :: 3.13
12
- Classifier: Programming Language :: Python :: 3.14
13
- Classifier: Typing :: Typed
14
- Classifier: Topic :: Software Development :: Libraries
15
- Classifier: Topic :: Internet :: WWW/HTTP
16
- Classifier: Framework :: AsyncIO
17
- Requires-Dist: httpx2>=2.0.0,<3.0
18
- Requires-Dist: pydantic>=2.0,<3.0
19
- Requires-Dist: httpware[msgspec,otel] ; extra == 'all'
20
- Requires-Dist: msgspec>=0.18 ; extra == 'msgspec'
21
- Requires-Dist: opentelemetry-api>=1.20 ; extra == 'otel'
22
- Requires-Dist: opentelemetry-sdk>=1.20 ; extra == 'otel'
23
- Requires-Python: >=3.11, <4
24
- Project-URL: repository, https://github.com/modern-python/httpware
25
- Project-URL: docs, https://httpware.readthedocs.io
26
- Provides-Extra: all
27
- Provides-Extra: msgspec
28
- Provides-Extra: otel
29
- Description-Content-Type: text/markdown
30
-
31
- # httpware
32
-
33
- [![Test](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
34
- [![PyPI version](https://badge.fury.io/py/httpware.svg)](https://pypi.org/project/httpware/)
35
- [![Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/)
36
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
37
-
38
- **Async HTTP client framework for Python.**
39
-
40
- `httpware` is a typed, async HTTP client library with a protocol-based seam so the transport is swappable (`httpx2` ships as the default). Middleware composes via an onion model. Pydantic and msgspec response decoding ship out of the box. `RecordedTransport` replaces `respx` for transport-level tests.
41
-
42
- > **Status:** Pre-1.0 (0.1.0 alpha). Public API is subject to change between minor releases until v1.0. Resilience middleware (retry / timeout / bulkhead), streaming, and observability are not yet shipped.
43
-
44
- ## Install
45
-
46
- ```bash
47
- pip install httpware
48
- ```
49
-
50
- Optional extras:
51
-
52
- ```bash
53
- pip install httpware[msgspec] # MsgspecDecoder
54
- ```
55
-
56
- (`otel`, `niquests`, and `all` extras are declared; integrations have not shipped yet.)
57
-
58
- ## Quickstart
59
-
60
- ```python
61
- from httpware import AsyncClient
62
- from pydantic import BaseModel
63
-
64
-
65
- class User(BaseModel):
66
- id: int
67
- name: str
68
-
69
-
70
- async def main() -> None:
71
- async with AsyncClient(base_url="https://api.example.com") as client:
72
- user = await client.get("/users/1", response_model=User)
73
- print(user.name)
74
- ```
75
-
76
- ## 📚 [Documentation](https://httpware.readthedocs.io)
77
-
78
- ## 📦 [PyPI](https://pypi.org/project/httpware)
79
-
80
- ## 📝 [License](./LICENSE)
81
-
82
- ## Part of `modern-python`
83
-
84
- Browse the full list of templates and libraries in [`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
httpware-0.2.0/README.md DELETED
@@ -1,54 +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 typed, async HTTP client library with a protocol-based seam so the transport is swappable (`httpx2` ships as the default). Middleware composes via an onion model. Pydantic and msgspec response decoding ship out of the box. `RecordedTransport` replaces `respx` for transport-level tests.
11
-
12
- > **Status:** Pre-1.0 (0.1.0 alpha). 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
18
- ```
19
-
20
- Optional extras:
21
-
22
- ```bash
23
- pip install httpware[msgspec] # MsgspecDecoder
24
- ```
25
-
26
- (`otel`, `niquests`, and `all` extras are declared; integrations have not shipped yet.)
27
-
28
- ## Quickstart
29
-
30
- ```python
31
- from httpware import AsyncClient
32
- from pydantic import BaseModel
33
-
34
-
35
- class User(BaseModel):
36
- id: int
37
- name: str
38
-
39
-
40
- async def main() -> None:
41
- async with AsyncClient(base_url="https://api.example.com") as client:
42
- user = await client.get("/users/1", response_model=User)
43
- print(user.name)
44
- ```
45
-
46
- ## 📚 [Documentation](https://httpware.readthedocs.io)
47
-
48
- ## 📦 [PyPI](https://pypi.org/project/httpware)
49
-
50
- ## 📝 [License](./LICENSE)
51
-
52
- ## Part of `modern-python`
53
-
54
- Browse the full list of templates and libraries in [`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
@@ -1,29 +0,0 @@
1
- """PydanticDecoder — module-level cached TypeAdapter adapter for ResponseDecoder."""
2
-
3
- import functools
4
- from typing import TypeVar
5
-
6
- from pydantic import TypeAdapter
7
-
8
-
9
- T = TypeVar("T")
10
-
11
-
12
- @functools.lru_cache(maxsize=1024)
13
- def _get_adapter(model: type[T]) -> TypeAdapter[T]:
14
- return TypeAdapter(model)
15
-
16
-
17
- class PydanticDecoder:
18
- """Decode raw response bytes into `model` via a cached `pydantic.TypeAdapter`."""
19
-
20
- def decode(self, content: bytes, model: type[T]) -> T:
21
- """Validate `content` as JSON against `model` in a single parse pass."""
22
- try:
23
- adapter = _get_adapter(model)
24
- except TypeError:
25
- adapter = TypeAdapter(model)
26
- return adapter.validate_json(content)
27
-
28
-
29
- __all__ = ["PydanticDecoder"]
File without changes