httpware 0.11.0__tar.gz → 0.13.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.11.0 → httpware-0.13.0}/PKG-INFO +1 -1
  2. {httpware-0.11.0 → httpware-0.13.0}/pyproject.toml +1 -1
  3. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/client.py +486 -6
  4. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/middleware/resilience/circuit_breaker.py +132 -12
  5. {httpware-0.11.0 → httpware-0.13.0}/README.md +0 -0
  6. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/__init__.py +0 -0
  7. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/_internal/__init__.py +0 -0
  8. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/_internal/exception_mapping.py +0 -0
  9. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/_internal/import_checker.py +0 -0
  10. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/_internal/observability.py +0 -0
  11. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/_internal/redaction.py +0 -0
  12. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/_internal/status.py +0 -0
  13. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/decoders/__init__.py +0 -0
  14. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/decoders/msgspec.py +0 -0
  15. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/decoders/pydantic.py +0 -0
  16. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/errors.py +0 -0
  17. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/middleware/__init__.py +0 -0
  18. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/middleware/chain.py +0 -0
  19. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/middleware/resilience/__init__.py +0 -0
  20. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/middleware/resilience/_backoff.py +0 -0
  21. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/middleware/resilience/budget.py +0 -0
  22. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
  23. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/middleware/resilience/retry.py +0 -0
  24. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/middleware/resilience/timeout.py +0 -0
  25. {httpware-0.11.0 → httpware-0.13.0}/src/httpware/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpware
3
- Version: 0.11.0
3
+ Version: 0.13.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.11.0"
29
+ version = "0.13.0"
30
30
  dependencies = [
31
31
  "httpx2>=2.0.0,<3.0",
32
32
  ]
@@ -228,7 +228,7 @@ class AsyncClient:
228
228
  """Delegate request construction to the wrapped httpx2.AsyncClient."""
229
229
  return self._httpx2_client.build_request(method, url, **kwargs)
230
230
 
231
- async def _request_with_body( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
231
+ def _prepare_request( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
232
232
  self,
233
233
  method: str,
234
234
  url: str,
@@ -242,8 +242,7 @@ class AsyncClient:
242
242
  content: typing.Any | None = None,
243
243
  data: typing.Any | None = None,
244
244
  files: typing.Any | None = None,
245
- response_model: type[T] | None = None,
246
- ) -> httpx2.Response | T:
245
+ ) -> httpx2.Request:
247
246
  kwargs: dict[str, typing.Any] = {}
248
247
  if params is not None:
249
248
  kwargs["params"] = params
@@ -266,8 +265,70 @@ class AsyncClient:
266
265
  request = self._httpx2_client.build_request(method, url, **kwargs)
267
266
  if _is_streaming_body_async(content) or _is_streaming_body_async(data) or _is_streaming_body_async(files):
268
267
  request.extensions[STREAMING_BODY_MARKER] = True
268
+ return request
269
+
270
+ async def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method signatures
271
+ self,
272
+ method: str,
273
+ url: str,
274
+ *,
275
+ params: typing.Any | None = None,
276
+ headers: typing.Any | None = None,
277
+ cookies: typing.Any | None = None,
278
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
279
+ extensions: typing.Any | None = None,
280
+ json: typing.Any | None = None,
281
+ content: typing.Any | None = None,
282
+ data: typing.Any | None = None,
283
+ files: typing.Any | None = None,
284
+ response_model: type[T] | None = None,
285
+ ) -> httpx2.Response | T:
286
+ request = self._prepare_request(
287
+ method,
288
+ url,
289
+ params=params,
290
+ headers=headers,
291
+ cookies=cookies,
292
+ timeout=timeout,
293
+ extensions=extensions,
294
+ json=json,
295
+ content=content,
296
+ data=data,
297
+ files=files,
298
+ )
269
299
  return await self.send(request, response_model=response_model)
270
300
 
301
+ async def _request_with_body_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
302
+ self,
303
+ method: str,
304
+ url: str,
305
+ *,
306
+ params: typing.Any | None = None,
307
+ headers: typing.Any | None = None,
308
+ cookies: typing.Any | None = None,
309
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
310
+ extensions: typing.Any | None = None,
311
+ json: typing.Any | None = None,
312
+ content: typing.Any | None = None,
313
+ data: typing.Any | None = None,
314
+ files: typing.Any | None = None,
315
+ response_model: type[T],
316
+ ) -> tuple[httpx2.Response, T]:
317
+ request = self._prepare_request(
318
+ method,
319
+ url,
320
+ params=params,
321
+ headers=headers,
322
+ cookies=cookies,
323
+ timeout=timeout,
324
+ extensions=extensions,
325
+ json=json,
326
+ content=content,
327
+ data=data,
328
+ files=files,
329
+ )
330
+ return await self.send_with_response(request, response_model=response_model)
331
+
271
332
  @typing.overload
272
333
  async def get(
273
334
  self,
@@ -317,6 +378,29 @@ class AsyncClient:
317
378
  response_model=response_model,
318
379
  )
319
380
 
381
+ async def get_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
382
+ self,
383
+ url: str,
384
+ *,
385
+ params: typing.Any | None = None,
386
+ headers: typing.Any | None = None,
387
+ cookies: typing.Any | None = None,
388
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
389
+ extensions: typing.Any | None = None,
390
+ response_model: type[T],
391
+ ) -> tuple[httpx2.Response, T]:
392
+ """Send a GET request; return (response, decoded body)."""
393
+ return await self._request_with_body_with_response(
394
+ "GET",
395
+ url,
396
+ params=params,
397
+ headers=headers,
398
+ cookies=cookies,
399
+ timeout=timeout,
400
+ extensions=extensions,
401
+ response_model=response_model,
402
+ )
403
+
320
404
  @typing.overload
321
405
  async def post(
322
406
  self,
@@ -382,6 +466,37 @@ class AsyncClient:
382
466
  response_model=response_model,
383
467
  )
384
468
 
469
+ async def post_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
470
+ self,
471
+ url: str,
472
+ *,
473
+ params: typing.Any | None = None,
474
+ headers: typing.Any | None = None,
475
+ cookies: typing.Any | None = None,
476
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
477
+ extensions: typing.Any | None = None,
478
+ json: typing.Any | None = None,
479
+ content: typing.Any | None = None,
480
+ data: typing.Any | None = None,
481
+ files: typing.Any | None = None,
482
+ response_model: type[T],
483
+ ) -> tuple[httpx2.Response, T]:
484
+ """Send a POST request; return (response, decoded body)."""
485
+ return await self._request_with_body_with_response(
486
+ "POST",
487
+ url,
488
+ params=params,
489
+ headers=headers,
490
+ cookies=cookies,
491
+ timeout=timeout,
492
+ extensions=extensions,
493
+ json=json,
494
+ content=content,
495
+ data=data,
496
+ files=files,
497
+ response_model=response_model,
498
+ )
499
+
385
500
  @typing.overload
386
501
  async def put(
387
502
  self,
@@ -447,6 +562,37 @@ class AsyncClient:
447
562
  response_model=response_model,
448
563
  )
449
564
 
565
+ async def put_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
566
+ self,
567
+ url: str,
568
+ *,
569
+ params: typing.Any | None = None,
570
+ headers: typing.Any | None = None,
571
+ cookies: typing.Any | None = None,
572
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
573
+ extensions: typing.Any | None = None,
574
+ json: typing.Any | None = None,
575
+ content: typing.Any | None = None,
576
+ data: typing.Any | None = None,
577
+ files: typing.Any | None = None,
578
+ response_model: type[T],
579
+ ) -> tuple[httpx2.Response, T]:
580
+ """Send a PUT request; return (response, decoded body)."""
581
+ return await self._request_with_body_with_response(
582
+ "PUT",
583
+ url,
584
+ params=params,
585
+ headers=headers,
586
+ cookies=cookies,
587
+ timeout=timeout,
588
+ extensions=extensions,
589
+ json=json,
590
+ content=content,
591
+ data=data,
592
+ files=files,
593
+ response_model=response_model,
594
+ )
595
+
450
596
  @typing.overload
451
597
  async def patch(
452
598
  self,
@@ -512,6 +658,37 @@ class AsyncClient:
512
658
  response_model=response_model,
513
659
  )
514
660
 
661
+ async def patch_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
662
+ self,
663
+ url: str,
664
+ *,
665
+ params: typing.Any | None = None,
666
+ headers: typing.Any | None = None,
667
+ cookies: typing.Any | None = None,
668
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
669
+ extensions: typing.Any | None = None,
670
+ json: typing.Any | None = None,
671
+ content: typing.Any | None = None,
672
+ data: typing.Any | None = None,
673
+ files: typing.Any | None = None,
674
+ response_model: type[T],
675
+ ) -> tuple[httpx2.Response, T]:
676
+ """Send a PATCH request; return (response, decoded body)."""
677
+ return await self._request_with_body_with_response(
678
+ "PATCH",
679
+ url,
680
+ params=params,
681
+ headers=headers,
682
+ cookies=cookies,
683
+ timeout=timeout,
684
+ extensions=extensions,
685
+ json=json,
686
+ content=content,
687
+ data=data,
688
+ files=files,
689
+ response_model=response_model,
690
+ )
691
+
515
692
  @typing.overload
516
693
  async def delete(
517
694
  self,
@@ -577,6 +754,37 @@ class AsyncClient:
577
754
  response_model=response_model,
578
755
  )
579
756
 
757
+ async def delete_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
758
+ self,
759
+ url: str,
760
+ *,
761
+ params: typing.Any | None = None,
762
+ headers: typing.Any | None = None,
763
+ cookies: typing.Any | None = None,
764
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
765
+ extensions: typing.Any | None = None,
766
+ json: typing.Any | None = None,
767
+ content: typing.Any | None = None,
768
+ data: typing.Any | None = None,
769
+ files: typing.Any | None = None,
770
+ response_model: type[T],
771
+ ) -> tuple[httpx2.Response, T]:
772
+ """Send a DELETE request; return (response, decoded body)."""
773
+ return await self._request_with_body_with_response(
774
+ "DELETE",
775
+ url,
776
+ params=params,
777
+ headers=headers,
778
+ cookies=cookies,
779
+ timeout=timeout,
780
+ extensions=extensions,
781
+ json=json,
782
+ content=content,
783
+ data=data,
784
+ files=files,
785
+ response_model=response_model,
786
+ )
787
+
580
788
  @typing.overload
581
789
  async def head(
582
790
  self,
@@ -743,6 +951,38 @@ class AsyncClient:
743
951
  response_model=response_model,
744
952
  )
745
953
 
954
+ async def request_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
955
+ self,
956
+ method: str,
957
+ url: str,
958
+ *,
959
+ params: typing.Any | None = None,
960
+ headers: typing.Any | None = None,
961
+ cookies: typing.Any | None = None,
962
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
963
+ extensions: typing.Any | None = None,
964
+ json: typing.Any | None = None,
965
+ content: typing.Any | None = None,
966
+ data: typing.Any | None = None,
967
+ files: typing.Any | None = None,
968
+ response_model: type[T],
969
+ ) -> tuple[httpx2.Response, T]:
970
+ """Send a request with an explicit method; return (response, decoded body)."""
971
+ return await self._request_with_body_with_response(
972
+ method,
973
+ url,
974
+ params=params,
975
+ headers=headers,
976
+ cookies=cookies,
977
+ timeout=timeout,
978
+ extensions=extensions,
979
+ json=json,
980
+ content=content,
981
+ data=data,
982
+ files=files,
983
+ response_model=response_model,
984
+ )
985
+
746
986
  @contextlib.asynccontextmanager
747
987
  async def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
748
988
  self,
@@ -1003,7 +1243,7 @@ class Client:
1003
1243
  """Delegate request construction to the wrapped httpx2.Client."""
1004
1244
  return self._httpx2_client.build_request(method, url, **kwargs)
1005
1245
 
1006
- def _request_with_body( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
1246
+ def _prepare_request( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
1007
1247
  self,
1008
1248
  method: str,
1009
1249
  url: str,
@@ -1017,8 +1257,7 @@ class Client:
1017
1257
  content: typing.Any | None = None,
1018
1258
  data: typing.Any | None = None,
1019
1259
  files: typing.Any | None = None,
1020
- response_model: type[T] | None = None,
1021
- ) -> httpx2.Response | T:
1260
+ ) -> httpx2.Request:
1022
1261
  kwargs: dict[str, typing.Any] = {}
1023
1262
  if params is not None:
1024
1263
  kwargs["params"] = params
@@ -1041,8 +1280,70 @@ class Client:
1041
1280
  request = self._httpx2_client.build_request(method, url, **kwargs)
1042
1281
  if _is_streaming_body_sync(content) or _is_streaming_body_sync(data) or _is_streaming_body_sync(files):
1043
1282
  request.extensions[STREAMING_BODY_MARKER] = True
1283
+ return request
1284
+
1285
+ def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method signatures
1286
+ self,
1287
+ method: str,
1288
+ url: str,
1289
+ *,
1290
+ params: typing.Any | None = None,
1291
+ headers: typing.Any | None = None,
1292
+ cookies: typing.Any | None = None,
1293
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
1294
+ extensions: typing.Any | None = None,
1295
+ json: typing.Any | None = None,
1296
+ content: typing.Any | None = None,
1297
+ data: typing.Any | None = None,
1298
+ files: typing.Any | None = None,
1299
+ response_model: type[T] | None = None,
1300
+ ) -> httpx2.Response | T:
1301
+ request = self._prepare_request(
1302
+ method,
1303
+ url,
1304
+ params=params,
1305
+ headers=headers,
1306
+ cookies=cookies,
1307
+ timeout=timeout,
1308
+ extensions=extensions,
1309
+ json=json,
1310
+ content=content,
1311
+ data=data,
1312
+ files=files,
1313
+ )
1044
1314
  return self.send(request, response_model=response_model)
1045
1315
 
1316
+ def _request_with_body_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
1317
+ self,
1318
+ method: str,
1319
+ url: str,
1320
+ *,
1321
+ params: typing.Any | None = None,
1322
+ headers: typing.Any | None = None,
1323
+ cookies: typing.Any | None = None,
1324
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
1325
+ extensions: typing.Any | None = None,
1326
+ json: typing.Any | None = None,
1327
+ content: typing.Any | None = None,
1328
+ data: typing.Any | None = None,
1329
+ files: typing.Any | None = None,
1330
+ response_model: type[T],
1331
+ ) -> tuple[httpx2.Response, T]:
1332
+ request = self._prepare_request(
1333
+ method,
1334
+ url,
1335
+ params=params,
1336
+ headers=headers,
1337
+ cookies=cookies,
1338
+ timeout=timeout,
1339
+ extensions=extensions,
1340
+ json=json,
1341
+ content=content,
1342
+ data=data,
1343
+ files=files,
1344
+ )
1345
+ return self.send_with_response(request, response_model=response_model)
1346
+
1046
1347
  @typing.overload
1047
1348
  def get(
1048
1349
  self,
@@ -1092,6 +1393,29 @@ class Client:
1092
1393
  response_model=response_model,
1093
1394
  )
1094
1395
 
1396
+ def get_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
1397
+ self,
1398
+ url: str,
1399
+ *,
1400
+ params: typing.Any | None = None,
1401
+ headers: typing.Any | None = None,
1402
+ cookies: typing.Any | None = None,
1403
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
1404
+ extensions: typing.Any | None = None,
1405
+ response_model: type[T],
1406
+ ) -> tuple[httpx2.Response, T]:
1407
+ """Send a GET request; return (response, decoded body)."""
1408
+ return self._request_with_body_with_response(
1409
+ "GET",
1410
+ url,
1411
+ params=params,
1412
+ headers=headers,
1413
+ cookies=cookies,
1414
+ timeout=timeout,
1415
+ extensions=extensions,
1416
+ response_model=response_model,
1417
+ )
1418
+
1095
1419
  @typing.overload
1096
1420
  def post(
1097
1421
  self,
@@ -1157,6 +1481,37 @@ class Client:
1157
1481
  response_model=response_model,
1158
1482
  )
1159
1483
 
1484
+ def post_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
1485
+ self,
1486
+ url: str,
1487
+ *,
1488
+ params: typing.Any | None = None,
1489
+ headers: typing.Any | None = None,
1490
+ cookies: typing.Any | None = None,
1491
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
1492
+ extensions: typing.Any | None = None,
1493
+ json: typing.Any | None = None,
1494
+ content: typing.Any | None = None,
1495
+ data: typing.Any | None = None,
1496
+ files: typing.Any | None = None,
1497
+ response_model: type[T],
1498
+ ) -> tuple[httpx2.Response, T]:
1499
+ """Send a POST request; return (response, decoded body)."""
1500
+ return self._request_with_body_with_response(
1501
+ "POST",
1502
+ url,
1503
+ params=params,
1504
+ headers=headers,
1505
+ cookies=cookies,
1506
+ timeout=timeout,
1507
+ extensions=extensions,
1508
+ json=json,
1509
+ content=content,
1510
+ data=data,
1511
+ files=files,
1512
+ response_model=response_model,
1513
+ )
1514
+
1160
1515
  @typing.overload
1161
1516
  def put(
1162
1517
  self,
@@ -1222,6 +1577,37 @@ class Client:
1222
1577
  response_model=response_model,
1223
1578
  )
1224
1579
 
1580
+ def put_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
1581
+ self,
1582
+ url: str,
1583
+ *,
1584
+ params: typing.Any | None = None,
1585
+ headers: typing.Any | None = None,
1586
+ cookies: typing.Any | None = None,
1587
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
1588
+ extensions: typing.Any | None = None,
1589
+ json: typing.Any | None = None,
1590
+ content: typing.Any | None = None,
1591
+ data: typing.Any | None = None,
1592
+ files: typing.Any | None = None,
1593
+ response_model: type[T],
1594
+ ) -> tuple[httpx2.Response, T]:
1595
+ """Send a PUT request; return (response, decoded body)."""
1596
+ return self._request_with_body_with_response(
1597
+ "PUT",
1598
+ url,
1599
+ params=params,
1600
+ headers=headers,
1601
+ cookies=cookies,
1602
+ timeout=timeout,
1603
+ extensions=extensions,
1604
+ json=json,
1605
+ content=content,
1606
+ data=data,
1607
+ files=files,
1608
+ response_model=response_model,
1609
+ )
1610
+
1225
1611
  @typing.overload
1226
1612
  def patch(
1227
1613
  self,
@@ -1287,6 +1673,37 @@ class Client:
1287
1673
  response_model=response_model,
1288
1674
  )
1289
1675
 
1676
+ def patch_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
1677
+ self,
1678
+ url: str,
1679
+ *,
1680
+ params: typing.Any | None = None,
1681
+ headers: typing.Any | None = None,
1682
+ cookies: typing.Any | None = None,
1683
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
1684
+ extensions: typing.Any | None = None,
1685
+ json: typing.Any | None = None,
1686
+ content: typing.Any | None = None,
1687
+ data: typing.Any | None = None,
1688
+ files: typing.Any | None = None,
1689
+ response_model: type[T],
1690
+ ) -> tuple[httpx2.Response, T]:
1691
+ """Send a PATCH request; return (response, decoded body)."""
1692
+ return self._request_with_body_with_response(
1693
+ "PATCH",
1694
+ url,
1695
+ params=params,
1696
+ headers=headers,
1697
+ cookies=cookies,
1698
+ timeout=timeout,
1699
+ extensions=extensions,
1700
+ json=json,
1701
+ content=content,
1702
+ data=data,
1703
+ files=files,
1704
+ response_model=response_model,
1705
+ )
1706
+
1290
1707
  @typing.overload
1291
1708
  def delete(
1292
1709
  self,
@@ -1352,6 +1769,37 @@ class Client:
1352
1769
  response_model=response_model,
1353
1770
  )
1354
1771
 
1772
+ def delete_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
1773
+ self,
1774
+ url: str,
1775
+ *,
1776
+ params: typing.Any | None = None,
1777
+ headers: typing.Any | None = None,
1778
+ cookies: typing.Any | None = None,
1779
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
1780
+ extensions: typing.Any | None = None,
1781
+ json: typing.Any | None = None,
1782
+ content: typing.Any | None = None,
1783
+ data: typing.Any | None = None,
1784
+ files: typing.Any | None = None,
1785
+ response_model: type[T],
1786
+ ) -> tuple[httpx2.Response, T]:
1787
+ """Send a DELETE request; return (response, decoded body)."""
1788
+ return self._request_with_body_with_response(
1789
+ "DELETE",
1790
+ url,
1791
+ params=params,
1792
+ headers=headers,
1793
+ cookies=cookies,
1794
+ timeout=timeout,
1795
+ extensions=extensions,
1796
+ json=json,
1797
+ content=content,
1798
+ data=data,
1799
+ files=files,
1800
+ response_model=response_model,
1801
+ )
1802
+
1355
1803
  @typing.overload
1356
1804
  def head(
1357
1805
  self,
@@ -1518,6 +1966,38 @@ class Client:
1518
1966
  response_model=response_model,
1519
1967
  )
1520
1968
 
1969
+ def request_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
1970
+ self,
1971
+ method: str,
1972
+ url: str,
1973
+ *,
1974
+ params: typing.Any | None = None,
1975
+ headers: typing.Any | None = None,
1976
+ cookies: typing.Any | None = None,
1977
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
1978
+ extensions: typing.Any | None = None,
1979
+ json: typing.Any | None = None,
1980
+ content: typing.Any | None = None,
1981
+ data: typing.Any | None = None,
1982
+ files: typing.Any | None = None,
1983
+ response_model: type[T],
1984
+ ) -> tuple[httpx2.Response, T]:
1985
+ """Send a request with an explicit method; return (response, decoded body)."""
1986
+ return self._request_with_body_with_response(
1987
+ method,
1988
+ url,
1989
+ params=params,
1990
+ headers=headers,
1991
+ cookies=cookies,
1992
+ timeout=timeout,
1993
+ extensions=extensions,
1994
+ json=json,
1995
+ content=content,
1996
+ data=data,
1997
+ files=files,
1998
+ response_model=response_model,
1999
+ )
2000
+
1521
2001
  @contextlib.contextmanager
1522
2002
  def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
1523
2003
  self,
@@ -1,4 +1,4 @@
1
- """CircuitBreaker + AsyncCircuitBreaker — classic consecutive-failure circuit breaker.
1
+ """CircuitBreaker + AsyncCircuitBreaker — consecutive-failure and failure-rate circuit breakers.
2
2
 
3
3
  See planning/specs/2026-06-13-circuit-breaker-and-timeout-design.md for the contract.
4
4
 
@@ -17,6 +17,15 @@ State machine (classic / consecutive-failure):
17
17
  HALF_OPEN — admit exactly one probe at a time; success_threshold consecutive probe
18
18
  successes close the circuit; one probe failure re-opens it.
19
19
 
20
+ Trip modes:
21
+ Classic (default) — opens when consecutive counted-failures reach failure_threshold.
22
+ Set failure_threshold to use this mode; leave failure_rate_threshold unset.
23
+ Rate (opt-in) — opens when the failure rate over a rolling window_seconds window
24
+ meets or exceeds failure_rate_threshold, provided at least minimum_calls
25
+ outcomes have been observed in that window. Set failure_rate_threshold to
26
+ activate; failure_threshold is ignored in this mode.
27
+ Half-open recovery and event names are identical across both modes.
28
+
20
29
  The lock-free _CircuitBreakerState holds the transition logic, shared by both wrappers.
21
30
  AsyncCircuitBreaker relies on asyncio atomicity (no await inside a transition) plus a
22
31
  single-event-loop guard; CircuitBreaker (sync) serializes transitions with a
@@ -42,6 +51,9 @@ from httpware.middleware import AsyncNext, Next
42
51
  _FAILURE_THRESHOLD_INVALID = "failure_threshold must be >= 1"
43
52
  _RESET_TIMEOUT_INVALID = "reset_timeout must be >= 0"
44
53
  _SUCCESS_THRESHOLD_INVALID = "success_threshold must be >= 1"
54
+ _FAILURE_RATE_THRESHOLD_INVALID = "failure_rate_threshold must be in (0, 1]"
55
+ _WINDOW_SECONDS_INVALID = "window_seconds must be > 0"
56
+ _MINIMUM_CALLS_INVALID = "minimum_calls must be >= 1"
45
57
  _CROSS_LOOP_MSG = (
46
58
  "AsyncCircuitBreaker is bound to a single event loop. First seen on {first!r}; "
47
59
  "current request is on {current!r}. Use one AsyncCircuitBreaker per loop; "
@@ -50,6 +62,8 @@ _CROSS_LOOP_MSG = (
50
62
 
51
63
  _DEFAULT_FAILURE_STATUS_CODES = frozenset(range(500, 600))
52
64
 
65
+ _BUCKET_COUNT = 10
66
+
53
67
  _ROLE_CLOSED = "closed"
54
68
  _ROLE_PROBE = "probe"
55
69
 
@@ -62,6 +76,56 @@ class _CircuitState(enum.Enum):
62
76
  HALF_OPEN = "half_open"
63
77
 
64
78
 
79
+ class _RollingWindow:
80
+ """Time-bucketed success/failure counters over a rolling window.
81
+
82
+ `window_seconds` is split into `_BUCKET_COUNT` buckets. Each bucket holds
83
+ [successes, failures] tagged with the integer time-slot it represents; a
84
+ bucket whose slot is stale is reset on write, and `totals` filters to the
85
+ live slot range so data older than the window never counts. Every method is
86
+ synchronous and reads `now` from its caller (so the breaker's critical
87
+ section owns the clock read).
88
+ """
89
+
90
+ def __init__(self, window_seconds: float) -> None:
91
+ self._bucket_width = window_seconds / _BUCKET_COUNT
92
+ self._slot = [-1] * _BUCKET_COUNT
93
+ self._success = [0] * _BUCKET_COUNT
94
+ self._failure = [0] * _BUCKET_COUNT
95
+
96
+ def _current_slot(self, now: float) -> int:
97
+ return int(now // self._bucket_width)
98
+
99
+ def record(self, now: float, *, failed: bool) -> None:
100
+ slot = self._current_slot(now)
101
+ index = slot % _BUCKET_COUNT
102
+ if self._slot[index] != slot: # bucket reused for a new slot — evict
103
+ self._slot[index] = slot
104
+ self._success[index] = 0
105
+ self._failure[index] = 0
106
+ if failed:
107
+ self._failure[index] += 1
108
+ else:
109
+ self._success[index] += 1
110
+
111
+ def totals(self, now: float) -> tuple[int, int]:
112
+ """Return (total, failures) across buckets still inside the window at `now`."""
113
+ slot = self._current_slot(now)
114
+ oldest = slot - _BUCKET_COUNT + 1
115
+ total = 0
116
+ failures = 0
117
+ for i in range(_BUCKET_COUNT):
118
+ if oldest <= self._slot[i] <= slot:
119
+ total += self._success[i] + self._failure[i]
120
+ failures += self._failure[i]
121
+ return total, failures
122
+
123
+ def clear(self) -> None:
124
+ self._slot = [-1] * _BUCKET_COUNT
125
+ self._success = [0] * _BUCKET_COUNT
126
+ self._failure = [0] * _BUCKET_COUNT
127
+
128
+
65
129
  class _CircuitBreakerState:
66
130
  """Lock-free circuit-breaker state machine shared by the sync + async wrappers.
67
131
 
@@ -70,13 +134,16 @@ class _CircuitBreakerState:
70
134
  inside a transition); the sync wrapper wraps each call in a threading.Lock.
71
135
  """
72
136
 
73
- def __init__(
137
+ def __init__( # noqa: PLR0913 — breaker state has many orthogonal knobs; a dataclass would be worse
74
138
  self,
75
139
  *,
76
140
  failure_threshold: int,
77
141
  reset_timeout: float,
78
142
  success_threshold: int,
79
143
  failure_status_codes: Collection[int] | None,
144
+ failure_rate_threshold: float | None,
145
+ window_seconds: float,
146
+ minimum_calls: int,
80
147
  now: Callable[[], float],
81
148
  ) -> None:
82
149
  if failure_threshold < 1:
@@ -85,6 +152,12 @@ class _CircuitBreakerState:
85
152
  raise ValueError(_RESET_TIMEOUT_INVALID)
86
153
  if success_threshold < 1:
87
154
  raise ValueError(_SUCCESS_THRESHOLD_INVALID)
155
+ if failure_rate_threshold is not None and not (0.0 < failure_rate_threshold <= 1.0):
156
+ raise ValueError(_FAILURE_RATE_THRESHOLD_INVALID)
157
+ if window_seconds <= 0:
158
+ raise ValueError(_WINDOW_SECONDS_INVALID)
159
+ if minimum_calls < 1:
160
+ raise ValueError(_MINIMUM_CALLS_INVALID)
88
161
  self._failure_threshold = failure_threshold
89
162
  self._reset_timeout = reset_timeout
90
163
  self._success_threshold = success_threshold
@@ -93,6 +166,11 @@ class _CircuitBreakerState:
93
166
  self._failure_status_codes = (
94
167
  frozenset(failure_status_codes) if failure_status_codes is not None else _DEFAULT_FAILURE_STATUS_CODES
95
168
  )
169
+ self._failure_rate_threshold = failure_rate_threshold
170
+ self._minimum_calls = minimum_calls
171
+ self._rate_mode = failure_rate_threshold is not None
172
+ self._window = _RollingWindow(window_seconds) if self._rate_mode else None
173
+ self._window_seconds = window_seconds
96
174
  self._now = now
97
175
  self._state = _CircuitState.CLOSED
98
176
  self._consecutive_failures = 0
@@ -140,22 +218,30 @@ class _CircuitBreakerState:
140
218
  if role == _ROLE_PROBE:
141
219
  self._probe_in_flight = False
142
220
  if self._state is _CircuitState.CLOSED:
143
- self._consecutive_failures = 0
221
+ if self._rate_mode:
222
+ self._record_outcome(request, failed=False)
223
+ else:
224
+ self._consecutive_failures = 0
144
225
  elif self._state is _CircuitState.HALF_OPEN:
145
226
  self._consecutive_successes += 1
146
227
  if self._consecutive_successes >= self._success_threshold:
147
228
  self._state = _CircuitState.CLOSED
148
229
  self._consecutive_failures = 0
149
230
  self._consecutive_successes = 0
231
+ if self._rate_mode:
232
+ self._window.clear() # ty: ignore[unresolved-attribute]
150
233
  self._emit(request, "circuit.closed", logging.INFO, "circuit closed — service recovered", {})
151
234
 
152
235
  def on_failure(self, role: str, request: httpx2.Request) -> None:
153
236
  if role == _ROLE_PROBE:
154
237
  self._probe_in_flight = False
155
238
  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)
239
+ if self._rate_mode:
240
+ self._record_outcome(request, failed=True)
241
+ else:
242
+ self._consecutive_failures += 1
243
+ if self._consecutive_failures >= self._failure_threshold:
244
+ self._open(request, failures=self._consecutive_failures)
159
245
  elif self._state is _CircuitState.HALF_OPEN:
160
246
  self._open(request, failures=1) # 1 = the single probe failure that re-opened the circuit
161
247
 
@@ -164,19 +250,41 @@ class _CircuitBreakerState:
164
250
  if role == _ROLE_PROBE:
165
251
  self._probe_in_flight = False
166
252
 
167
- def _open(self, request: httpx2.Request, *, failures: int) -> None:
253
+ def _enter_open(self, request: httpx2.Request, message: str, attributes: dict[str, typing.Any]) -> None:
168
254
  self._state = _CircuitState.OPEN
169
255
  self._opened_at = self._now()
170
256
  self._consecutive_failures = 0
171
257
  self._consecutive_successes = 0
172
- self._emit(
258
+ self._emit(request, "circuit.opened", logging.WARNING, message, attributes)
259
+
260
+ def _open(self, request: httpx2.Request, *, failures: int) -> None:
261
+ self._enter_open(
173
262
  request,
174
- "circuit.opened",
175
- logging.WARNING,
176
263
  "circuit opened — failure threshold reached",
177
264
  {"failure_threshold": self._failure_threshold, "failures": failures},
178
265
  )
179
266
 
267
+ def _open_rate(self, request: httpx2.Request, *, total: int, failures: int) -> None:
268
+ self._enter_open(
269
+ request,
270
+ "circuit opened — failure rate threshold reached",
271
+ {
272
+ "failure_rate": failures / total,
273
+ "failure_rate_threshold": self._failure_rate_threshold,
274
+ "window_seconds": self._window_seconds,
275
+ "observed_calls": total,
276
+ },
277
+ )
278
+
279
+ def _record_outcome(self, request: httpx2.Request, *, failed: bool) -> None:
280
+ # Only reached in rate mode, where _window and _failure_rate_threshold are non-None.
281
+ now = self._now()
282
+ self._window.record(now, failed=failed) # ty: ignore[unresolved-attribute]
283
+ total, failures = self._window.totals(now) # ty: ignore[unresolved-attribute]
284
+ threshold = self._failure_rate_threshold
285
+ if threshold is not None and total >= self._minimum_calls and failures / total >= threshold:
286
+ self._open_rate(request, total=total, failures=failures)
287
+
180
288
  def _emit(
181
289
  self,
182
290
  request: httpx2.Request,
@@ -197,13 +305,16 @@ class _CircuitBreakerState:
197
305
  class AsyncCircuitBreaker:
198
306
  """Async classic circuit breaker middleware. See the module docstring for the contract."""
199
307
 
200
- def __init__(
308
+ def __init__( # noqa: PLR0913 — breaker has many orthogonal knobs; a dataclass would be worse
201
309
  self,
202
310
  *,
203
311
  failure_threshold: int = 5,
204
312
  reset_timeout: float = 30.0,
205
313
  success_threshold: int = 1,
206
314
  failure_status_codes: Collection[int] | None = None,
315
+ failure_rate_threshold: float | None = None,
316
+ window_seconds: float = 30.0,
317
+ minimum_calls: int = 20,
207
318
  _now: Callable[[], float] = time.monotonic,
208
319
  ) -> None:
209
320
  self._state = _CircuitBreakerState(
@@ -211,6 +322,9 @@ class AsyncCircuitBreaker:
211
322
  reset_timeout=reset_timeout,
212
323
  success_threshold=success_threshold,
213
324
  failure_status_codes=failure_status_codes,
325
+ failure_rate_threshold=failure_rate_threshold,
326
+ window_seconds=window_seconds,
327
+ minimum_calls=minimum_calls,
214
328
  now=_now,
215
329
  )
216
330
  self._loop: asyncio.AbstractEventLoop | None = None
@@ -261,13 +375,16 @@ class CircuitBreaker:
261
375
  (one shared circuit); a sync instance cannot be shared with an AsyncClient.
262
376
  """
263
377
 
264
- def __init__(
378
+ def __init__( # noqa: PLR0913 — breaker has many orthogonal knobs; a dataclass would be worse
265
379
  self,
266
380
  *,
267
381
  failure_threshold: int = 5,
268
382
  reset_timeout: float = 30.0,
269
383
  success_threshold: int = 1,
270
384
  failure_status_codes: Collection[int] | None = None,
385
+ failure_rate_threshold: float | None = None,
386
+ window_seconds: float = 30.0,
387
+ minimum_calls: int = 20,
271
388
  _now: Callable[[], float] = time.monotonic,
272
389
  ) -> None:
273
390
  self._state = _CircuitBreakerState(
@@ -275,6 +392,9 @@ class CircuitBreaker:
275
392
  reset_timeout=reset_timeout,
276
393
  success_threshold=success_threshold,
277
394
  failure_status_codes=failure_status_codes,
395
+ failure_rate_threshold=failure_rate_threshold,
396
+ window_seconds=window_seconds,
397
+ minimum_calls=minimum_calls,
278
398
  now=_now,
279
399
  )
280
400
  self._lock = threading.Lock()
File without changes