httpware 0.13.0__tar.gz → 0.14.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 (25) hide show
  1. {httpware-0.13.0 → httpware-0.14.0}/PKG-INFO +1 -1
  2. {httpware-0.13.0 → httpware-0.14.0}/pyproject.toml +1 -1
  3. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/__init__.py +2 -0
  4. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/resilience/__init__.py +2 -1
  5. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/resilience/circuit_breaker.py +34 -11
  6. {httpware-0.13.0 → httpware-0.14.0}/README.md +0 -0
  7. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/_internal/__init__.py +0 -0
  8. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/_internal/exception_mapping.py +0 -0
  9. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/_internal/import_checker.py +0 -0
  10. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/_internal/observability.py +0 -0
  11. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/_internal/redaction.py +0 -0
  12. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/_internal/status.py +0 -0
  13. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/client.py +0 -0
  14. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/decoders/__init__.py +0 -0
  15. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/decoders/msgspec.py +0 -0
  16. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/decoders/pydantic.py +0 -0
  17. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/errors.py +0 -0
  18. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/__init__.py +0 -0
  19. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/chain.py +0 -0
  20. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/resilience/_backoff.py +0 -0
  21. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/resilience/budget.py +0 -0
  22. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
  23. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/resilience/retry.py +0 -0
  24. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/resilience/timeout.py +0 -0
  25. {httpware-0.13.0 → httpware-0.14.0}/src/httpware/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpware
3
- Version: 0.13.0
3
+ Version: 0.14.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
@@ -26,7 +26,7 @@ classifiers = [
26
26
  "Topic :: Internet :: WWW/HTTP",
27
27
  "Framework :: AsyncIO",
28
28
  ]
29
- version = "0.13.0"
29
+ version = "0.14.0"
30
30
  dependencies = [
31
31
  "httpx2>=2.0.0,<3.0",
32
32
  ]
@@ -46,6 +46,7 @@ from httpware.middleware.resilience import (
46
46
  AsyncTimeout,
47
47
  Bulkhead,
48
48
  CircuitBreaker,
49
+ CircuitState,
49
50
  Retry,
50
51
  RetryBudget,
51
52
  )
@@ -65,6 +66,7 @@ __all__ = [
65
66
  "BulkheadFullError",
66
67
  "CircuitBreaker",
67
68
  "CircuitOpenError",
69
+ "CircuitState",
68
70
  "Client",
69
71
  "ClientError",
70
72
  "ClientStatusError",
@@ -2,7 +2,7 @@
2
2
 
3
3
  from httpware.middleware.resilience.budget import RetryBudget
4
4
  from httpware.middleware.resilience.bulkhead import AsyncBulkhead, Bulkhead
5
- from httpware.middleware.resilience.circuit_breaker import AsyncCircuitBreaker, CircuitBreaker
5
+ from httpware.middleware.resilience.circuit_breaker import AsyncCircuitBreaker, CircuitBreaker, CircuitState
6
6
  from httpware.middleware.resilience.retry import AsyncRetry, Retry
7
7
  from httpware.middleware.resilience.timeout import AsyncTimeout
8
8
 
@@ -14,6 +14,7 @@ __all__ = [
14
14
  "AsyncTimeout",
15
15
  "Bulkhead",
16
16
  "CircuitBreaker",
17
+ "CircuitState",
17
18
  "Retry",
18
19
  "RetryBudget",
19
20
  ]
@@ -70,7 +70,9 @@ _ROLE_PROBE = "probe"
70
70
  _LOGGER = logging.getLogger("httpware.circuit_breaker")
71
71
 
72
72
 
73
- class _CircuitState(enum.Enum):
73
+ class CircuitState(enum.Enum):
74
+ """Lifecycle state of a circuit breaker: CLOSED, OPEN, or HALF_OPEN."""
75
+
74
76
  CLOSED = "closed"
75
77
  OPEN = "open"
76
78
  HALF_OPEN = "half_open"
@@ -172,7 +174,7 @@ class _CircuitBreakerState:
172
174
  self._window = _RollingWindow(window_seconds) if self._rate_mode else None
173
175
  self._window_seconds = window_seconds
174
176
  self._now = now
175
- self._state = _CircuitState.CLOSED
177
+ self._state = CircuitState.CLOSED
176
178
  self._consecutive_failures = 0
177
179
  self._consecutive_successes = 0
178
180
  self._opened_at = 0.0
@@ -181,14 +183,19 @@ class _CircuitBreakerState:
181
183
  def is_failure_status(self, status_code: int) -> bool:
182
184
  return status_code in self._failure_status_codes
183
185
 
186
+ @property
187
+ def state(self) -> CircuitState:
188
+ """The circuit's current stored state (raw read; no lazy OPEN→HALF_OPEN transition)."""
189
+ return self._state
190
+
184
191
  def admit(self, request: httpx2.Request) -> str:
185
192
  """Decide the request's role, or raise CircuitOpenError. No await inside."""
186
- if self._state is _CircuitState.CLOSED:
193
+ if self._state is CircuitState.CLOSED:
187
194
  return _ROLE_CLOSED
188
- if self._state is _CircuitState.OPEN:
195
+ if self._state is CircuitState.OPEN:
189
196
  elapsed = self._now() - self._opened_at
190
197
  if elapsed >= self._reset_timeout:
191
- self._state = _CircuitState.HALF_OPEN
198
+ self._state = CircuitState.HALF_OPEN
192
199
  self._probe_in_flight = True
193
200
  self._emit(request, "circuit.half_open", logging.INFO, "circuit half-open — admitting probe", {})
194
201
  return _ROLE_PROBE
@@ -217,15 +224,15 @@ class _CircuitBreakerState:
217
224
  def on_success(self, role: str, request: httpx2.Request) -> None:
218
225
  if role == _ROLE_PROBE:
219
226
  self._probe_in_flight = False
220
- if self._state is _CircuitState.CLOSED:
227
+ if self._state is CircuitState.CLOSED:
221
228
  if self._rate_mode:
222
229
  self._record_outcome(request, failed=False)
223
230
  else:
224
231
  self._consecutive_failures = 0
225
- elif self._state is _CircuitState.HALF_OPEN:
232
+ elif self._state is CircuitState.HALF_OPEN:
226
233
  self._consecutive_successes += 1
227
234
  if self._consecutive_successes >= self._success_threshold:
228
- self._state = _CircuitState.CLOSED
235
+ self._state = CircuitState.CLOSED
229
236
  self._consecutive_failures = 0
230
237
  self._consecutive_successes = 0
231
238
  if self._rate_mode:
@@ -235,14 +242,14 @@ class _CircuitBreakerState:
235
242
  def on_failure(self, role: str, request: httpx2.Request) -> None:
236
243
  if role == _ROLE_PROBE:
237
244
  self._probe_in_flight = False
238
- if self._state is _CircuitState.CLOSED:
245
+ if self._state is CircuitState.CLOSED:
239
246
  if self._rate_mode:
240
247
  self._record_outcome(request, failed=True)
241
248
  else:
242
249
  self._consecutive_failures += 1
243
250
  if self._consecutive_failures >= self._failure_threshold:
244
251
  self._open(request, failures=self._consecutive_failures)
245
- elif self._state is _CircuitState.HALF_OPEN:
252
+ elif self._state is CircuitState.HALF_OPEN:
246
253
  self._open(request, failures=1) # 1 = the single probe failure that re-opened the circuit
247
254
 
248
255
  def release_probe(self, role: str) -> None:
@@ -251,7 +258,7 @@ class _CircuitBreakerState:
251
258
  self._probe_in_flight = False
252
259
 
253
260
  def _enter_open(self, request: httpx2.Request, message: str, attributes: dict[str, typing.Any]) -> None:
254
- self._state = _CircuitState.OPEN
261
+ self._state = CircuitState.OPEN
255
262
  self._opened_at = self._now()
256
263
  self._consecutive_failures = 0
257
264
  self._consecutive_successes = 0
@@ -346,6 +353,14 @@ class AsyncCircuitBreaker:
346
353
  elif self._loop is not current: # pragma: no cover
347
354
  raise RuntimeError(_CROSS_LOOP_MSG.format(first=self._loop, current=current))
348
355
 
356
+ @property
357
+ def state(self) -> CircuitState:
358
+ """Current circuit state — CLOSED, OPEN, or HALF_OPEN.
359
+
360
+ Read-only and side-effect-free (a single atomic attribute read; intentionally lock-free).
361
+ """
362
+ return self._state.state
363
+
349
364
  async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002
350
365
  """Admit, forward, then record the outcome. Fast-fail when the circuit is not closed."""
351
366
  self._check_loop()
@@ -399,6 +414,14 @@ class CircuitBreaker:
399
414
  )
400
415
  self._lock = threading.Lock()
401
416
 
417
+ @property
418
+ def state(self) -> CircuitState:
419
+ """Current circuit state — CLOSED, OPEN, or HALF_OPEN.
420
+
421
+ Read-only and side-effect-free (a single atomic attribute read; intentionally lock-free).
422
+ """
423
+ return self._state.state
424
+
402
425
  def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002
403
426
  """Admit, forward, then record the outcome. Fast-fail when the circuit is not closed."""
404
427
  with self._lock:
File without changes