httpware 0.9.1__tar.gz → 0.10.1__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 (25) hide show
  1. {httpware-0.9.1 → httpware-0.10.1}/PKG-INFO +8 -6
  2. {httpware-0.9.1 → httpware-0.10.1}/README.md +7 -5
  3. {httpware-0.9.1 → httpware-0.10.1}/pyproject.toml +1 -1
  4. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/__init__.py +15 -1
  5. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/_internal/observability.py +11 -4
  6. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/errors.py +29 -0
  7. httpware-0.10.1/src/httpware/middleware/resilience/__init__.py +19 -0
  8. httpware-0.10.1/src/httpware/middleware/resilience/circuit_breaker.py +305 -0
  9. httpware-0.10.1/src/httpware/middleware/resilience/timeout.py +76 -0
  10. httpware-0.9.1/src/httpware/middleware/resilience/__init__.py +0 -8
  11. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/_internal/__init__.py +0 -0
  12. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/_internal/exception_mapping.py +0 -0
  13. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/_internal/import_checker.py +0 -0
  14. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/_internal/status.py +0 -0
  15. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/client.py +0 -0
  16. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/decoders/__init__.py +0 -0
  17. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/decoders/msgspec.py +0 -0
  18. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/decoders/pydantic.py +0 -0
  19. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/middleware/__init__.py +0 -0
  20. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/middleware/chain.py +0 -0
  21. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/middleware/resilience/_backoff.py +0 -0
  22. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/middleware/resilience/budget.py +0 -0
  23. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/middleware/resilience/bulkhead.py +0 -0
  24. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/middleware/resilience/retry.py +0 -0
  25. {httpware-0.9.1 → httpware-0.10.1}/src/httpware/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpware
3
- Version: 0.9.1
3
+ Version: 0.10.1
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
 
@@ -142,20 +142,22 @@ It does NOT pass through the middleware chain: `AsyncRetry`, `AsyncBulkhead`, an
142
142
 
143
143
  ## Errors
144
144
 
145
- 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`.
145
+ 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`, `BulkheadFullError`, and `CircuitOpenError`. Everything inherits `httpware.ClientError`.
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.INFO) # INFO: includes recovery events (half_open, closed)
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
 
@@ -112,20 +112,22 @@ It does NOT pass through the middleware chain: `AsyncRetry`, `AsyncBulkhead`, an
112
112
 
113
113
  ## Errors
114
114
 
115
- 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`.
115
+ 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`, `BulkheadFullError`, and `CircuitOpenError`. Everything inherits `httpware.ClientError`.
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.INFO) # INFO: includes recovery events (half_open, closed)
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.1"
29
+ version = "0.10.1"
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",
@@ -2,11 +2,13 @@
2
2
 
3
3
  See planning/specs/2026-06-05-observability-design.md for the contract.
4
4
 
5
- Logger names (``httpware.retry``, ``httpware.bulkhead``) and event names
6
- (``retry.giving_up``, ``bulkhead.rejected``, etc.) are the public observability
5
+ Logger names (``httpware.retry``, ``httpware.bulkhead``, ``httpware.circuit_breaker``,
6
+ ``httpware.timeout``) and event names (``retry.giving_up``, ``bulkhead.rejected``,
7
+ ``circuit.opened``, ``timeout.exceeded``, etc.) are the public observability
7
8
  surface. They are stable: renames are breaking changes.
8
9
  """
9
10
 
11
+ import contextlib
10
12
  import logging
11
13
  import typing
12
14
 
@@ -37,7 +39,7 @@ def _emit_event(
37
39
  the optional-extras isolation invariant: ``import httpware`` must not pull
38
40
  ``opentelemetry`` into ``sys.modules`` when the extra is absent.
39
41
  """
40
- logger.log(level, message, extra=attributes)
42
+ logger.log(level, message, extra={**attributes, "event": event_name})
41
43
  if import_checker.is_otel_installed:
42
44
  try:
43
45
  from opentelemetry import trace # noqa: PLC0415 — lazy by design (optional-extras isolation)
@@ -45,4 +47,9 @@ def _emit_event(
45
47
  # opentelemetry namespace exists but the api package is broken or missing —
46
48
  # degrade to log-only emission. The structured log record above has already fired.
47
49
  return
48
- trace.get_current_span().add_event(event_name, attributes=attributes)
50
+ # Observability must never break the request path — suppress any failure from
51
+ # add_event (e.g. a recording span with a broken exporter or attribute validation).
52
+ # The structured log record above has already fired; CancelledError/KeyboardInterrupt
53
+ # are not Exception subclasses and will still propagate.
54
+ with contextlib.suppress(Exception):
55
+ trace.get_current_span().add_event(event_name, attributes=attributes)
@@ -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,305 @@
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. In
9
+ particular, non-NetworkError transport problems — e.g. httpx2.InvalidURL from a
10
+ malformed URL — are foreign: they propagate unchanged and do not increment the
11
+ failure counter, so programming errors cannot trip the breaker.
12
+
13
+ State machine (classic / consecutive-failure):
14
+ CLOSED — forward; count consecutive counted-failures; open at failure_threshold.
15
+ OPEN — fast-fail with CircuitOpenError; after reset_timeout the next request
16
+ becomes the half-open probe.
17
+ HALF_OPEN — admit exactly one probe at a time; success_threshold consecutive probe
18
+ successes close the circuit; one probe failure re-opens it.
19
+
20
+ The lock-free _CircuitBreakerState holds the transition logic, shared by both wrappers.
21
+ AsyncCircuitBreaker relies on asyncio atomicity (no await inside a transition) plus a
22
+ single-event-loop guard; CircuitBreaker (sync) serializes transitions with a
23
+ threading.Lock. Both are sharable across clients (one shared circuit); a sync instance
24
+ cannot be shared with an async one.
25
+ """
26
+
27
+ import asyncio
28
+ import enum
29
+ import logging
30
+ import threading
31
+ import time
32
+ import typing
33
+ from collections.abc import Callable, Collection
34
+
35
+ import httpx2
36
+
37
+ from httpware._internal.observability import _emit_event
38
+ from httpware.errors import CircuitOpenError, NetworkError, StatusError, TimeoutError # noqa: A004
39
+ from httpware.middleware import AsyncNext, Next
40
+
41
+
42
+ _FAILURE_THRESHOLD_INVALID = "failure_threshold must be >= 1"
43
+ _RESET_TIMEOUT_INVALID = "reset_timeout must be >= 0"
44
+ _SUCCESS_THRESHOLD_INVALID = "success_threshold must be >= 1"
45
+ _CROSS_LOOP_MSG = (
46
+ "AsyncCircuitBreaker is bound to a single event loop. First seen on {first!r}; "
47
+ "current request is on {current!r}. Use one AsyncCircuitBreaker per loop; "
48
+ "cross-thread sharing requires the sync CircuitBreaker primitive."
49
+ )
50
+
51
+ _DEFAULT_FAILURE_STATUS_CODES = frozenset(range(500, 600))
52
+
53
+ _ROLE_CLOSED = "closed"
54
+ _ROLE_PROBE = "probe"
55
+
56
+ _LOGGER = logging.getLogger("httpware.circuit_breaker")
57
+
58
+
59
+ class _CircuitState(enum.Enum):
60
+ CLOSED = "closed"
61
+ OPEN = "open"
62
+ HALF_OPEN = "half_open"
63
+
64
+
65
+ class _CircuitBreakerState:
66
+ """Lock-free circuit-breaker state machine shared by the sync + async wrappers.
67
+
68
+ Every method is synchronous and performs no I/O beyond logging. The async wrapper
69
+ calls these directly (atomic under a single event loop because no await occurs
70
+ inside a transition); the sync wrapper wraps each call in a threading.Lock.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ *,
76
+ failure_threshold: int,
77
+ reset_timeout: float,
78
+ success_threshold: int,
79
+ failure_status_codes: Collection[int] | None,
80
+ now: Callable[[], float],
81
+ ) -> None:
82
+ if failure_threshold < 1:
83
+ raise ValueError(_FAILURE_THRESHOLD_INVALID)
84
+ if reset_timeout < 0:
85
+ raise ValueError(_RESET_TIMEOUT_INVALID)
86
+ if success_threshold < 1:
87
+ raise ValueError(_SUCCESS_THRESHOLD_INVALID)
88
+ self._failure_threshold = failure_threshold
89
+ self._reset_timeout = reset_timeout
90
+ self._success_threshold = success_threshold
91
+ # Accept any Collection (set, frozenset, list, ...) and freeze it so callers
92
+ # aren't forced to construct a frozenset just to satisfy the type checker.
93
+ self._failure_status_codes = (
94
+ frozenset(failure_status_codes) if failure_status_codes is not None else _DEFAULT_FAILURE_STATUS_CODES
95
+ )
96
+ self._now = now
97
+ self._state = _CircuitState.CLOSED
98
+ self._consecutive_failures = 0
99
+ self._consecutive_successes = 0
100
+ self._opened_at = 0.0
101
+ self._probe_in_flight = False
102
+
103
+ def is_failure_status(self, status_code: int) -> bool:
104
+ return status_code in self._failure_status_codes
105
+
106
+ def admit(self, request: httpx2.Request) -> str:
107
+ """Decide the request's role, or raise CircuitOpenError. No await inside."""
108
+ if self._state is _CircuitState.CLOSED:
109
+ return _ROLE_CLOSED
110
+ if self._state is _CircuitState.OPEN:
111
+ elapsed = self._now() - self._opened_at
112
+ if elapsed >= self._reset_timeout:
113
+ self._state = _CircuitState.HALF_OPEN
114
+ self._probe_in_flight = True
115
+ self._emit(request, "circuit.half_open", logging.INFO, "circuit half-open — admitting probe", {})
116
+ return _ROLE_PROBE
117
+ retry_after = max(0.0, self._reset_timeout - elapsed)
118
+ self._emit(
119
+ request,
120
+ "circuit.rejected",
121
+ logging.WARNING,
122
+ "circuit open — rejecting request",
123
+ {"retry_after": retry_after},
124
+ )
125
+ raise CircuitOpenError(retry_after=retry_after)
126
+ # HALF_OPEN
127
+ if self._probe_in_flight:
128
+ self._emit(
129
+ request,
130
+ "circuit.rejected",
131
+ logging.WARNING,
132
+ "circuit half-open — rejecting request (probe in flight)",
133
+ {"retry_after": None},
134
+ )
135
+ raise CircuitOpenError(retry_after=None)
136
+ self._probe_in_flight = True
137
+ return _ROLE_PROBE
138
+
139
+ def on_success(self, role: str, request: httpx2.Request) -> None:
140
+ if role == _ROLE_PROBE:
141
+ self._probe_in_flight = False
142
+ if self._state is _CircuitState.CLOSED:
143
+ self._consecutive_failures = 0
144
+ elif self._state is _CircuitState.HALF_OPEN:
145
+ self._consecutive_successes += 1
146
+ if self._consecutive_successes >= self._success_threshold:
147
+ self._state = _CircuitState.CLOSED
148
+ self._consecutive_failures = 0
149
+ self._consecutive_successes = 0
150
+ self._emit(request, "circuit.closed", logging.INFO, "circuit closed — service recovered", {})
151
+
152
+ def on_failure(self, role: str, request: httpx2.Request) -> None:
153
+ if role == _ROLE_PROBE:
154
+ self._probe_in_flight = False
155
+ if self._state is _CircuitState.CLOSED:
156
+ self._consecutive_failures += 1
157
+ if self._consecutive_failures >= self._failure_threshold:
158
+ self._open(request, failures=self._consecutive_failures)
159
+ elif self._state is _CircuitState.HALF_OPEN:
160
+ self._open(request, failures=1) # 1 = the single probe failure that re-opened the circuit
161
+
162
+ def release_probe(self, role: str) -> None:
163
+ """Release the probe slot without recording success or failure (non-counted exc)."""
164
+ if role == _ROLE_PROBE:
165
+ self._probe_in_flight = False
166
+
167
+ def _open(self, request: httpx2.Request, *, failures: int) -> None:
168
+ self._state = _CircuitState.OPEN
169
+ self._opened_at = self._now()
170
+ self._consecutive_failures = 0
171
+ self._consecutive_successes = 0
172
+ self._emit(
173
+ request,
174
+ "circuit.opened",
175
+ logging.WARNING,
176
+ "circuit opened — failure threshold reached",
177
+ {"failure_threshold": self._failure_threshold, "failures": failures},
178
+ )
179
+
180
+ def _emit(
181
+ self,
182
+ request: httpx2.Request,
183
+ event_name: str,
184
+ level: int,
185
+ message: str,
186
+ attributes: dict[str, typing.Any],
187
+ ) -> None:
188
+ _emit_event(
189
+ _LOGGER,
190
+ event_name,
191
+ level=level,
192
+ message=message,
193
+ attributes={**attributes, "method": request.method, "url": str(request.url)},
194
+ )
195
+
196
+
197
+ class AsyncCircuitBreaker:
198
+ """Async classic circuit breaker middleware. See the module docstring for the contract."""
199
+
200
+ def __init__(
201
+ self,
202
+ *,
203
+ failure_threshold: int = 5,
204
+ reset_timeout: float = 30.0,
205
+ success_threshold: int = 1,
206
+ failure_status_codes: Collection[int] | None = None,
207
+ _now: Callable[[], float] = time.monotonic,
208
+ ) -> None:
209
+ self._state = _CircuitBreakerState(
210
+ failure_threshold=failure_threshold,
211
+ reset_timeout=reset_timeout,
212
+ success_threshold=success_threshold,
213
+ failure_status_codes=failure_status_codes,
214
+ now=_now,
215
+ )
216
+ self._loop: asyncio.AbstractEventLoop | None = None
217
+ self._loop_lock = threading.Lock()
218
+
219
+ def _check_loop(self) -> None:
220
+ current = asyncio.get_running_loop()
221
+ cached = self._loop
222
+ if cached is current:
223
+ return
224
+ if cached is not None:
225
+ raise RuntimeError(_CROSS_LOOP_MSG.format(first=cached, current=current))
226
+ with self._loop_lock:
227
+ if self._loop is None:
228
+ self._loop = current
229
+ # pragma below: inner double-check-with-lock race arm; only reachable when
230
+ # two threads simultaneously pass the outer check, which single-threaded
231
+ # tests can't trigger.
232
+ elif self._loop is not current: # pragma: no cover
233
+ raise RuntimeError(_CROSS_LOOP_MSG.format(first=self._loop, current=current))
234
+
235
+ async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002
236
+ """Admit, forward, then record the outcome. Fast-fail when the circuit is not closed."""
237
+ self._check_loop()
238
+ role = self._state.admit(request)
239
+ try:
240
+ response = await next(request)
241
+ except StatusError as exc:
242
+ if self._state.is_failure_status(exc.response.status_code):
243
+ self._state.on_failure(role, request)
244
+ else:
245
+ self._state.on_success(role, request)
246
+ raise
247
+ except (NetworkError, TimeoutError):
248
+ self._state.on_failure(role, request)
249
+ raise
250
+ except BaseException:
251
+ self._state.release_probe(role)
252
+ raise
253
+ self._state.on_success(role, request)
254
+ return response
255
+
256
+
257
+ class CircuitBreaker:
258
+ """Sync classic circuit breaker middleware. Mirror of AsyncCircuitBreaker.
259
+
260
+ Serializes every state transition with a threading.Lock. Sharable across Clients
261
+ (one shared circuit); a sync instance cannot be shared with an AsyncClient.
262
+ """
263
+
264
+ def __init__(
265
+ self,
266
+ *,
267
+ failure_threshold: int = 5,
268
+ reset_timeout: float = 30.0,
269
+ success_threshold: int = 1,
270
+ failure_status_codes: Collection[int] | None = None,
271
+ _now: Callable[[], float] = time.monotonic,
272
+ ) -> None:
273
+ self._state = _CircuitBreakerState(
274
+ failure_threshold=failure_threshold,
275
+ reset_timeout=reset_timeout,
276
+ success_threshold=success_threshold,
277
+ failure_status_codes=failure_status_codes,
278
+ now=_now,
279
+ )
280
+ self._lock = threading.Lock()
281
+
282
+ def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002
283
+ """Admit, forward, then record the outcome. Fast-fail when the circuit is not closed."""
284
+ with self._lock:
285
+ role = self._state.admit(request)
286
+ try:
287
+ response = next(request)
288
+ except StatusError as exc:
289
+ with self._lock:
290
+ if self._state.is_failure_status(exc.response.status_code):
291
+ self._state.on_failure(role, request)
292
+ else:
293
+ self._state.on_success(role, request)
294
+ raise
295
+ except (NetworkError, TimeoutError):
296
+ with self._lock:
297
+ self._state.on_failure(role, request)
298
+ raise
299
+ except BaseException:
300
+ with self._lock:
301
+ self._state.release_probe(role)
302
+ raise
303
+ with self._lock:
304
+ self._state.on_success(role, request)
305
+ return response
@@ -0,0 +1,76 @@
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
+ import math
18
+
19
+ import httpx2
20
+
21
+ from httpware._internal.observability import _emit_event
22
+ from httpware.errors import TimeoutError as HttpwareTimeoutError
23
+ from httpware.middleware import AsyncNext
24
+
25
+
26
+ _TIMEOUT_INVALID = "timeout must be a finite number > 0"
27
+
28
+ _LOGGER = logging.getLogger("httpware.timeout")
29
+
30
+
31
+ class AsyncTimeout:
32
+ """Bounds total wall-clock time spent in the inner pipeline.
33
+
34
+ Parameters
35
+ ----------
36
+ timeout
37
+ Required. Overall deadline in seconds for ``next(request)`` to complete,
38
+ including everything it wraps (retries, backoff sleeps, the call itself).
39
+ Must be ``> 0``. On expiry the middleware raises ``httpware.TimeoutError``.
40
+
41
+ Place outermost in the chain for an overall-operation deadline. For bounding a
42
+ single outbound call (connect/read/write/pool), configure ``httpx2`` instead.
43
+
44
+ """
45
+
46
+ def __init__(self, *, timeout: float) -> None:
47
+ if not math.isfinite(timeout) or timeout <= 0:
48
+ raise ValueError(_TIMEOUT_INVALID)
49
+ self._timeout = timeout
50
+
51
+ async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002
52
+ """Invoke next under an asyncio.timeout; raise httpware.TimeoutError on expiry.
53
+
54
+ Only a deadline THIS middleware imposed is re-wrapped: ``cm.expired()``
55
+ distinguishes our own expiry from an inner ``TimeoutError`` (e.g. an httpx2
56
+ per-call timeout surfacing through a retry), which propagates unchanged.
57
+ """
58
+ try:
59
+ async with asyncio.timeout(self._timeout) as cm:
60
+ return await next(request)
61
+ except TimeoutError as exc:
62
+ if not cm.expired():
63
+ raise # inner TimeoutError, not our deadline — leave it untouched
64
+ _emit_event(
65
+ _LOGGER,
66
+ "timeout.exceeded",
67
+ level=logging.WARNING,
68
+ message="overall timeout exceeded",
69
+ attributes={
70
+ "timeout": self._timeout,
71
+ "method": request.method,
72
+ "url": str(request.url),
73
+ },
74
+ )
75
+ msg = f"overall timeout of {self._timeout}s exceeded"
76
+ raise HttpwareTimeoutError(msg) from exc
@@ -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"]