httpware 0.10.0__tar.gz → 0.11.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 (27) hide show
  1. httpware-0.11.0/PKG-INFO +110 -0
  2. httpware-0.11.0/README.md +80 -0
  3. {httpware-0.10.0 → httpware-0.11.0}/pyproject.toml +2 -2
  4. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/__init__.py +2 -0
  5. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/_internal/observability.py +20 -4
  6. httpware-0.11.0/src/httpware/_internal/redaction.py +116 -0
  7. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/_internal/status.py +9 -4
  8. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/client.py +34 -1
  9. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/decoders/msgspec.py +2 -0
  10. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/decoders/pydantic.py +12 -4
  11. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/errors.py +48 -21
  12. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/__init__.py +13 -0
  13. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/resilience/_backoff.py +8 -5
  14. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/resilience/budget.py +8 -4
  15. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/resilience/circuit_breaker.py +4 -1
  16. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/resilience/retry.py +37 -35
  17. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/resilience/timeout.py +3 -2
  18. httpware-0.10.0/PKG-INFO +0 -181
  19. httpware-0.10.0/README.md +0 -151
  20. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/_internal/__init__.py +0 -0
  21. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/_internal/exception_mapping.py +0 -0
  22. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/_internal/import_checker.py +0 -0
  23. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/decoders/__init__.py +0 -0
  24. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/chain.py +0 -0
  25. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/resilience/__init__.py +0 -0
  26. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
  27. {httpware-0.10.0 → httpware-0.11.0}/src/httpware/py.typed +0 -0
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: httpware
3
+ Version: 0.11.0
4
+ Summary: Resilience-first async HTTP client framework for Python
5
+ Keywords: http,async,client,resilience,retry,circuit-breaker,middleware,httpx,pydantic
6
+ Author: Artur Shiriev
7
+ Author-email: Artur Shiriev <me@shiriev.ru>
8
+ License-Expression: MIT
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Typing :: Typed
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Classifier: Topic :: Internet :: WWW/HTTP
16
+ Classifier: Framework :: AsyncIO
17
+ Requires-Dist: httpx2>=2.0.0,<3.0
18
+ Requires-Dist: httpware[pydantic,msgspec,otel] ; extra == 'all'
19
+ Requires-Dist: msgspec>=0.18 ; extra == 'msgspec'
20
+ Requires-Dist: opentelemetry-api>=1.20 ; extra == 'otel'
21
+ Requires-Dist: pydantic>=2.0,<3.0 ; extra == 'pydantic'
22
+ Requires-Python: >=3.11, <4
23
+ Project-URL: repository, https://github.com/modern-python/httpware
24
+ Project-URL: docs, https://httpware.modern-python.org
25
+ Provides-Extra: all
26
+ Provides-Extra: msgspec
27
+ Provides-Extra: otel
28
+ Provides-Extra: pydantic
29
+ Description-Content-Type: text/markdown
30
+
31
+ # httpware
32
+
33
+ [![PyPI version](https://img.shields.io/pypi/v/httpware.svg)](https://pypi.org/project/httpware/)
34
+ [![Supported Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/)
35
+ [![Downloads](https://img.shields.io/pypi/dm/httpware.svg)](https://pypistats.org/packages/httpware)
36
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
37
+ [![CI](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
38
+ [![License](https://img.shields.io/github/license/modern-python/httpware.svg)](https://github.com/modern-python/httpware/blob/main/LICENSE)
39
+ [![GitHub stars](https://img.shields.io/github/stars/modern-python/httpware)](https://github.com/modern-python/httpware/stargazers)
40
+ [![Context7](https://img.shields.io/badge/Context7-docs-blue)](https://context7.com/modern-python/httpware)
41
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
42
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
43
+ [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
44
+
45
+ **A Python HTTP client framework with sync and async clients for building resilient service clients.**
46
+
47
+ ## Why httpware
48
+
49
+ - **Typed errors, no `raise_for_status()`** — 4xx/5xx automatically raise a status-keyed exception tree (`NotFoundError`, `RateLimitedError`, …), all under `httpware.StatusError`.
50
+ - **Typed response bodies** — `response_model=YourType` decodes the body straight to your pydantic or msgspec model; a missing decoder fails fast, *before* the request goes out.
51
+ - **Production resilience as composable middleware** — retry + retry-budget, bulkhead, circuit breaker, and timeout, composed at construction — all over standard `httpx2`.
52
+
53
+ Built on `httpx2`: httpware re-exports `httpx2.Request`/`httpx2.Response` and stays a thin wrapper, not a new HTTP abstraction.
54
+
55
+ > **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ pip install httpware # core only — no decoder
61
+ pip install httpware[pydantic] # + PydanticDecoder — BaseModel, dataclasses, primitives, generics
62
+ pip install httpware[msgspec] # + MsgspecDecoder — Struct, dataclasses, primitives, generics
63
+ pip install httpware[pydantic,msgspec] # both — BaseModel routes to pydantic, Struct to msgspec
64
+ pip install httpware[all] # everything (pydantic, msgspec, otel)
65
+ ```
66
+
67
+ ## Quickstart
68
+
69
+ A typed GET against a live API (needs `pip install httpware[pydantic]`):
70
+
71
+ ```python
72
+ import asyncio
73
+
74
+ from httpware import AsyncClient
75
+ from pydantic import BaseModel
76
+
77
+
78
+ class User(BaseModel):
79
+ id: int
80
+ name: str
81
+
82
+
83
+ async def main() -> None:
84
+ async with AsyncClient(base_url="https://jsonplaceholder.typicode.com") as client:
85
+ user = await client.get("/users/1", response_model=User)
86
+ print(user.name) # Leanne Graham
87
+
88
+
89
+ asyncio.run(main())
90
+ ```
91
+
92
+ The sync `Client` is identical — swap `AsyncClient` → `Client` and drop the `await` / `async with`. A 4xx/5xx response raises a typed `StatusError`; a malformed body raises `DecodeError`. Both subclass `httpware.ClientError`.
93
+
94
+ ## Documentation
95
+
96
+ Full guides live at **[httpware.modern-python.org](https://httpware.modern-python.org)**:
97
+
98
+ - **[Quickstart & observability](https://httpware.modern-python.org/)** — resilience middleware, streaming, and the stable logger/event contract.
99
+ - **[Middleware](https://httpware.modern-python.org/middleware/)** — write your own (auth, tracing, request-ID propagation).
100
+ - **[Resilience](https://httpware.modern-python.org/resilience/)** — retry + retry-budget, bulkhead, circuit breaker, timeout.
101
+ - **[Errors](https://httpware.modern-python.org/errors/)** — the exception tree and catching strategies.
102
+ - **[Testing](https://httpware.modern-python.org/testing/)** — `httpx2.MockTransport` injection.
103
+ - **[Recipes](https://httpware.modern-python.org/recipes/modern-di/)** — DI wiring, phase-decorator patterns, link-header pagination.
104
+
105
+ ## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases) · 📦 [PyPI](https://pypi.org/project/httpware) · 📝 [License](LICENSE)
106
+
107
+ ## Part of `modern-python`
108
+
109
+ Browse the full list of templates and libraries in
110
+ [`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
@@ -0,0 +1,80 @@
1
+ # httpware
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/httpware.svg)](https://pypi.org/project/httpware/)
4
+ [![Supported Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/)
5
+ [![Downloads](https://img.shields.io/pypi/dm/httpware.svg)](https://pypistats.org/packages/httpware)
6
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
7
+ [![CI](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
8
+ [![License](https://img.shields.io/github/license/modern-python/httpware.svg)](https://github.com/modern-python/httpware/blob/main/LICENSE)
9
+ [![GitHub stars](https://img.shields.io/github/stars/modern-python/httpware)](https://github.com/modern-python/httpware/stargazers)
10
+ [![Context7](https://img.shields.io/badge/Context7-docs-blue)](https://context7.com/modern-python/httpware)
11
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
12
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
13
+ [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
14
+
15
+ **A Python HTTP client framework with sync and async clients for building resilient service clients.**
16
+
17
+ ## Why httpware
18
+
19
+ - **Typed errors, no `raise_for_status()`** — 4xx/5xx automatically raise a status-keyed exception tree (`NotFoundError`, `RateLimitedError`, …), all under `httpware.StatusError`.
20
+ - **Typed response bodies** — `response_model=YourType` decodes the body straight to your pydantic or msgspec model; a missing decoder fails fast, *before* the request goes out.
21
+ - **Production resilience as composable middleware** — retry + retry-budget, bulkhead, circuit breaker, and timeout, composed at construction — all over standard `httpx2`.
22
+
23
+ Built on `httpx2`: httpware re-exports `httpx2.Request`/`httpx2.Response` and stays a thin wrapper, not a new HTTP abstraction.
24
+
25
+ > **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install httpware # core only — no decoder
31
+ pip install httpware[pydantic] # + PydanticDecoder — BaseModel, dataclasses, primitives, generics
32
+ pip install httpware[msgspec] # + MsgspecDecoder — Struct, dataclasses, primitives, generics
33
+ pip install httpware[pydantic,msgspec] # both — BaseModel routes to pydantic, Struct to msgspec
34
+ pip install httpware[all] # everything (pydantic, msgspec, otel)
35
+ ```
36
+
37
+ ## Quickstart
38
+
39
+ A typed GET against a live API (needs `pip install httpware[pydantic]`):
40
+
41
+ ```python
42
+ import asyncio
43
+
44
+ from httpware import AsyncClient
45
+ from pydantic import BaseModel
46
+
47
+
48
+ class User(BaseModel):
49
+ id: int
50
+ name: str
51
+
52
+
53
+ async def main() -> None:
54
+ async with AsyncClient(base_url="https://jsonplaceholder.typicode.com") as client:
55
+ user = await client.get("/users/1", response_model=User)
56
+ print(user.name) # Leanne Graham
57
+
58
+
59
+ asyncio.run(main())
60
+ ```
61
+
62
+ The sync `Client` is identical — swap `AsyncClient` → `Client` and drop the `await` / `async with`. A 4xx/5xx response raises a typed `StatusError`; a malformed body raises `DecodeError`. Both subclass `httpware.ClientError`.
63
+
64
+ ## Documentation
65
+
66
+ Full guides live at **[httpware.modern-python.org](https://httpware.modern-python.org)**:
67
+
68
+ - **[Quickstart & observability](https://httpware.modern-python.org/)** — resilience middleware, streaming, and the stable logger/event contract.
69
+ - **[Middleware](https://httpware.modern-python.org/middleware/)** — write your own (auth, tracing, request-ID propagation).
70
+ - **[Resilience](https://httpware.modern-python.org/resilience/)** — retry + retry-budget, bulkhead, circuit breaker, timeout.
71
+ - **[Errors](https://httpware.modern-python.org/errors/)** — the exception tree and catching strategies.
72
+ - **[Testing](https://httpware.modern-python.org/testing/)** — `httpx2.MockTransport` injection.
73
+ - **[Recipes](https://httpware.modern-python.org/recipes/modern-di/)** — DI wiring, phase-decorator patterns, link-header pagination.
74
+
75
+ ## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases) · 📦 [PyPI](https://pypi.org/project/httpware) · 📝 [License](LICENSE)
76
+
77
+ ## Part of `modern-python`
78
+
79
+ Browse the full list of templates and libraries in
80
+ [`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
@@ -26,7 +26,7 @@ classifiers = [
26
26
  "Topic :: Internet :: WWW/HTTP",
27
27
  "Framework :: AsyncIO",
28
28
  ]
29
- version = "0.10.0"
29
+ version = "0.11.0"
30
30
  dependencies = [
31
31
  "httpx2>=2.0.0,<3.0",
32
32
  ]
@@ -97,4 +97,4 @@ asyncio_default_fixture_loop_scope = "function"
97
97
 
98
98
  [tool.coverage]
99
99
  run.concurrency = ["thread"]
100
- report.exclude_also = ["if typing.TYPE_CHECKING:"]
100
+ report.exclude_also = ["if typing.TYPE_CHECKING:", 'pytest\.fail\(']
@@ -17,6 +17,7 @@ from httpware.errors import (
17
17
  NetworkError,
18
18
  NotFoundError,
19
19
  RateLimitedError,
20
+ ResponseTooLargeError,
20
21
  RetryBudgetExhaustedError,
21
22
  ServerStatusError,
22
23
  ServiceUnavailableError,
@@ -78,6 +79,7 @@ __all__ = [
78
79
  "NotFoundError",
79
80
  "RateLimitedError",
80
81
  "ResponseDecoder",
82
+ "ResponseTooLargeError",
81
83
  "Retry",
82
84
  "RetryBudget",
83
85
  "RetryBudgetExhaustedError",
@@ -2,15 +2,18 @@
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
 
13
15
  from httpware._internal import import_checker
16
+ from httpware._internal.redaction import redact_url
14
17
 
15
18
 
16
19
  def _emit_event(
@@ -23,6 +26,12 @@ def _emit_event(
23
26
  ) -> None:
24
27
  """Emit one observability event to both channels.
25
28
 
29
+ The ``url`` attribute, when present, is run through
30
+ ``redaction.redact_url`` here — at the single emission boundary — so a
31
+ request URL's userinfo and known-sensitive query/fragment secrets never
32
+ reach a log record or span event, regardless of how a caller built the
33
+ attributes dict.
34
+
26
35
  1. Always emits a structured log record at ``level`` with ``extra=attributes``
27
36
  (so log aggregators that index ``extra`` see structured fields).
28
37
  2. If ``opentelemetry-api`` is installed, calls
@@ -37,7 +46,9 @@ def _emit_event(
37
46
  the optional-extras isolation invariant: ``import httpware`` must not pull
38
47
  ``opentelemetry`` into ``sys.modules`` when the extra is absent.
39
48
  """
40
- logger.log(level, message, extra=attributes)
49
+ raw_url = attributes.get("url")
50
+ safe_attributes = {**attributes, "url": redact_url(raw_url)} if isinstance(raw_url, str) else attributes
51
+ logger.log(level, message, extra={**safe_attributes, "event": event_name})
41
52
  if import_checker.is_otel_installed:
42
53
  try:
43
54
  from opentelemetry import trace # noqa: PLC0415 — lazy by design (optional-extras isolation)
@@ -45,4 +56,9 @@ def _emit_event(
45
56
  # opentelemetry namespace exists but the api package is broken or missing —
46
57
  # degrade to log-only emission. The structured log record above has already fired.
47
58
  return
48
- trace.get_current_span().add_event(event_name, attributes=attributes)
59
+ # Observability must never break the request path — suppress any failure from
60
+ # add_event (e.g. a recording span with a broken exporter or attribute validation).
61
+ # The structured log record above has already fired; CancelledError/KeyboardInterrupt
62
+ # are not Exception subclasses and will still propagate.
63
+ with contextlib.suppress(Exception):
64
+ trace.get_current_span().add_event(event_name, attributes=safe_attributes)
@@ -0,0 +1,116 @@
1
+ """URL sanitation for logs, telemetry, and error messages.
2
+
3
+ Strips ``user:pass@`` userinfo and masks the values of known-sensitive query
4
+ parameters so secrets embedded in URLs do not leak into observability output.
5
+ Shared by ``errors.py`` (StatusError messages) and the resilience middleware
6
+ (event attributes).
7
+ """
8
+
9
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
10
+
11
+
12
+ SENSITIVE_QUERY_KEYS = frozenset(
13
+ {
14
+ "api_key",
15
+ "apikey",
16
+ "access_token",
17
+ "refresh_token",
18
+ "token",
19
+ "secret",
20
+ "client_secret",
21
+ "password",
22
+ "passwd",
23
+ "pwd",
24
+ "auth",
25
+ "authorization",
26
+ "sig",
27
+ "signature",
28
+ "key",
29
+ "private_key",
30
+ "session",
31
+ "sessionid",
32
+ "x-api-key",
33
+ }
34
+ )
35
+
36
+ _REDACTED = "REDACTED"
37
+
38
+
39
+ def _reassemble(scheme: str, netloc: str, path: str, query: str, fragment: str) -> str:
40
+ """Like ``urlunsplit``, but avoid the spurious triple-slash for an empty authority.
41
+
42
+ ``urlunsplit(("http", "", "/path", ...))`` yields ``http:///path`` for a
43
+ netloc-using scheme. When userinfo stripping leaves no host (e.g.
44
+ ``http://user:pass@/path``) we want ``http:/path`` (scheme + path), not a
45
+ triple-slash. With a non-empty netloc this delegates to ``urlunsplit``, so
46
+ normal URLs are byte-identical.
47
+ """
48
+ if netloc:
49
+ return urlunsplit((scheme, netloc, path, query, fragment))
50
+ tail = path
51
+ if query:
52
+ tail += "?" + query
53
+ if fragment:
54
+ tail += "#" + fragment
55
+ return f"{scheme}:{tail}" if scheme else tail
56
+
57
+
58
+ def _strip_userinfo(url: str) -> str:
59
+ if "@" not in url or "://" not in url:
60
+ return url
61
+ parts = urlsplit(url)
62
+ if parts.username is None and parts.password is None:
63
+ return url
64
+ # Strip the "user:pass@" prefix from the raw netloc to preserve host:port
65
+ # exactly (including IPv6 brackets), rather than reconstructing from parts.
66
+ netloc = parts.netloc.split("@", 1)[1] if "@" in parts.netloc else parts.netloc
67
+ return _reassemble(parts.scheme, netloc, parts.path, parts.query, parts.fragment)
68
+
69
+
70
+ def _mask_component(component: str) -> tuple[str, bool]:
71
+ """Mask sensitive key=value pairs in a query or fragment string.
72
+
73
+ Returns ``(masked_component, was_changed)``; when no sensitive key is
74
+ found the original string is returned unchanged (``was_changed=False``).
75
+ """
76
+ pairs = parse_qsl(component, keep_blank_values=True)
77
+ if not any(key.strip().lower() in SENSITIVE_QUERY_KEYS for key, _ in pairs):
78
+ return component, False
79
+ masked = [(key, _REDACTED if key.strip().lower() in SENSITIVE_QUERY_KEYS else value) for key, value in pairs]
80
+ return urlencode(masked), True
81
+
82
+
83
+ def _mask_query(url: str) -> str:
84
+ parts = urlsplit(url)
85
+ has_query = bool(parts.query)
86
+ has_fragment = bool(parts.fragment)
87
+
88
+ if not has_query and not has_fragment:
89
+ return url
90
+
91
+ new_query = parts.query
92
+ new_fragment = parts.fragment
93
+ changed = False
94
+
95
+ if has_query:
96
+ new_query, q_changed = _mask_component(parts.query)
97
+ changed = changed or q_changed
98
+
99
+ if has_fragment:
100
+ new_fragment, f_changed = _mask_component(parts.fragment)
101
+ changed = changed or f_changed
102
+
103
+ if not changed:
104
+ return url # common-path guard: nothing sensitive, leave bytes untouched
105
+
106
+ return _reassemble(parts.scheme, parts.netloc, parts.path, new_query, new_fragment)
107
+
108
+
109
+ def redact_url(url: str) -> str:
110
+ """Return ``url`` safe for logs/telemetry/errors.
111
+
112
+ Userinfo is stripped and the values of known-sensitive query parameters are
113
+ replaced with ``REDACTED`` (keys preserved). URLs with no sensitive query
114
+ key are returned byte-identical to the userinfo-stripped input.
115
+ """
116
+ return _mask_query(_strip_userinfo(url))
@@ -29,19 +29,24 @@ def _raise_on_status_error(response: httpx2.Response) -> None:
29
29
  raise exc_class(response)
30
30
 
31
31
 
32
+ def _is_replayable_type(value: object) -> bool:
33
+ """Return True if value is a replayable type (safe to replay across retry attempts)."""
34
+ return isinstance(value, (bytes, bytearray, memoryview, str, dict, list, tuple))
35
+
36
+
32
37
  def _is_streaming_body_async(value: object) -> bool:
33
- """Return True if value is an async-iterable that cannot be safely replayed for retry."""
38
+ """Return True if value is a non-replayable body (async-iterable or sync non-replayable iterable)."""
34
39
  if value is None:
35
40
  return False
36
- if isinstance(value, (bytes, bytearray, memoryview, str, dict)):
41
+ if _is_replayable_type(value):
37
42
  return False
38
- return hasattr(value, "__aiter__")
43
+ return hasattr(value, "__aiter__") or hasattr(value, "__iter__")
39
44
 
40
45
 
41
46
  def _is_streaming_body_sync(value: object) -> bool:
42
47
  """Return True if value is a sync iterable body that cannot be safely replayed for retry."""
43
48
  if value is None:
44
49
  return False
45
- if isinstance(value, (bytes, bytearray, memoryview, str, dict, list, tuple)):
50
+ if _is_replayable_type(value):
46
51
  return False
47
52
  return hasattr(value, "__iter__")
@@ -16,7 +16,7 @@ from httpware._internal.status import (
16
16
  _raise_on_status_error,
17
17
  )
18
18
  from httpware.decoders import ResponseDecoder
19
- from httpware.errors import DecodeError, MissingDecoderError, TransportError
19
+ from httpware.errors import DecodeError, MissingDecoderError, ResponseTooLargeError, TransportError
20
20
  from httpware.middleware import AsyncMiddleware, AsyncNext, Middleware, Next
21
21
  from httpware.middleware.chain import compose, compose_async
22
22
 
@@ -31,6 +31,17 @@ _HTTPX2_CLIENT_CONFLICT_MESSAGE = (
31
31
  )
32
32
 
33
33
 
34
+ def _parse_content_length(raw: str | None) -> int | None:
35
+ """Return a non-negative int Content-Length, or None for missing/garbage. Never raises."""
36
+ if raw is None:
37
+ return None
38
+ try:
39
+ value = int(raw)
40
+ except ValueError:
41
+ return None
42
+ return value if value >= 0 else None
43
+
44
+
34
45
  def _build_default_decoders() -> tuple[ResponseDecoder, ...]:
35
46
  """Construct the default decoder tuple based on installed extras.
36
47
 
@@ -82,6 +93,7 @@ class AsyncClient:
82
93
  _decoders: tuple[ResponseDecoder, ...]
83
94
  _user_middleware: tuple[AsyncMiddleware, ...]
84
95
  _dispatch: AsyncNext
96
+ _max_error_body_bytes: int | None
85
97
 
86
98
  def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API
87
99
  self,
@@ -96,6 +108,7 @@ class AsyncClient:
96
108
  httpx2_client: httpx2.AsyncClient | None = None,
97
109
  decoders: Sequence[ResponseDecoder] | None = None,
98
110
  middleware: Sequence[AsyncMiddleware] = (),
111
+ max_error_body_bytes: int | None = None,
99
112
  ) -> None:
100
113
  if httpx2_client is not None:
101
114
  forwarded = {
@@ -133,6 +146,7 @@ class AsyncClient:
133
146
  self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders()
134
147
  self._user_middleware = tuple(middleware)
135
148
  self._dispatch = compose_async(self._user_middleware, self._terminal)
149
+ self._max_error_body_bytes = max_error_body_bytes
136
150
 
137
151
  def _dispatch_decoder(self, model: type) -> ResponseDecoder | None:
138
152
  """Walk `_decoders` and return the first decoder claiming `model`, or None."""
@@ -785,6 +799,14 @@ class AsyncClient:
785
799
 
786
800
  async with _httpx2_exception_mapper(), self._httpx2_client.stream(method, url, **kwargs) as response:
787
801
  if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
802
+ if self._max_error_body_bytes is not None:
803
+ content_length = _parse_content_length(response.headers.get("content-length"))
804
+ if content_length is not None and content_length > self._max_error_body_bytes:
805
+ raise ResponseTooLargeError(
806
+ status_code=response.status_code,
807
+ limit=self._max_error_body_bytes,
808
+ content_length=content_length,
809
+ )
788
810
  await response.aread() # pre-read body so exc.response.content works
789
811
  _raise_on_status_error(response)
790
812
  yield response
@@ -822,6 +844,7 @@ class Client:
822
844
  _decoders: tuple[ResponseDecoder, ...]
823
845
  _user_middleware: tuple[Middleware, ...]
824
846
  _dispatch: Next
847
+ _max_error_body_bytes: int | None
825
848
 
826
849
  def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API
827
850
  self,
@@ -836,6 +859,7 @@ class Client:
836
859
  httpx2_client: httpx2.Client | None = None,
837
860
  decoders: Sequence[ResponseDecoder] | None = None,
838
861
  middleware: Sequence[Middleware] = (),
862
+ max_error_body_bytes: int | None = None,
839
863
  ) -> None:
840
864
  if httpx2_client is not None:
841
865
  forwarded = {
@@ -873,6 +897,7 @@ class Client:
873
897
  self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders()
874
898
  self._user_middleware = tuple(middleware)
875
899
  self._dispatch = compose(self._user_middleware, self._terminal)
900
+ self._max_error_body_bytes = max_error_body_bytes
876
901
 
877
902
  def _dispatch_decoder(self, model: type) -> ResponseDecoder | None:
878
903
  """Walk `_decoders` and return the first decoder claiming `model`, or None."""
@@ -1547,6 +1572,14 @@ class Client:
1547
1572
 
1548
1573
  with _httpx2_exception_mapper_sync(), self._httpx2_client.stream(method, url, **kwargs) as response:
1549
1574
  if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
1575
+ if self._max_error_body_bytes is not None:
1576
+ content_length = _parse_content_length(response.headers.get("content-length"))
1577
+ if content_length is not None and content_length > self._max_error_body_bytes:
1578
+ raise ResponseTooLargeError(
1579
+ status_code=response.status_code,
1580
+ limit=self._max_error_body_bytes,
1581
+ content_length=content_length,
1582
+ )
1550
1583
  response.read() # pre-read body so exc.response.content works
1551
1584
  _raise_on_status_error(response)
1552
1585
  yield response
@@ -26,6 +26,8 @@ def _contains_custom_type(info: "msgspec.inspect.Type") -> bool:
26
26
  makes the walk both correct (a Struct is a valid target) and safe against
27
27
  infinite recursion on self-referential struct definitions.
28
28
  """
29
+ if not import_checker.is_msgspec_installed:
30
+ raise ImportError(MISSING_DEPENDENCY_MESSAGE)
29
31
  if isinstance(info, msgspec.inspect.CustomType):
30
32
  return True
31
33
  for name in dir(info):
@@ -10,11 +10,13 @@ not trip the ImportError when the user is not using `response_model=`.
10
10
  import typing
11
11
  from typing import TypeVar
12
12
 
13
- from pydantic import TypeAdapter
14
-
15
13
  from httpware._internal import import_checker
16
14
 
17
15
 
16
+ if import_checker.is_pydantic_installed:
17
+ from pydantic import TypeAdapter
18
+
19
+
18
20
  MISSING_DEPENDENCY_MESSAGE = (
19
21
  "PydanticDecoder requires the 'pydantic' extra. Install with: pip install httpware[pydantic]"
20
22
  )
@@ -23,9 +25,15 @@ T = TypeVar("T")
23
25
 
24
26
 
25
27
  class PydanticDecoder:
26
- """Decode raw response bytes into `model` via a per-instance cached `pydantic.TypeAdapter`."""
28
+ """Decode raw response bytes into `model` via a per-instance cached `pydantic.TypeAdapter`.
29
+
30
+ Requires the `pydantic` extra: `pip install httpware[pydantic]`. Importing
31
+ this module without the extra works (the `pydantic` import is guarded by an
32
+ `is_pydantic_installed` check), but instantiating the decoder raises
33
+ `ImportError`.
34
+ """
27
35
 
28
- _adapters: dict[type, TypeAdapter[typing.Any]]
36
+ _adapters: dict[type, "TypeAdapter[typing.Any]"]
29
37
  _can_decode_results: dict[type, bool]
30
38
 
31
39
  def __init__(self) -> None:
@@ -1,35 +1,25 @@
1
1
  """Status-keyed exception hierarchy.
2
2
 
3
- Auto-raise rule lives at AsyncClient's internal terminal (see client.py).
3
+ Auto-raise fires at four sites (all in client.py): both clients' internal
4
+ terminals (Client._terminal / AsyncClient._terminal) and both stream() methods
5
+ (Client.stream / AsyncClient.stream).
4
6
  Unknown 4xx falls back to ClientStatusError; unknown 5xx to ServerStatusError.
5
7
  The fallback assumes 400 <= status < 600.
6
8
 
7
- __repr__ and the summary message strip user:pass@ userinfo from
8
- response.request.url to avoid leaking credentials in tracebacks.
9
- Query-string secrets are NOT stripped here.
9
+ __repr__ and the summary message run response.request.url through
10
+ _internal.redaction.redact_url, which strips user:pass@ userinfo and masks the
11
+ values of known-sensitive query parameters. NOTE: the full request headers
12
+ (Authorization, Cookie, ...) remain reachable via exc.response.request — handler
13
+ authors must redact those before logging.
10
14
  """
11
15
 
12
16
  import builtins
13
17
  from collections.abc import Mapping
14
18
  from typing import Any
15
- from urllib.parse import urlsplit, urlunsplit
16
19
 
17
20
  import httpx2
18
21
 
19
-
20
- def _strip_userinfo(url: str) -> str:
21
- if "@" not in url or "://" not in url:
22
- return url
23
- parts = urlsplit(url)
24
- if parts.username is None and parts.password is None:
25
- return url
26
- hostname = parts.hostname or ""
27
- if ":" in hostname: # IPv6 literal — re-wrap in brackets
28
- hostname = f"[{hostname}]"
29
- netloc = hostname
30
- if parts.port is not None:
31
- netloc = f"{netloc}:{parts.port}"
32
- return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
22
+ from httpware._internal.redaction import redact_url
33
23
 
34
24
 
35
25
  class ClientError(Exception):
@@ -76,13 +66,13 @@ class StatusError(ClientError):
76
66
 
77
67
  def _summary(self) -> str:
78
68
  method = self.response.request.method
79
- url = _strip_userinfo(str(self.response.request.url))
69
+ url = redact_url(str(self.response.request.url))
80
70
  return f"{self.response.status_code} {method} {url}"
81
71
 
82
72
  def __repr__(self) -> str:
83
73
  cls_name = type(self).__name__
84
74
  method = self.response.request.method
85
- url = _strip_userinfo(str(self.response.request.url))
75
+ url = redact_url(str(self.response.request.url))
86
76
  return f"<{cls_name} status={self.response.status_code} method={method} url={url}>"
87
77
 
88
78
  def __reduce__(self) -> tuple[Any, ...]:
@@ -323,3 +313,40 @@ class MissingDecoderError(ClientError):
323
313
 
324
314
  def __reduce__(self) -> tuple[Any, ...]:
325
315
  return (_reconstruct_missing_decoder, (type(self), self.model, self.registered_names))
316
+
317
+
318
+ def _reconstruct_response_too_large(
319
+ cls: "type[ResponseTooLargeError]",
320
+ status_code: int,
321
+ limit: int,
322
+ content_length: int | None,
323
+ ) -> "ResponseTooLargeError":
324
+ return cls(status_code=status_code, limit=limit, content_length=content_length)
325
+
326
+
327
+ class ResponseTooLargeError(ClientError):
328
+ """Raised when an error response body exceeds the client's max_error_body_bytes cap.
329
+
330
+ Fires from stream() on a 4xx/5xx whose declared Content-Length exceeds the
331
+ configured cap, BEFORE the body is read — so the oversized body is never
332
+ buffered. Only raised when max_error_body_bytes is set (opt-in).
333
+ """
334
+
335
+ status_code: int
336
+ limit: int
337
+ content_length: int | None
338
+
339
+ def __init__(self, *, status_code: int, limit: int, content_length: int | None) -> None:
340
+ self.status_code = status_code
341
+ self.limit = limit
342
+ self.content_length = content_length
343
+ super().__init__(
344
+ f"error response body too large: status={status_code} "
345
+ f"content_length={content_length} exceeds max_error_body_bytes={limit}"
346
+ )
347
+
348
+ def __reduce__(self) -> tuple[Any, ...]:
349
+ return (
350
+ _reconstruct_response_too_large,
351
+ (type(self), self.status_code, self.limit, self.content_length),
352
+ )
@@ -11,6 +11,19 @@ from typing import Protocol, TypeAlias, runtime_checkable
11
11
  import httpx2
12
12
 
13
13
 
14
+ __all__ = [
15
+ "AsyncMiddleware",
16
+ "AsyncNext",
17
+ "Middleware",
18
+ "Next",
19
+ "after_response",
20
+ "async_after_response",
21
+ "async_before_request",
22
+ "async_on_error",
23
+ "before_request",
24
+ "on_error",
25
+ ]
26
+
14
27
  AsyncNext: TypeAlias = Callable[[httpx2.Request], Awaitable[httpx2.Response]]
15
28
 
16
29
 
@@ -17,10 +17,13 @@ def full_jitter_delay(
17
17
 
18
18
  `attempt_index` is 0 for the first retry, 1 for the second, etc.
19
19
 
20
- Uses ``2.0 **`` (float exponentiation) rather than ``2 **`` so that
21
- ``attempt_index >= 1024`` saturates to ``math.inf`` and ``min`` clamps to
22
- ``max_delay`` — ``2 ** 1024`` would raise ``OverflowError`` during the
23
- int→float conversion.
20
+ For large ``attempt_index`` (>= 1024), ``2.0 ** attempt_index`` raises
21
+ ``OverflowError``. That is caught and the ceiling is clamped directly to
22
+ ``max_delay``, which is exactly what ``min`` would produce for an infinite
23
+ exponentiation result.
24
24
  """
25
- ceiling = min(max_delay, base_delay * (2.0**attempt_index))
25
+ try:
26
+ ceiling = min(max_delay, base_delay * (2.0**attempt_index))
27
+ except OverflowError:
28
+ ceiling = max_delay
26
29
  return _random_uniform(0.0, ceiling)
@@ -2,10 +2,14 @@
2
2
 
3
3
  See planning/specs/2026-06-05-retry-and-retry-budget-design.md for the contract.
4
4
 
5
- Thread-safe and asyncio-safe: all mutations go through a threading.Lock.
6
- A single RetryBudget instance is safe to share across threads, across
7
- coroutines on one event loop, and across (sync Client, AsyncClient) pairs
8
- in the same process.
5
+ Thread-safe and asyncio-safe: all mutations go through a threading.Lock,
6
+ which ensures no torn state across concurrent accesses. When a RetryBudget
7
+ is shared between a sync Client (pool thread) and an AsyncClient (event-loop
8
+ thread), a sync thread holding the lock can briefly block the loop thread's
9
+ acquisition; the critical section (purge + append/compare) is intentionally
10
+ tiny to bound this latency. Safe to share across threads, across coroutines
11
+ on one event loop, and across (sync Client, AsyncClient) pairs in the same
12
+ process.
9
13
  """
10
14
 
11
15
  import math
@@ -5,7 +5,10 @@ See planning/specs/2026-06-13-circuit-breaker-and-timeout-design.md for the cont
5
5
  A counted failure is a NetworkError, an httpware TimeoutError, or a StatusError whose
6
6
  status_code is in the effective failure set (default: all 5xx). 4xx — including 429 —
7
7
  count as successes: 429 means healthy-but-throttling, and tripping on it amplifies
8
- incidents. Any other exception propagates without affecting circuit state.
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.
9
12
 
10
13
  State machine (classic / consecutive-failure):
11
14
  CLOSED — forward; count consecutive counted-failures; open at failure_threshold.
@@ -58,7 +58,7 @@ def _parse_retry_after(value: str) -> float | None:
58
58
  """Parse a Retry-After header value. Returns None on malformed input."""
59
59
  try:
60
60
  return max(0.0, float(int(value))) # clamp: negative integers are malformed servers
61
- except ValueError:
61
+ except (ValueError, OverflowError):
62
62
  pass
63
63
  try:
64
64
  parsed = email.utils.parsedate_to_datetime(value)
@@ -159,6 +159,24 @@ class AsyncRetry:
159
159
  )
160
160
  raise last_exc
161
161
 
162
+ retry_after: float | None = None
163
+ if self.respect_retry_after and last_response is not None:
164
+ header = last_response.headers.get("Retry-After")
165
+ if header is not None:
166
+ retry_after = _parse_retry_after(header)
167
+
168
+ if retry_after is not None and retry_after > self.max_delay:
169
+ if last_exc is None: # pragma: no cover — retry_after requires last_response which requires last_exc
170
+ msg = "AsyncRetry: retry_after path reached with no last_exc"
171
+ raise AssertionError(msg)
172
+ last_exc.add_note(
173
+ _RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE.format(
174
+ retry_after=retry_after,
175
+ max_delay=self.max_delay,
176
+ ),
177
+ )
178
+ raise last_exc
179
+
162
180
  if not self.budget.try_withdraw():
163
181
  _emit_event(
164
182
  _LOGGER,
@@ -178,23 +196,6 @@ class AsyncRetry:
178
196
  attempts=attempt + 1,
179
197
  ) from last_exc
180
198
 
181
- retry_after: float | None = None
182
- if self.respect_retry_after and last_response is not None:
183
- header = last_response.headers.get("Retry-After")
184
- if header is not None:
185
- retry_after = _parse_retry_after(header)
186
-
187
- if retry_after is not None and retry_after > self.max_delay:
188
- if last_exc is None: # pragma: no cover — retry_after requires last_response which requires last_exc
189
- msg = "AsyncRetry: retry_after path reached with no last_exc"
190
- raise AssertionError(msg)
191
- last_exc.add_note(
192
- _RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE.format(
193
- retry_after=retry_after,
194
- max_delay=self.max_delay,
195
- ),
196
- )
197
- raise last_exc
198
199
  if retry_after is not None:
199
200
  delay = retry_after
200
201
  else:
@@ -297,6 +298,24 @@ class Retry:
297
298
  )
298
299
  raise last_exc
299
300
 
301
+ retry_after: float | None = None
302
+ if self.respect_retry_after and last_response is not None:
303
+ header = last_response.headers.get("Retry-After")
304
+ if header is not None:
305
+ retry_after = _parse_retry_after(header)
306
+
307
+ if retry_after is not None and retry_after > self.max_delay:
308
+ if last_exc is None: # pragma: no cover — retry_after requires last_response which requires last_exc
309
+ msg = "Retry: retry_after path reached with no last_exc"
310
+ raise AssertionError(msg)
311
+ last_exc.add_note(
312
+ _RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE.format(
313
+ retry_after=retry_after,
314
+ max_delay=self.max_delay,
315
+ ),
316
+ )
317
+ raise last_exc
318
+
300
319
  if not self.budget.try_withdraw():
301
320
  _emit_event(
302
321
  _LOGGER,
@@ -316,23 +335,6 @@ class Retry:
316
335
  attempts=attempt + 1,
317
336
  ) from last_exc
318
337
 
319
- retry_after: float | None = None
320
- if self.respect_retry_after and last_response is not None:
321
- header = last_response.headers.get("Retry-After")
322
- if header is not None:
323
- retry_after = _parse_retry_after(header)
324
-
325
- if retry_after is not None and retry_after > self.max_delay:
326
- if last_exc is None: # pragma: no cover — retry_after requires last_response which requires last_exc
327
- msg = "Retry: retry_after path reached with no last_exc"
328
- raise AssertionError(msg)
329
- last_exc.add_note(
330
- _RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE.format(
331
- retry_after=retry_after,
332
- max_delay=self.max_delay,
333
- ),
334
- )
335
- raise last_exc
336
338
  if retry_after is not None:
337
339
  delay = retry_after
338
340
  else:
@@ -14,6 +14,7 @@ timeouts. Sync callers configure httpx2's timeouts directly; there is no sync Ti
14
14
 
15
15
  import asyncio
16
16
  import logging
17
+ import math
17
18
 
18
19
  import httpx2
19
20
 
@@ -22,7 +23,7 @@ from httpware.errors import TimeoutError as HttpwareTimeoutError
22
23
  from httpware.middleware import AsyncNext
23
24
 
24
25
 
25
- _TIMEOUT_INVALID = "timeout must be > 0"
26
+ _TIMEOUT_INVALID = "timeout must be a finite number > 0"
26
27
 
27
28
  _LOGGER = logging.getLogger("httpware.timeout")
28
29
 
@@ -43,7 +44,7 @@ class AsyncTimeout:
43
44
  """
44
45
 
45
46
  def __init__(self, *, timeout: float) -> None:
46
- if timeout <= 0:
47
+ if not math.isfinite(timeout) or timeout <= 0:
47
48
  raise ValueError(_TIMEOUT_INVALID)
48
49
  self._timeout = timeout
49
50
 
httpware-0.10.0/PKG-INFO DELETED
@@ -1,181 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: httpware
3
- Version: 0.10.0
4
- Summary: Resilience-first async HTTP client framework for Python
5
- Keywords: http,async,client,resilience,retry,circuit-breaker,middleware,httpx,pydantic
6
- Author: Artur Shiriev
7
- Author-email: Artur Shiriev <me@shiriev.ru>
8
- License-Expression: MIT
9
- Classifier: Programming Language :: Python :: 3.11
10
- Classifier: Programming Language :: Python :: 3.12
11
- Classifier: Programming Language :: Python :: 3.13
12
- Classifier: Programming Language :: Python :: 3.14
13
- Classifier: Typing :: Typed
14
- Classifier: Topic :: Software Development :: Libraries
15
- Classifier: Topic :: Internet :: WWW/HTTP
16
- Classifier: Framework :: AsyncIO
17
- Requires-Dist: httpx2>=2.0.0,<3.0
18
- Requires-Dist: httpware[pydantic,msgspec,otel] ; extra == 'all'
19
- Requires-Dist: msgspec>=0.18 ; extra == 'msgspec'
20
- Requires-Dist: opentelemetry-api>=1.20 ; extra == 'otel'
21
- Requires-Dist: pydantic>=2.0,<3.0 ; extra == 'pydantic'
22
- Requires-Python: >=3.11, <4
23
- Project-URL: repository, https://github.com/modern-python/httpware
24
- Project-URL: docs, https://httpware.modern-python.org
25
- Provides-Extra: all
26
- Provides-Extra: msgspec
27
- Provides-Extra: otel
28
- Provides-Extra: pydantic
29
- Description-Content-Type: text/markdown
30
-
31
- # httpware
32
-
33
- [![Test](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
34
- [![PyPI version](https://badge.fury.io/py/httpware.svg)](https://pypi.org/project/httpware/)
35
- [![Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/)
36
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
37
-
38
- **A Python HTTP client framework with sync and async clients for building resilient service clients.**
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 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
-
42
- > **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
43
-
44
- ## Install
45
-
46
- ```bash
47
- pip install httpware # core only — no decoder
48
- pip install httpware[pydantic] # + PydanticDecoder — handles BaseModel + dataclasses + primitives + generics
49
- pip install httpware[msgspec] # + MsgspecDecoder — handles Struct + dataclasses + primitives + generics
50
- pip install httpware[pydantic,msgspec] # both extras — both decoders register; BaseModel routes to pydantic, Struct to msgspec
51
- pip install httpware[all] # everything declared above (pydantic, msgspec, otel)
52
- ```
53
-
54
- `AsyncClient()` resolves `decoders=None` against installed extras: pydantic if installed (first), msgspec if installed (second), or an empty tuple if neither. `AsyncClient()` never raises on missing extras — failure is deferred to the first `response_model=` call, where `MissingDecoderError` fires *before* the HTTP request if no registered decoder claims the model.
55
-
56
- ## Quickstart
57
-
58
- **Async usage:**
59
-
60
- ```python
61
- import asyncio
62
-
63
- from httpware import AsyncClient
64
-
65
- async def main() -> None:
66
- async with AsyncClient(base_url="https://example.test") as client:
67
- response = await client.get("/users/42")
68
- print(response.json())
69
-
70
- asyncio.run(main())
71
- ```
72
-
73
- **Sync usage:**
74
-
75
- ```python
76
- from httpware import Client
77
-
78
- with Client(base_url="https://example.test") as client:
79
- response = client.get("/users/42")
80
- print(response.json())
81
- ```
82
-
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
-
85
- ```python
86
- from httpware import AsyncClient
87
- from pydantic import BaseModel
88
-
89
-
90
- class User(BaseModel):
91
- id: int
92
- name: str
93
-
94
-
95
- async def main() -> None:
96
- async with AsyncClient(base_url="https://api.example.com") as client:
97
- user = await client.get("/users/1", response_model=User)
98
- print(user.name)
99
- ```
100
-
101
- ### With resilience middleware
102
-
103
- Compose resilience middleware at construction; `AsyncBulkhead` goes outside `AsyncRetry` so one slot covers all retry attempts.
104
-
105
- The sync `Client` accepts identical `middleware=[...]`; swap `AsyncClient` → `Client` and `AsyncRetry` → `Retry` for the sync version.
106
-
107
- ```python
108
- from httpware import AsyncClient, AsyncBulkhead, AsyncRetry
109
-
110
-
111
- async def main() -> None:
112
- async with AsyncClient(
113
- base_url="https://api.example.com",
114
- middleware=[
115
- AsyncBulkhead(max_concurrent=10), # cap total in-flight
116
- AsyncRetry(), # default: 3 attempts, full-jitter backoff
117
- ],
118
- ) as client:
119
- user = await client.get("/users/1", response_model=User)
120
- ```
121
-
122
- Need a custom middleware (auth, tracing, request-ID propagation, etc.)? See the [Middleware guide](https://httpware.modern-python.org/middleware/).
123
-
124
- ### Streaming responses
125
-
126
- For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager:
127
-
128
- ```python
129
- from httpware import AsyncClient
130
-
131
-
132
- async def main() -> None:
133
- async with AsyncClient(base_url="https://api.example.com") as client:
134
- async with client.stream("GET", "/big-file") as response:
135
- async for chunk in response.aiter_bytes():
136
- process(chunk)
137
- ```
138
-
139
- `stream()` auto-raises `StatusError` subclasses on 4xx/5xx with the response body pre-read, so `exc.response.content` is accessible from the caught exception.
140
-
141
- It does NOT pass through the middleware chain: `AsyncRetry`, `AsyncBulkhead`, and any custom middleware are bypassed. (AsyncRetry separately refuses to retry any request — stream or non-stream — whose body was an async-iterable, since streams can't replay across attempts.)
142
-
143
- ## Errors
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`.
146
-
147
- ## Observability
148
-
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
-
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
-
153
- ```python
154
- import logging
155
-
156
- # Enable visibility into resilience operational events
157
- logging.getLogger("httpware.retry").setLevel(logging.WARNING)
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)
161
- ```
162
-
163
- For OTel attribute enrichment on the active span — install the extra:
164
-
165
- ```bash
166
- pip install httpware[otel]
167
- ```
168
-
169
- When installed, `_emit_event` calls `trace.get_current_span().add_event(name, attributes=...)` automatically. We never create our own spans; for HTTP-level tracing install `opentelemetry-instrumentation-httpx` separately.
170
-
171
- ## 📚 [Documentation](https://httpware.modern-python.org)
172
-
173
- ## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
174
-
175
- ## 📦 [PyPI](https://pypi.org/project/httpware)
176
-
177
- ## 📝 [License](https://github.com/modern-python/httpware/blob/main/LICENSE)
178
-
179
- ## Part of `modern-python`
180
-
181
- Browse the full list of templates and libraries in [`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
httpware-0.10.0/README.md DELETED
@@ -1,151 +0,0 @@
1
- # httpware
2
-
3
- [![Test](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
4
- [![PyPI version](https://badge.fury.io/py/httpware.svg)](https://pypi.org/project/httpware/)
5
- [![Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
-
8
- **A Python HTTP client framework with sync and async clients for building resilient service clients.**
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 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
-
12
- > **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
13
-
14
- ## Install
15
-
16
- ```bash
17
- pip install httpware # core only — no decoder
18
- pip install httpware[pydantic] # + PydanticDecoder — handles BaseModel + dataclasses + primitives + generics
19
- pip install httpware[msgspec] # + MsgspecDecoder — handles Struct + dataclasses + primitives + generics
20
- pip install httpware[pydantic,msgspec] # both extras — both decoders register; BaseModel routes to pydantic, Struct to msgspec
21
- pip install httpware[all] # everything declared above (pydantic, msgspec, otel)
22
- ```
23
-
24
- `AsyncClient()` resolves `decoders=None` against installed extras: pydantic if installed (first), msgspec if installed (second), or an empty tuple if neither. `AsyncClient()` never raises on missing extras — failure is deferred to the first `response_model=` call, where `MissingDecoderError` fires *before* the HTTP request if no registered decoder claims the model.
25
-
26
- ## Quickstart
27
-
28
- **Async usage:**
29
-
30
- ```python
31
- import asyncio
32
-
33
- from httpware import AsyncClient
34
-
35
- async def main() -> None:
36
- async with AsyncClient(base_url="https://example.test") as client:
37
- response = await client.get("/users/42")
38
- print(response.json())
39
-
40
- asyncio.run(main())
41
- ```
42
-
43
- **Sync usage:**
44
-
45
- ```python
46
- from httpware import Client
47
-
48
- with Client(base_url="https://example.test") as client:
49
- response = client.get("/users/42")
50
- print(response.json())
51
- ```
52
-
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
-
55
- ```python
56
- from httpware import AsyncClient
57
- from pydantic import BaseModel
58
-
59
-
60
- class User(BaseModel):
61
- id: int
62
- name: str
63
-
64
-
65
- async def main() -> None:
66
- async with AsyncClient(base_url="https://api.example.com") as client:
67
- user = await client.get("/users/1", response_model=User)
68
- print(user.name)
69
- ```
70
-
71
- ### With resilience middleware
72
-
73
- Compose resilience middleware at construction; `AsyncBulkhead` goes outside `AsyncRetry` so one slot covers all retry attempts.
74
-
75
- The sync `Client` accepts identical `middleware=[...]`; swap `AsyncClient` → `Client` and `AsyncRetry` → `Retry` for the sync version.
76
-
77
- ```python
78
- from httpware import AsyncClient, AsyncBulkhead, AsyncRetry
79
-
80
-
81
- async def main() -> None:
82
- async with AsyncClient(
83
- base_url="https://api.example.com",
84
- middleware=[
85
- AsyncBulkhead(max_concurrent=10), # cap total in-flight
86
- AsyncRetry(), # default: 3 attempts, full-jitter backoff
87
- ],
88
- ) as client:
89
- user = await client.get("/users/1", response_model=User)
90
- ```
91
-
92
- Need a custom middleware (auth, tracing, request-ID propagation, etc.)? See the [Middleware guide](https://httpware.modern-python.org/middleware/).
93
-
94
- ### Streaming responses
95
-
96
- For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager:
97
-
98
- ```python
99
- from httpware import AsyncClient
100
-
101
-
102
- async def main() -> None:
103
- async with AsyncClient(base_url="https://api.example.com") as client:
104
- async with client.stream("GET", "/big-file") as response:
105
- async for chunk in response.aiter_bytes():
106
- process(chunk)
107
- ```
108
-
109
- `stream()` auto-raises `StatusError` subclasses on 4xx/5xx with the response body pre-read, so `exc.response.content` is accessible from the caught exception.
110
-
111
- It does NOT pass through the middleware chain: `AsyncRetry`, `AsyncBulkhead`, and any custom middleware are bypassed. (AsyncRetry separately refuses to retry any request — stream or non-stream — whose body was an async-iterable, since streams can't replay across attempts.)
112
-
113
- ## Errors
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`.
116
-
117
- ## Observability
118
-
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
-
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
-
123
- ```python
124
- import logging
125
-
126
- # Enable visibility into resilience operational events
127
- logging.getLogger("httpware.retry").setLevel(logging.WARNING)
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)
131
- ```
132
-
133
- For OTel attribute enrichment on the active span — install the extra:
134
-
135
- ```bash
136
- pip install httpware[otel]
137
- ```
138
-
139
- When installed, `_emit_event` calls `trace.get_current_span().add_event(name, attributes=...)` automatically. We never create our own spans; for HTTP-level tracing install `opentelemetry-instrumentation-httpx` separately.
140
-
141
- ## 📚 [Documentation](https://httpware.modern-python.org)
142
-
143
- ## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
144
-
145
- ## 📦 [PyPI](https://pypi.org/project/httpware)
146
-
147
- ## 📝 [License](https://github.com/modern-python/httpware/blob/main/LICENSE)
148
-
149
- ## Part of `modern-python`
150
-
151
- Browse the full list of templates and libraries in [`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.