httpware 0.15.0__tar.gz → 0.15.2__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 (30) hide show
  1. {httpware-0.15.0 → httpware-0.15.2}/PKG-INFO +19 -7
  2. {httpware-0.15.0 → httpware-0.15.2}/README.md +18 -6
  3. {httpware-0.15.0 → httpware-0.15.2}/pyproject.toml +1 -1
  4. httpware-0.15.2/src/httpware/_internal/body_cap.py +146 -0
  5. httpware-0.15.2/src/httpware/_internal/exception_mapping.py +54 -0
  6. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/_internal/observability.py +1 -1
  7. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/client.py +178 -293
  8. httpware-0.15.2/src/httpware/decoders/_caching.py +30 -0
  9. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/decoders/msgspec.py +4 -18
  10. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/decoders/pydantic.py +4 -18
  11. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/errors.py +25 -86
  12. httpware-0.15.2/src/httpware/middleware/resilience/_event_loop_guard.py +37 -0
  13. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/resilience/budget.py +1 -1
  14. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/resilience/bulkhead.py +42 -53
  15. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/resilience/circuit_breaker.py +8 -15
  16. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/resilience/retry.py +1 -1
  17. httpware-0.15.0/src/httpware/_internal/exception_mapping.py +0 -28
  18. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/__init__.py +0 -0
  19. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/_internal/__init__.py +0 -0
  20. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/_internal/import_checker.py +0 -0
  21. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/_internal/redaction.py +0 -0
  22. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/_internal/status.py +0 -0
  23. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/decoders/__init__.py +0 -0
  24. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/decoders/_resolver.py +0 -0
  25. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/__init__.py +0 -0
  26. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/chain.py +0 -0
  27. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/resilience/__init__.py +0 -0
  28. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/resilience/_backoff.py +0 -0
  29. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/resilience/timeout.py +0 -0
  30. {httpware-0.15.0 → httpware-0.15.2}/src/httpware/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpware
3
- Version: 0.15.0
3
+ Version: 0.15.2
4
4
  Summary: Python HTTP client framework with sync & async clients and built-in resilience
5
5
  Keywords: http-client,httpx,async,asyncio,resilience,retry,circuit-breaker,bulkhead,middleware,pydantic,msgspec,python
6
6
  Author: Artur Shiriev
@@ -33,11 +33,17 @@ Provides-Extra: otel
33
33
  Provides-Extra: pydantic
34
34
  Description-Content-Type: text/markdown
35
35
 
36
- # httpware
36
+ <p align="center">
37
+ <picture>
38
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/httpware/lockup-dark.svg">
39
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/httpware/lockup-light.svg">
40
+ <img alt="httpware" src="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/httpware/lockup.png" width="420">
41
+ </picture>
42
+ </p>
37
43
 
38
44
  [![PyPI version](https://img.shields.io/pypi/v/httpware.svg)](https://pypi.org/project/httpware/)
39
45
  [![Supported Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/)
40
- [![Downloads](https://img.shields.io/pypi/dm/httpware.svg)](https://pypistats.org/packages/httpware)
46
+ [![Downloads](https://static.pepy.tech/badge/httpware/month)](https://pepy.tech/projects/httpware)
41
47
  [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
42
48
  [![CI](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
43
49
  [![License](https://img.shields.io/github/license/modern-python/httpware.svg)](https://github.com/modern-python/httpware/blob/main/LICENSE)
@@ -47,13 +53,19 @@ Description-Content-Type: text/markdown
47
53
  [![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)
48
54
  [![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)
49
55
 
50
- **A Python HTTP client framework with sync and async clients for building resilient service clients.**
56
+ **Typed, resilient HTTP clients for Python typed errors, typed response bodies, and composable resilience (retry, bulkhead, circuit breaker), sync or async.**
51
57
 
52
58
  ## Why httpware
53
59
 
54
- - **Typed errors, no `raise_for_status()`** 4xx/5xx automatically raise a status-keyed exception tree (`NotFoundError`, `RateLimitedError`, …), all under `httpware.StatusError`.
55
- - **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.
56
- - **Production resilience as composable middleware** — retry + retry-budget, bulkhead, circuit breaker, and timeout, composed at construction — all over standard `httpx2`.
60
+ - **Errors you can catch by name** a 404 raises `NotFoundError`, a 429
61
+ `RateLimitedError`, automatically; everything else bubbles up under one
62
+ `httpware.StatusError` base. No `raise_for_status()`, no status-code
63
+ branching.
64
+ - **Typed response bodies** — `response_model=User` decodes the body straight
65
+ to your pydantic or msgspec type; a missing decoder fails fast, *before* the
66
+ request goes out.
67
+ - **Composable resilience** — retry + retry-budget, bulkhead, circuit breaker,
68
+ and timeout as middleware over standard `httpx2`.
57
69
 
58
70
  Built on `httpx2`: httpware re-exports `httpx2.Request`/`httpx2.Response` and stays a thin wrapper, not a new HTTP abstraction.
59
71
 
@@ -1,8 +1,14 @@
1
- # httpware
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/httpware/lockup-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/httpware/lockup-light.svg">
5
+ <img alt="httpware" src="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/httpware/lockup.png" width="420">
6
+ </picture>
7
+ </p>
2
8
 
3
9
  [![PyPI version](https://img.shields.io/pypi/v/httpware.svg)](https://pypi.org/project/httpware/)
4
10
  [![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)
11
+ [![Downloads](https://static.pepy.tech/badge/httpware/month)](https://pepy.tech/projects/httpware)
6
12
  [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
7
13
  [![CI](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
8
14
  [![License](https://img.shields.io/github/license/modern-python/httpware.svg)](https://github.com/modern-python/httpware/blob/main/LICENSE)
@@ -12,13 +18,19 @@
12
18
  [![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
19
  [![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
20
 
15
- **A Python HTTP client framework with sync and async clients for building resilient service clients.**
21
+ **Typed, resilient HTTP clients for Python typed errors, typed response bodies, and composable resilience (retry, bulkhead, circuit breaker), sync or async.**
16
22
 
17
23
  ## Why httpware
18
24
 
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`.
25
+ - **Errors you can catch by name** a 404 raises `NotFoundError`, a 429
26
+ `RateLimitedError`, automatically; everything else bubbles up under one
27
+ `httpware.StatusError` base. No `raise_for_status()`, no status-code
28
+ branching.
29
+ - **Typed response bodies** — `response_model=User` decodes the body straight
30
+ to your pydantic or msgspec type; a missing decoder fails fast, *before* the
31
+ request goes out.
32
+ - **Composable resilience** — retry + retry-budget, bulkhead, circuit breaker,
33
+ and timeout as middleware over standard `httpx2`.
22
34
 
23
35
  Built on `httpx2`: httpware re-exports `httpx2.Request`/`httpx2.Response` and stays a thin wrapper, not a new HTTP abstraction.
24
36
 
@@ -31,7 +31,7 @@ classifiers = [
31
31
  "Topic :: Internet :: WWW/HTTP",
32
32
  "Framework :: AsyncIO",
33
33
  ]
34
- version = "0.15.0"
34
+ version = "0.15.2"
35
35
  dependencies = [
36
36
  "httpx2>=2.0.0,<3.0",
37
37
  ]
@@ -0,0 +1,146 @@
1
+ """Response-body cap enforcement: validate, read-capped (sync + async)."""
2
+
3
+ import typing
4
+ from collections.abc import Mapping
5
+ from http import HTTPStatus
6
+
7
+ import httpx2
8
+
9
+ from httpware.errors import ResponseTooLargeError
10
+
11
+
12
+ _MAX_RESPONSE_BODY_BYTES_INVALID = "max_response_body_bytes must be >= 1"
13
+
14
+
15
+ def _validate_max_response_body_bytes(cap: int | None) -> None:
16
+ """Reject a non-None cap below 1. None means unbounded (the default)."""
17
+ if cap is not None and cap < 1:
18
+ raise ValueError(_MAX_RESPONSE_BODY_BYTES_INVALID)
19
+
20
+
21
+ def _parse_content_length(raw: str | None) -> int | None:
22
+ """Return a non-negative int Content-Length, or None for missing/garbage. Never raises."""
23
+ if raw is None:
24
+ return None
25
+ try:
26
+ value = int(raw)
27
+ except ValueError:
28
+ return None
29
+ return value if value >= 0 else None
30
+
31
+
32
+ class _CapExceeded(Exception): # noqa: N818 — internal control-flow signal, not a user-facing error
33
+ """Internal signal: decoded bytes crossed the cap mid-read. Carries bytes read so far."""
34
+
35
+ def __init__(self, *, read: int) -> None:
36
+ self.read = read
37
+ super().__init__(f"decoded body exceeded cap after {read} bytes")
38
+
39
+
40
+ def _accumulate_capped(chunks: typing.Iterable[bytes], cap: int) -> bytes:
41
+ """Concatenate `chunks`, raising `_CapExceeded` the moment the running total exceeds `cap`.
42
+
43
+ Counts decoded bytes (the in-memory footprint). Grown in a single bytearray
44
+ so there is no transient list-plus-join double allocation.
45
+ """
46
+ buf = bytearray()
47
+ for chunk in chunks:
48
+ buf += chunk
49
+ if len(buf) > cap:
50
+ raise _CapExceeded(read=len(buf))
51
+ return bytes(buf)
52
+
53
+
54
+ def _safe_extensions(extensions: Mapping[str, typing.Any]) -> dict[str, typing.Any]:
55
+ """Copy response extensions, dropping the now-stale `network_stream`.
56
+
57
+ The rebuilt buffered Response never touches its network stream, so carrying a
58
+ consumed/closed one wholesale is sloppy. `http_version`/`reason_phrase` and
59
+ any other keys are preserved.
60
+ """
61
+ return {key: value for key, value in extensions.items() if key != "network_stream"}
62
+
63
+
64
+ # Headers describing the wire encoding of the body. The accumulator yields the
65
+ # DECODED body, so these no longer apply; httpx2 recomputes content-length from
66
+ # the buffered content. Carrying content-encoding forward makes httpx2 try to
67
+ # re-decode already-decoded bytes and raise.
68
+ _WIRE_BODY_HEADERS = ("content-encoding", "content-length", "transfer-encoding")
69
+ _BODILESS_STATUS = frozenset({HTTPStatus.NO_CONTENT, HTTPStatus.NOT_MODIFIED}) # 204, 304
70
+
71
+
72
+ def _buffered_headers(headers: httpx2.Headers) -> httpx2.Headers:
73
+ """Copy `headers`, stripping wire-encoding headers stale after decoding+buffering."""
74
+ out = httpx2.Headers(headers)
75
+ for name in _WIRE_BODY_HEADERS:
76
+ if name in out:
77
+ del out[name]
78
+ return out
79
+
80
+
81
+ def _response_has_body(method: str, status_code: int) -> bool:
82
+ """Whether a response carries a message body (RFC 9110 §6.4.1).
83
+
84
+ HEAD responses and 204/304 never have a body regardless of a declared
85
+ Content-Length, so they must never trip the cap.
86
+ """
87
+ return method.upper() != "HEAD" and status_code not in _BODILESS_STATUS
88
+
89
+
90
+ def _reject_if_declared_length_exceeds_cap(response: httpx2.Response, cap: int) -> int | None:
91
+ """Return the parsed Content-Length, raising ResponseTooLargeError(reason="declared") if it already exceeds cap."""
92
+ content_length = _parse_content_length(response.headers.get("content-length"))
93
+ if content_length is not None and content_length > cap:
94
+ raise ResponseTooLargeError(
95
+ status_code=response.status_code, limit=cap, content_length=content_length, reason="declared"
96
+ )
97
+ return content_length
98
+
99
+
100
+ def _build_buffered_response(response: httpx2.Response, content: bytes, request: httpx2.Request) -> httpx2.Response:
101
+ """Rebuild a buffered Response from the original streaming `response` and the final decoded `content`."""
102
+ return httpx2.Response(
103
+ status_code=response.status_code,
104
+ headers=_buffered_headers(response.headers),
105
+ content=content,
106
+ request=request,
107
+ extensions=_safe_extensions(response.extensions),
108
+ history=response.history,
109
+ )
110
+
111
+
112
+ def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) -> httpx2.Response:
113
+ """Buffer a streaming sync `response` under `cap` decoded bytes; return a buffered Response.
114
+
115
+ Raises `ResponseTooLargeError` (reason="declared") if the declared
116
+ Content-Length already exceeds `cap` — before any byte is read — and
117
+ (reason="streamed") if the decoded body crosses `cap` mid-read. Does not
118
+ close `response`; the caller owns the stream lifecycle.
119
+ """
120
+ if not _response_has_body(request.method, response.status_code):
121
+ response.read() # empty body; preserve the original response (and its headers)
122
+ return response
123
+ content_length = _reject_if_declared_length_exceeds_cap(response, cap)
124
+ try:
125
+ content = _accumulate_capped(response.iter_bytes(), cap)
126
+ except _CapExceeded:
127
+ raise ResponseTooLargeError(
128
+ status_code=response.status_code, limit=cap, content_length=content_length, reason="streamed"
129
+ ) from None
130
+ return _build_buffered_response(response, content, request)
131
+
132
+
133
+ async def _read_capped_async(response: httpx2.Response, cap: int, request: httpx2.Request) -> httpx2.Response:
134
+ """Async mirror of `_read_capped` (counts decoded bytes from `aiter_bytes`)."""
135
+ if not _response_has_body(request.method, response.status_code):
136
+ await response.aread() # empty body; preserve the original response (and its headers)
137
+ return response
138
+ content_length = _reject_if_declared_length_exceeds_cap(response, cap)
139
+ buf = bytearray()
140
+ async for chunk in response.aiter_bytes():
141
+ buf += chunk
142
+ if len(buf) > cap:
143
+ raise ResponseTooLargeError(
144
+ status_code=response.status_code, limit=cap, content_length=content_length, reason="streamed"
145
+ )
146
+ return _build_buffered_response(response, bytes(buf), request)
@@ -0,0 +1,54 @@
1
+ """httpx2 -> httpware exception mapping + context-manager wrappers (shared).
2
+
3
+ map_httpx2_exception is a pure function used by both Client._terminal and
4
+ AsyncClient._terminal, and by both stream() methods. Clause ordering:
5
+ TimeoutException -> InvalidURL/CookieConflict -> NetworkError -> HTTPError
6
+ (subclass before parent so the right type wins). The two context managers
7
+ below wrap it for use as `with`/`async with` blocks around the httpx2 call.
8
+ """
9
+
10
+ import contextlib
11
+ from collections.abc import AsyncIterator, Iterator
12
+
13
+ import httpx2
14
+
15
+ from httpware.errors import NetworkError, TimeoutError, TransportError # noqa: A004
16
+
17
+
18
+ def map_httpx2_exception(exc: BaseException) -> NetworkError | TimeoutError | TransportError:
19
+ """Map an httpx2 exception to its httpware equivalent.
20
+
21
+ Order is significant: more-specific httpx2 types must match before more
22
+ general ones. We return the mapped exception; the caller does `raise ... from exc`.
23
+ """
24
+ if isinstance(exc, httpx2.TimeoutException):
25
+ return TimeoutError(str(exc))
26
+ if isinstance(exc, (httpx2.InvalidURL, httpx2.CookieConflict)):
27
+ return TransportError(str(exc))
28
+ if isinstance(exc, httpx2.NetworkError):
29
+ return NetworkError(str(exc))
30
+ if isinstance(exc, httpx2.HTTPError):
31
+ return TransportError(str(exc))
32
+ return TransportError(str(exc)) # pragma: no cover — defensive default; httpx2.HTTPError is the root
33
+
34
+
35
+ @contextlib.asynccontextmanager
36
+ async def _httpx2_exception_mapper() -> AsyncIterator[None]:
37
+ """Map httpx2 exceptions to httpware exceptions. Shared by AsyncClient._terminal and stream()."""
38
+ try:
39
+ yield
40
+ except httpx2.HTTPError as exc:
41
+ raise map_httpx2_exception(exc) from exc
42
+ except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
43
+ raise map_httpx2_exception(exc) from exc
44
+
45
+
46
+ @contextlib.contextmanager
47
+ def _httpx2_exception_mapper_sync() -> Iterator[None]:
48
+ """Map httpx2 exceptions to httpware exceptions. Sync sibling of _httpx2_exception_mapper."""
49
+ try:
50
+ yield
51
+ except httpx2.HTTPError as exc:
52
+ raise map_httpx2_exception(exc) from exc
53
+ except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
54
+ raise map_httpx2_exception(exc) from exc
@@ -1,6 +1,6 @@
1
1
  """Observability emission helper — structured logging + opt-in OpenTelemetry span events.
2
2
 
3
- See planning/specs/2026-06-05-observability-design.md for the contract.
3
+ See architecture/resilience.md (Observability section) for the contract.
4
4
 
5
5
  Logger names (``httpware.retry``, ``httpware.bulkhead``, ``httpware.circuit_breaker``,
6
6
  ``httpware.timeout``) and event names (``retry.giving_up``, ``bulkhead.rejected``,