httpware 0.9.0__tar.gz → 0.10.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.
Files changed (26) hide show
  1. {httpware-0.9.0 → httpware-0.10.0}/PKG-INFO +8 -6
  2. {httpware-0.9.0 → httpware-0.10.0}/README.md +7 -5
  3. {httpware-0.9.0 → httpware-0.10.0}/pyproject.toml +1 -1
  4. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/__init__.py +15 -1
  5. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/decoders/__init__.py +7 -0
  6. httpware-0.10.0/src/httpware/decoders/msgspec.py +116 -0
  7. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/decoders/pydantic.py +19 -0
  8. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/errors.py +29 -0
  9. httpware-0.10.0/src/httpware/middleware/resilience/__init__.py +19 -0
  10. httpware-0.10.0/src/httpware/middleware/resilience/circuit_breaker.py +302 -0
  11. httpware-0.10.0/src/httpware/middleware/resilience/timeout.py +75 -0
  12. httpware-0.9.0/src/httpware/decoders/msgspec.py +0 -66
  13. httpware-0.9.0/src/httpware/middleware/resilience/__init__.py +0 -8
  14. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/_internal/__init__.py +0 -0
  15. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/_internal/exception_mapping.py +0 -0
  16. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/_internal/import_checker.py +0 -0
  17. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/_internal/observability.py +0 -0
  18. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/_internal/status.py +0 -0
  19. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/client.py +0 -0
  20. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/middleware/__init__.py +0 -0
  21. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/middleware/chain.py +0 -0
  22. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/middleware/resilience/_backoff.py +0 -0
  23. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/middleware/resilience/budget.py +0 -0
  24. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
  25. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/middleware/resilience/retry.py +0 -0
  26. {httpware-0.9.0 → httpware-0.10.0}/src/httpware/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpware
3
- Version: 0.9.0
3
+ Version: 0.10.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
@@ -37,7 +37,7 @@ Description-Content-Type: text/markdown
37
37
 
38
38
  **A Python HTTP client framework with sync and async clients for building resilient service clients.**
39
39
 
40
- `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 — `AsyncRetry`/`Retry` middleware with a Finagle-style `RetryBudget`, plus an `AsyncBulkhead`/`Bulkhead` concurrency limiter under `httpware.middleware.resilience`.
40
+ `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 resilience suite under `httpware.middleware.resilience` — `AsyncRetry`/`Retry` with a Finagle-style `RetryBudget`, `AsyncBulkhead`/`Bulkhead` concurrency limiter, `AsyncCircuitBreaker`/`CircuitBreaker` consecutive-failure breaker, and `AsyncTimeout` for overall-operation wall-clock bounds.
41
41
 
42
42
  > **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
43
43
 
@@ -80,7 +80,7 @@ with Client(base_url="https://example.test") as client:
80
80
  print(response.json())
81
81
  ```
82
82
 
83
- Typed decoding via `response_model=` works in both worlds — requires `pip install httpware[pydantic]`. Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors.
83
+ Typed decoding via `response_model=` works in both worlds — install either `pip install httpware[pydantic]` or `pip install httpware[msgspec]` (or both; pydantic is tried first when both are present). Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors.
84
84
 
85
85
  ```python
86
86
  from httpware import AsyncClient
@@ -146,16 +146,18 @@ All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `Se
146
146
 
147
147
  ## Observability
148
148
 
149
- `AsyncRetry`/`Retry` and `AsyncBulkhead`/`Bulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed). Event names and payloads are identical across sync and async; dashboards built against one class apply unchanged to the other.
149
+ All resilience middleware emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed). Event names and payloads are identical across sync and async; dashboards built against one class apply unchanged to the other.
150
150
 
151
- Logger names (`httpware.retry`, `httpware.bulkhead`) and event names (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`, `bulkhead.rejected`) are the stable public contract.
151
+ Logger names and event names are the stable public contract: `httpware.retry` (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`), `httpware.bulkhead` (`bulkhead.rejected`), `httpware.circuit_breaker` (`circuit.opened`, `circuit.rejected`, `circuit.half_open`, `circuit.closed`), and `httpware.timeout` (`timeout.exceeded`).
152
152
 
153
153
  ```python
154
154
  import logging
155
155
 
156
- # Enable visibility into retry / bulkhead operational events
156
+ # Enable visibility into resilience operational events
157
157
  logging.getLogger("httpware.retry").setLevel(logging.WARNING)
158
158
  logging.getLogger("httpware.bulkhead").setLevel(logging.WARNING)
159
+ logging.getLogger("httpware.circuit_breaker").setLevel(logging.WARNING)
160
+ logging.getLogger("httpware.timeout").setLevel(logging.WARNING)
159
161
  ```
160
162
 
161
163
  For OTel attribute enrichment on the active span — install the extra:
@@ -7,7 +7,7 @@
7
7
 
8
8
  **A Python HTTP client framework with sync and async clients for building resilient service clients.**
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. It also ships a small resilience suite — `AsyncRetry`/`Retry` middleware with a Finagle-style `RetryBudget`, plus an `AsyncBulkhead`/`Bulkhead` concurrency limiter under `httpware.middleware.resilience`.
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 resilience suite under `httpware.middleware.resilience` — `AsyncRetry`/`Retry` with a Finagle-style `RetryBudget`, `AsyncBulkhead`/`Bulkhead` concurrency limiter, `AsyncCircuitBreaker`/`CircuitBreaker` consecutive-failure breaker, and `AsyncTimeout` for overall-operation wall-clock bounds.
11
11
 
12
12
  > **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
13
13
 
@@ -50,7 +50,7 @@ with Client(base_url="https://example.test") as client:
50
50
  print(response.json())
51
51
  ```
52
52
 
53
- Typed decoding via `response_model=` works in both worlds — requires `pip install httpware[pydantic]`. Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors.
53
+ Typed decoding via `response_model=` works in both worlds — install either `pip install httpware[pydantic]` or `pip install httpware[msgspec]` (or both; pydantic is tried first when both are present). Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors.
54
54
 
55
55
  ```python
56
56
  from httpware import AsyncClient
@@ -116,16 +116,18 @@ All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `Se
116
116
 
117
117
  ## Observability
118
118
 
119
- `AsyncRetry`/`Retry` and `AsyncBulkhead`/`Bulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed). Event names and payloads are identical across sync and async; dashboards built against one class apply unchanged to the other.
119
+ All resilience middleware emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed). Event names and payloads are identical across sync and async; dashboards built against one class apply unchanged to the other.
120
120
 
121
- Logger names (`httpware.retry`, `httpware.bulkhead`) and event names (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`, `bulkhead.rejected`) are the stable public contract.
121
+ Logger names and event names are the stable public contract: `httpware.retry` (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`), `httpware.bulkhead` (`bulkhead.rejected`), `httpware.circuit_breaker` (`circuit.opened`, `circuit.rejected`, `circuit.half_open`, `circuit.closed`), and `httpware.timeout` (`timeout.exceeded`).
122
122
 
123
123
  ```python
124
124
  import logging
125
125
 
126
- # Enable visibility into retry / bulkhead operational events
126
+ # Enable visibility into resilience operational events
127
127
  logging.getLogger("httpware.retry").setLevel(logging.WARNING)
128
128
  logging.getLogger("httpware.bulkhead").setLevel(logging.WARNING)
129
+ logging.getLogger("httpware.circuit_breaker").setLevel(logging.WARNING)
130
+ logging.getLogger("httpware.timeout").setLevel(logging.WARNING)
129
131
  ```
130
132
 
131
133
  For OTel attribute enrichment on the active span — install the extra:
@@ -26,7 +26,7 @@ classifiers = [
26
26
  "Topic :: Internet :: WWW/HTTP",
27
27
  "Framework :: AsyncIO",
28
28
  ]
29
- version = "0.9.0"
29
+ version = "0.10.0"
30
30
  dependencies = [
31
31
  "httpx2>=2.0.0,<3.0",
32
32
  ]
@@ -6,6 +6,7 @@ from httpware.errors import (
6
6
  STATUS_TO_EXCEPTION,
7
7
  BadRequestError,
8
8
  BulkheadFullError,
9
+ CircuitOpenError,
9
10
  ClientError,
10
11
  ClientStatusError,
11
12
  ConflictError,
@@ -37,19 +38,32 @@ from httpware.middleware import (
37
38
  before_request,
38
39
  on_error,
39
40
  )
40
- from httpware.middleware.resilience import AsyncBulkhead, AsyncRetry, Bulkhead, Retry, RetryBudget
41
+ from httpware.middleware.resilience import (
42
+ AsyncBulkhead,
43
+ AsyncCircuitBreaker,
44
+ AsyncRetry,
45
+ AsyncTimeout,
46
+ Bulkhead,
47
+ CircuitBreaker,
48
+ Retry,
49
+ RetryBudget,
50
+ )
41
51
 
42
52
 
43
53
  __all__ = [
44
54
  "STATUS_TO_EXCEPTION",
45
55
  "AsyncBulkhead",
56
+ "AsyncCircuitBreaker",
46
57
  "AsyncClient",
47
58
  "AsyncMiddleware",
48
59
  "AsyncNext",
49
60
  "AsyncRetry",
61
+ "AsyncTimeout",
50
62
  "BadRequestError",
51
63
  "Bulkhead",
52
64
  "BulkheadFullError",
65
+ "CircuitBreaker",
66
+ "CircuitOpenError",
53
67
  "Client",
54
68
  "ClientError",
55
69
  "ClientStatusError",
@@ -19,6 +19,13 @@ class ResponseDecoder(Protocol):
19
19
  list ordering encodes the caller's preference for shared shapes.
20
20
  Native types of another library (e.g. `PydanticDecoder` vs
21
21
  `msgspec.Struct`) MUST be rejected.
22
+
23
+ `can_decode` MUST NOT raise. It runs at dispatch time — before the HTTP
24
+ call and outside the `DecodeError` wrap that protects `decode` — so an
25
+ exception here escapes the `ClientError` contract rather than being
26
+ translated. A decoder that cannot determine support for `model` must
27
+ return False (decline), not raise; the built-in decoders treat any
28
+ probe failure as False.
22
29
  """
23
30
  ...
24
31
 
@@ -0,0 +1,116 @@
1
+ """MsgspecDecoder — opt-in ResponseDecoder backed by a per-instance msgspec.json.Decoder cache."""
2
+
3
+ import typing
4
+ from typing import TypeVar
5
+
6
+ from httpware._internal import import_checker
7
+
8
+
9
+ if import_checker.is_msgspec_installed:
10
+ import msgspec
11
+
12
+
13
+ MISSING_DEPENDENCY_MESSAGE = "MsgspecDecoder requires the 'msgspec' extra. Install with: pip install httpware[msgspec]"
14
+
15
+ T = TypeVar("T")
16
+
17
+
18
+ def _contains_custom_type(info: "msgspec.inspect.Type") -> bool:
19
+ """Return True if `info` is a CustomType or nests one in its parameters.
20
+
21
+ Walks generic-container parameterization (list/dict/set/tuple/union element
22
+ types) by visiting any attribute that is itself a `msgspec.inspect.Type` or a
23
+ tuple of them. It deliberately does NOT descend into `StructType`/dataclass
24
+ fields: those expose `fields` as `Field` objects (not `Type`), so the walk
25
+ stops at the boundary of a type msgspec natively owns. That boundary is what
26
+ makes the walk both correct (a Struct is a valid target) and safe against
27
+ infinite recursion on self-referential struct definitions.
28
+ """
29
+ if isinstance(info, msgspec.inspect.CustomType):
30
+ return True
31
+ for name in dir(info):
32
+ if name.startswith("_"):
33
+ continue
34
+ value = getattr(info, name, None)
35
+ if isinstance(value, msgspec.inspect.Type):
36
+ if _contains_custom_type(value):
37
+ return True
38
+ elif (
39
+ isinstance(value, tuple)
40
+ and value
41
+ and all(isinstance(item, msgspec.inspect.Type) for item in value)
42
+ and any(_contains_custom_type(item) for item in value)
43
+ ):
44
+ return True
45
+ return False
46
+
47
+
48
+ class MsgspecDecoder:
49
+ """Decode raw response bytes via a per-instance cached `msgspec.json.Decoder(model)`.
50
+
51
+ Requires the `msgspec` extra: `pip install httpware[msgspec]`. Importing
52
+ this module without the extra works (the `msgspec` import is guarded by a
53
+ `find_spec` check), but instantiating the decoder raises `ImportError`.
54
+ """
55
+
56
+ _msgspec_decoders: dict[type, "msgspec.json.Decoder[typing.Any]"]
57
+ _can_decode_results: dict[type, bool]
58
+
59
+ def __init__(self) -> None:
60
+ if not import_checker.is_msgspec_installed:
61
+ raise ImportError(MISSING_DEPENDENCY_MESSAGE)
62
+ self._msgspec_decoders = {}
63
+ self._can_decode_results = {}
64
+
65
+ def _get_msgspec_decoder(self, model: type[T]) -> "msgspec.json.Decoder[T]":
66
+ decoder = self._msgspec_decoders.get(model)
67
+ if decoder is None:
68
+ decoder = msgspec.json.Decoder(model)
69
+ self._msgspec_decoders[model] = decoder
70
+ return decoder
71
+
72
+ def can_decode(self, model: type) -> bool:
73
+ """Return True iff msgspec natively understands `model` end-to-end.
74
+
75
+ The verdict is memoized per `model`: the probe below (an uncached
76
+ `type_info` call plus a recursive tree walk) runs once per type, not on
77
+ every dispatch. Unhashable models skip the cache and probe fresh.
78
+ """
79
+ try:
80
+ cached = self._can_decode_results.get(model)
81
+ except TypeError: # unhashable model — can't memoize, probe fresh
82
+ return self._probe_can_decode(model)
83
+ if cached is not None:
84
+ return cached
85
+ result = self._probe_can_decode(model)
86
+ self._can_decode_results[model] = result
87
+ return result
88
+
89
+ def _probe_can_decode(self, model: type) -> bool:
90
+ """Decide whether msgspec natively decodes `model` (the uncached path).
91
+
92
+ msgspec builds a Decoder for almost any class via a generic CustomType
93
+ fallback; the Decoder constructor does NOT raise on unsupported types
94
+ (e.g. pydantic.BaseModel, or a container parameterized by one). We walk
95
+ msgspec.inspect.type_info and reject if a CustomType appears anywhere in
96
+ the type tree, so MissingDecoderError fires before a request is sent.
97
+ """
98
+ try:
99
+ info = msgspec.inspect.type_info(model)
100
+ except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
101
+ return False
102
+ if _contains_custom_type(info):
103
+ return False
104
+ try:
105
+ self._get_msgspec_decoder(model)
106
+ except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
107
+ return False
108
+ return True
109
+
110
+ def decode(self, content: bytes, model: type[T]) -> T:
111
+ """Validate `content` as JSON against `model` in a single parse pass."""
112
+ try:
113
+ decoder = self._get_msgspec_decoder(model)
114
+ except TypeError:
115
+ decoder = msgspec.json.Decoder(model)
116
+ return decoder.decode(content)
@@ -26,11 +26,13 @@ class PydanticDecoder:
26
26
  """Decode raw response bytes into `model` via a per-instance cached `pydantic.TypeAdapter`."""
27
27
 
28
28
  _adapters: dict[type, TypeAdapter[typing.Any]]
29
+ _can_decode_results: dict[type, bool]
29
30
 
30
31
  def __init__(self) -> None:
31
32
  if not import_checker.is_pydantic_installed:
32
33
  raise ImportError(MISSING_DEPENDENCY_MESSAGE)
33
34
  self._adapters = {}
35
+ self._can_decode_results = {}
34
36
 
35
37
  def _get_adapter(self, model: type[T]) -> "TypeAdapter[T]":
36
38
  adapter = self._adapters.get(model)
@@ -42,6 +44,23 @@ class PydanticDecoder:
42
44
  def can_decode(self, model: type) -> bool:
43
45
  """Return True iff pydantic can build a schema for `model`.
44
46
 
47
+ The verdict is memoized per `model` so a rejection (which costs a
48
+ `PydanticSchemaGenerationError` round-trip) is not re-probed on every
49
+ dispatch. Unhashable models skip the cache and probe fresh.
50
+ """
51
+ try:
52
+ cached = self._can_decode_results.get(model)
53
+ except TypeError: # unhashable model — can't memoize, probe fresh
54
+ return self._probe_can_decode(model)
55
+ if cached is not None:
56
+ return cached
57
+ result = self._probe_can_decode(model)
58
+ self._can_decode_results[model] = result
59
+ return result
60
+
61
+ def _probe_can_decode(self, model: type) -> bool:
62
+ """Decide whether pydantic can build a schema for `model` (uncached).
63
+
45
64
  Probes via `_get_adapter`; subsequent calls (including `decode`) reuse
46
65
  the cached `TypeAdapter`. Rejects `msgspec.Struct` subclasses —
47
66
  pydantic raises `PydanticSchemaGenerationError` (a `TypeError`) when
@@ -214,6 +214,35 @@ class BulkheadFullError(ClientError):
214
214
  )
215
215
 
216
216
 
217
+ def _reconstruct_circuit_open(
218
+ cls: "type[CircuitOpenError]",
219
+ retry_after: float | None,
220
+ ) -> "CircuitOpenError":
221
+ return cls(retry_after=retry_after)
222
+
223
+
224
+ class CircuitOpenError(ClientError):
225
+ """Raised when a CircuitBreaker refuses a request because the circuit is not closed.
226
+
227
+ Fires when the circuit is OPEN, or when it is HALF_OPEN and the single probe
228
+ slot is already taken. The request is never forwarded to ``next``. ``retry_after``
229
+ carries the seconds until the circuit will next admit a probe, when known
230
+ (``None`` when a concurrent probe is already in flight).
231
+ """
232
+
233
+ retry_after: float | None
234
+
235
+ def __init__(self, *, retry_after: float | None) -> None:
236
+ self.retry_after = retry_after
237
+ if retry_after is None:
238
+ super().__init__("circuit open (a probe request is already in flight)")
239
+ else:
240
+ super().__init__(f"circuit open (retry_after={retry_after:.3f}s)")
241
+
242
+ def __reduce__(self) -> tuple[Any, ...]:
243
+ return (_reconstruct_circuit_open, (type(self), self.retry_after))
244
+
245
+
217
246
  def _reconstruct_decode_error(
218
247
  cls: "type[DecodeError]",
219
248
  response: httpx2.Response,
@@ -0,0 +1,19 @@
1
+ """Resilience middleware: Bulkhead, CircuitBreaker, Retry, RetryBudget, and their Async counterparts + AsyncTimeout."""
2
+
3
+ from httpware.middleware.resilience.budget import RetryBudget
4
+ from httpware.middleware.resilience.bulkhead import AsyncBulkhead, Bulkhead
5
+ from httpware.middleware.resilience.circuit_breaker import AsyncCircuitBreaker, CircuitBreaker
6
+ from httpware.middleware.resilience.retry import AsyncRetry, Retry
7
+ from httpware.middleware.resilience.timeout import AsyncTimeout
8
+
9
+
10
+ __all__ = [
11
+ "AsyncBulkhead",
12
+ "AsyncCircuitBreaker",
13
+ "AsyncRetry",
14
+ "AsyncTimeout",
15
+ "Bulkhead",
16
+ "CircuitBreaker",
17
+ "Retry",
18
+ "RetryBudget",
19
+ ]
@@ -0,0 +1,302 @@
1
+ """CircuitBreaker + AsyncCircuitBreaker — classic consecutive-failure circuit breaker.
2
+
3
+ See planning/specs/2026-06-13-circuit-breaker-and-timeout-design.md for the contract.
4
+
5
+ A counted failure is a NetworkError, an httpware TimeoutError, or a StatusError whose
6
+ status_code is in the effective failure set (default: all 5xx). 4xx — including 429 —
7
+ count as successes: 429 means healthy-but-throttling, and tripping on it amplifies
8
+ incidents. Any other exception propagates without affecting circuit state.
9
+
10
+ State machine (classic / consecutive-failure):
11
+ CLOSED — forward; count consecutive counted-failures; open at failure_threshold.
12
+ OPEN — fast-fail with CircuitOpenError; after reset_timeout the next request
13
+ becomes the half-open probe.
14
+ HALF_OPEN — admit exactly one probe at a time; success_threshold consecutive probe
15
+ successes close the circuit; one probe failure re-opens it.
16
+
17
+ The lock-free _CircuitBreakerState holds the transition logic, shared by both wrappers.
18
+ AsyncCircuitBreaker relies on asyncio atomicity (no await inside a transition) plus a
19
+ single-event-loop guard; CircuitBreaker (sync) serializes transitions with a
20
+ threading.Lock. Both are sharable across clients (one shared circuit); a sync instance
21
+ cannot be shared with an async one.
22
+ """
23
+
24
+ import asyncio
25
+ import enum
26
+ import logging
27
+ import threading
28
+ import time
29
+ import typing
30
+ from collections.abc import Callable, Collection
31
+
32
+ import httpx2
33
+
34
+ from httpware._internal.observability import _emit_event
35
+ from httpware.errors import CircuitOpenError, NetworkError, StatusError, TimeoutError # noqa: A004
36
+ from httpware.middleware import AsyncNext, Next
37
+
38
+
39
+ _FAILURE_THRESHOLD_INVALID = "failure_threshold must be >= 1"
40
+ _RESET_TIMEOUT_INVALID = "reset_timeout must be >= 0"
41
+ _SUCCESS_THRESHOLD_INVALID = "success_threshold must be >= 1"
42
+ _CROSS_LOOP_MSG = (
43
+ "AsyncCircuitBreaker is bound to a single event loop. First seen on {first!r}; "
44
+ "current request is on {current!r}. Use one AsyncCircuitBreaker per loop; "
45
+ "cross-thread sharing requires the sync CircuitBreaker primitive."
46
+ )
47
+
48
+ _DEFAULT_FAILURE_STATUS_CODES = frozenset(range(500, 600))
49
+
50
+ _ROLE_CLOSED = "closed"
51
+ _ROLE_PROBE = "probe"
52
+
53
+ _LOGGER = logging.getLogger("httpware.circuit_breaker")
54
+
55
+
56
+ class _CircuitState(enum.Enum):
57
+ CLOSED = "closed"
58
+ OPEN = "open"
59
+ HALF_OPEN = "half_open"
60
+
61
+
62
+ class _CircuitBreakerState:
63
+ """Lock-free circuit-breaker state machine shared by the sync + async wrappers.
64
+
65
+ Every method is synchronous and performs no I/O beyond logging. The async wrapper
66
+ calls these directly (atomic under a single event loop because no await occurs
67
+ inside a transition); the sync wrapper wraps each call in a threading.Lock.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ *,
73
+ failure_threshold: int,
74
+ reset_timeout: float,
75
+ success_threshold: int,
76
+ failure_status_codes: Collection[int] | None,
77
+ now: Callable[[], float],
78
+ ) -> None:
79
+ if failure_threshold < 1:
80
+ raise ValueError(_FAILURE_THRESHOLD_INVALID)
81
+ if reset_timeout < 0:
82
+ raise ValueError(_RESET_TIMEOUT_INVALID)
83
+ if success_threshold < 1:
84
+ raise ValueError(_SUCCESS_THRESHOLD_INVALID)
85
+ self._failure_threshold = failure_threshold
86
+ self._reset_timeout = reset_timeout
87
+ self._success_threshold = success_threshold
88
+ # Accept any Collection (set, frozenset, list, ...) and freeze it so callers
89
+ # aren't forced to construct a frozenset just to satisfy the type checker.
90
+ self._failure_status_codes = (
91
+ frozenset(failure_status_codes) if failure_status_codes is not None else _DEFAULT_FAILURE_STATUS_CODES
92
+ )
93
+ self._now = now
94
+ self._state = _CircuitState.CLOSED
95
+ self._consecutive_failures = 0
96
+ self._consecutive_successes = 0
97
+ self._opened_at = 0.0
98
+ self._probe_in_flight = False
99
+
100
+ def is_failure_status(self, status_code: int) -> bool:
101
+ return status_code in self._failure_status_codes
102
+
103
+ def admit(self, request: httpx2.Request) -> str:
104
+ """Decide the request's role, or raise CircuitOpenError. No await inside."""
105
+ if self._state is _CircuitState.CLOSED:
106
+ return _ROLE_CLOSED
107
+ if self._state is _CircuitState.OPEN:
108
+ elapsed = self._now() - self._opened_at
109
+ if elapsed >= self._reset_timeout:
110
+ self._state = _CircuitState.HALF_OPEN
111
+ self._probe_in_flight = True
112
+ self._emit(request, "circuit.half_open", logging.INFO, "circuit half-open — admitting probe", {})
113
+ return _ROLE_PROBE
114
+ retry_after = max(0.0, self._reset_timeout - elapsed)
115
+ self._emit(
116
+ request,
117
+ "circuit.rejected",
118
+ logging.WARNING,
119
+ "circuit open — rejecting request",
120
+ {"retry_after": retry_after},
121
+ )
122
+ raise CircuitOpenError(retry_after=retry_after)
123
+ # HALF_OPEN
124
+ if self._probe_in_flight:
125
+ self._emit(
126
+ request,
127
+ "circuit.rejected",
128
+ logging.WARNING,
129
+ "circuit half-open — rejecting request (probe in flight)",
130
+ {"retry_after": None},
131
+ )
132
+ raise CircuitOpenError(retry_after=None)
133
+ self._probe_in_flight = True
134
+ return _ROLE_PROBE
135
+
136
+ def on_success(self, role: str, request: httpx2.Request) -> None:
137
+ if role == _ROLE_PROBE:
138
+ self._probe_in_flight = False
139
+ if self._state is _CircuitState.CLOSED:
140
+ self._consecutive_failures = 0
141
+ elif self._state is _CircuitState.HALF_OPEN:
142
+ self._consecutive_successes += 1
143
+ if self._consecutive_successes >= self._success_threshold:
144
+ self._state = _CircuitState.CLOSED
145
+ self._consecutive_failures = 0
146
+ self._consecutive_successes = 0
147
+ self._emit(request, "circuit.closed", logging.INFO, "circuit closed — service recovered", {})
148
+
149
+ def on_failure(self, role: str, request: httpx2.Request) -> None:
150
+ if role == _ROLE_PROBE:
151
+ self._probe_in_flight = False
152
+ if self._state is _CircuitState.CLOSED:
153
+ self._consecutive_failures += 1
154
+ if self._consecutive_failures >= self._failure_threshold:
155
+ self._open(request, failures=self._consecutive_failures)
156
+ elif self._state is _CircuitState.HALF_OPEN:
157
+ self._open(request, failures=1) # 1 = the single probe failure that re-opened the circuit
158
+
159
+ def release_probe(self, role: str) -> None:
160
+ """Release the probe slot without recording success or failure (non-counted exc)."""
161
+ if role == _ROLE_PROBE:
162
+ self._probe_in_flight = False
163
+
164
+ def _open(self, request: httpx2.Request, *, failures: int) -> None:
165
+ self._state = _CircuitState.OPEN
166
+ self._opened_at = self._now()
167
+ self._consecutive_failures = 0
168
+ self._consecutive_successes = 0
169
+ self._emit(
170
+ request,
171
+ "circuit.opened",
172
+ logging.WARNING,
173
+ "circuit opened — failure threshold reached",
174
+ {"failure_threshold": self._failure_threshold, "failures": failures},
175
+ )
176
+
177
+ def _emit(
178
+ self,
179
+ request: httpx2.Request,
180
+ event_name: str,
181
+ level: int,
182
+ message: str,
183
+ attributes: dict[str, typing.Any],
184
+ ) -> None:
185
+ _emit_event(
186
+ _LOGGER,
187
+ event_name,
188
+ level=level,
189
+ message=message,
190
+ attributes={**attributes, "method": request.method, "url": str(request.url)},
191
+ )
192
+
193
+
194
+ class AsyncCircuitBreaker:
195
+ """Async classic circuit breaker middleware. See the module docstring for the contract."""
196
+
197
+ def __init__(
198
+ self,
199
+ *,
200
+ failure_threshold: int = 5,
201
+ reset_timeout: float = 30.0,
202
+ success_threshold: int = 1,
203
+ failure_status_codes: Collection[int] | None = None,
204
+ _now: Callable[[], float] = time.monotonic,
205
+ ) -> None:
206
+ self._state = _CircuitBreakerState(
207
+ failure_threshold=failure_threshold,
208
+ reset_timeout=reset_timeout,
209
+ success_threshold=success_threshold,
210
+ failure_status_codes=failure_status_codes,
211
+ now=_now,
212
+ )
213
+ self._loop: asyncio.AbstractEventLoop | None = None
214
+ self._loop_lock = threading.Lock()
215
+
216
+ def _check_loop(self) -> None:
217
+ current = asyncio.get_running_loop()
218
+ cached = self._loop
219
+ if cached is current:
220
+ return
221
+ if cached is not None:
222
+ raise RuntimeError(_CROSS_LOOP_MSG.format(first=cached, current=current))
223
+ with self._loop_lock:
224
+ if self._loop is None:
225
+ self._loop = current
226
+ # pragma below: inner double-check-with-lock race arm; only reachable when
227
+ # two threads simultaneously pass the outer check, which single-threaded
228
+ # tests can't trigger.
229
+ elif self._loop is not current: # pragma: no cover
230
+ raise RuntimeError(_CROSS_LOOP_MSG.format(first=self._loop, current=current))
231
+
232
+ async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002
233
+ """Admit, forward, then record the outcome. Fast-fail when the circuit is not closed."""
234
+ self._check_loop()
235
+ role = self._state.admit(request)
236
+ try:
237
+ response = await next(request)
238
+ except StatusError as exc:
239
+ if self._state.is_failure_status(exc.response.status_code):
240
+ self._state.on_failure(role, request)
241
+ else:
242
+ self._state.on_success(role, request)
243
+ raise
244
+ except (NetworkError, TimeoutError):
245
+ self._state.on_failure(role, request)
246
+ raise
247
+ except BaseException:
248
+ self._state.release_probe(role)
249
+ raise
250
+ self._state.on_success(role, request)
251
+ return response
252
+
253
+
254
+ class CircuitBreaker:
255
+ """Sync classic circuit breaker middleware. Mirror of AsyncCircuitBreaker.
256
+
257
+ Serializes every state transition with a threading.Lock. Sharable across Clients
258
+ (one shared circuit); a sync instance cannot be shared with an AsyncClient.
259
+ """
260
+
261
+ def __init__(
262
+ self,
263
+ *,
264
+ failure_threshold: int = 5,
265
+ reset_timeout: float = 30.0,
266
+ success_threshold: int = 1,
267
+ failure_status_codes: Collection[int] | None = None,
268
+ _now: Callable[[], float] = time.monotonic,
269
+ ) -> None:
270
+ self._state = _CircuitBreakerState(
271
+ failure_threshold=failure_threshold,
272
+ reset_timeout=reset_timeout,
273
+ success_threshold=success_threshold,
274
+ failure_status_codes=failure_status_codes,
275
+ now=_now,
276
+ )
277
+ self._lock = threading.Lock()
278
+
279
+ def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002
280
+ """Admit, forward, then record the outcome. Fast-fail when the circuit is not closed."""
281
+ with self._lock:
282
+ role = self._state.admit(request)
283
+ try:
284
+ response = next(request)
285
+ except StatusError as exc:
286
+ with self._lock:
287
+ if self._state.is_failure_status(exc.response.status_code):
288
+ self._state.on_failure(role, request)
289
+ else:
290
+ self._state.on_success(role, request)
291
+ raise
292
+ except (NetworkError, TimeoutError):
293
+ with self._lock:
294
+ self._state.on_failure(role, request)
295
+ raise
296
+ except BaseException:
297
+ with self._lock:
298
+ self._state.release_probe(role)
299
+ raise
300
+ with self._lock:
301
+ self._state.on_success(role, request)
302
+ return response
@@ -0,0 +1,75 @@
1
+ """AsyncTimeout middleware — overall wall-clock deadline across the inner pipeline.
2
+
3
+ This is NOT a per-call timeout — httpx2's connect/read/write/pool timeouts are the
4
+ right tool for bounding a single outbound call, and AsyncTimeout does not duplicate
5
+ them. What httpx2 cannot bound is the total wall-clock across the whole middleware
6
+ pipeline (most importantly across an AsyncRetry loop, whose attempts and backoff
7
+ sleeps it knows nothing about). Place AsyncTimeout outermost to enforce
8
+ "this whole operation must finish within `timeout` seconds, even across retries."
9
+
10
+ Async-only by design: a sync total-deadline cannot interrupt a blocking httpx2 call
11
+ mid-flight (sync Python has no cancellation), and httpx2 already covers sync per-call
12
+ timeouts. Sync callers configure httpx2's timeouts directly; there is no sync Timeout.
13
+ """
14
+
15
+ import asyncio
16
+ import logging
17
+
18
+ import httpx2
19
+
20
+ from httpware._internal.observability import _emit_event
21
+ from httpware.errors import TimeoutError as HttpwareTimeoutError
22
+ from httpware.middleware import AsyncNext
23
+
24
+
25
+ _TIMEOUT_INVALID = "timeout must be > 0"
26
+
27
+ _LOGGER = logging.getLogger("httpware.timeout")
28
+
29
+
30
+ class AsyncTimeout:
31
+ """Bounds total wall-clock time spent in the inner pipeline.
32
+
33
+ Parameters
34
+ ----------
35
+ timeout
36
+ Required. Overall deadline in seconds for ``next(request)`` to complete,
37
+ including everything it wraps (retries, backoff sleeps, the call itself).
38
+ Must be ``> 0``. On expiry the middleware raises ``httpware.TimeoutError``.
39
+
40
+ Place outermost in the chain for an overall-operation deadline. For bounding a
41
+ single outbound call (connect/read/write/pool), configure ``httpx2`` instead.
42
+
43
+ """
44
+
45
+ def __init__(self, *, timeout: float) -> None:
46
+ if timeout <= 0:
47
+ raise ValueError(_TIMEOUT_INVALID)
48
+ self._timeout = timeout
49
+
50
+ async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002
51
+ """Invoke next under an asyncio.timeout; raise httpware.TimeoutError on expiry.
52
+
53
+ Only a deadline THIS middleware imposed is re-wrapped: ``cm.expired()``
54
+ distinguishes our own expiry from an inner ``TimeoutError`` (e.g. an httpx2
55
+ per-call timeout surfacing through a retry), which propagates unchanged.
56
+ """
57
+ try:
58
+ async with asyncio.timeout(self._timeout) as cm:
59
+ return await next(request)
60
+ except TimeoutError as exc:
61
+ if not cm.expired():
62
+ raise # inner TimeoutError, not our deadline — leave it untouched
63
+ _emit_event(
64
+ _LOGGER,
65
+ "timeout.exceeded",
66
+ level=logging.WARNING,
67
+ message="overall timeout exceeded",
68
+ attributes={
69
+ "timeout": self._timeout,
70
+ "method": request.method,
71
+ "url": str(request.url),
72
+ },
73
+ )
74
+ msg = f"overall timeout of {self._timeout}s exceeded"
75
+ raise HttpwareTimeoutError(msg) from exc
@@ -1,66 +0,0 @@
1
- """MsgspecDecoder — opt-in ResponseDecoder backed by a per-instance msgspec.json.Decoder cache."""
2
-
3
- import typing
4
- from typing import TypeVar
5
-
6
- from httpware._internal import import_checker
7
-
8
-
9
- if import_checker.is_msgspec_installed:
10
- import msgspec
11
-
12
-
13
- MISSING_DEPENDENCY_MESSAGE = "MsgspecDecoder requires the 'msgspec' extra. Install with: pip install httpware[msgspec]"
14
-
15
- T = TypeVar("T")
16
-
17
-
18
- class MsgspecDecoder:
19
- """Decode raw response bytes via a per-instance cached `msgspec.json.Decoder(model)`.
20
-
21
- Requires the `msgspec` extra: `pip install httpware[msgspec]`. Importing
22
- this module without the extra works (the `msgspec` import is guarded by a
23
- `find_spec` check), but instantiating the decoder raises `ImportError`.
24
- """
25
-
26
- _msgspec_decoders: dict[type, "msgspec.json.Decoder[typing.Any]"]
27
-
28
- def __init__(self) -> None:
29
- if not import_checker.is_msgspec_installed:
30
- raise ImportError(MISSING_DEPENDENCY_MESSAGE)
31
- self._msgspec_decoders = {}
32
-
33
- def _get_msgspec_decoder(self, model: type[T]) -> "msgspec.json.Decoder[T]":
34
- decoder = self._msgspec_decoders.get(model)
35
- if decoder is None:
36
- decoder = msgspec.json.Decoder(model)
37
- self._msgspec_decoders[model] = decoder
38
- return decoder
39
-
40
- def can_decode(self, model: type) -> bool:
41
- """Return True iff msgspec natively understands `model`.
42
-
43
- msgspec builds a Decoder for almost any class via a generic CustomType
44
- fallback; the Decoder constructor itself does NOT raise on unsupported
45
- types (e.g. pydantic.BaseModel). We use msgspec.inspect.type_info
46
- to detect the fallback and reject CustomType results explicitly.
47
- """
48
- try:
49
- info = msgspec.inspect.type_info(model)
50
- except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
51
- return False
52
- if isinstance(info, msgspec.inspect.CustomType):
53
- return False
54
- try:
55
- self._get_msgspec_decoder(model)
56
- except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
57
- return False
58
- return True
59
-
60
- def decode(self, content: bytes, model: type[T]) -> T:
61
- """Validate `content` as JSON against `model` in a single parse pass."""
62
- try:
63
- decoder = self._get_msgspec_decoder(model)
64
- except TypeError:
65
- decoder = msgspec.json.Decoder(model)
66
- return decoder.decode(content)
@@ -1,8 +0,0 @@
1
- """Resilience primitives: Bulkhead/AsyncBulkhead, Retry/AsyncRetry, RetryBudget."""
2
-
3
- from httpware.middleware.resilience.budget import RetryBudget
4
- from httpware.middleware.resilience.bulkhead import AsyncBulkhead, Bulkhead
5
- from httpware.middleware.resilience.retry import AsyncRetry, Retry
6
-
7
-
8
- __all__ = ["AsyncBulkhead", "AsyncRetry", "Bulkhead", "Retry", "RetryBudget"]