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.
- {httpware-0.15.0 → httpware-0.15.2}/PKG-INFO +19 -7
- {httpware-0.15.0 → httpware-0.15.2}/README.md +18 -6
- {httpware-0.15.0 → httpware-0.15.2}/pyproject.toml +1 -1
- httpware-0.15.2/src/httpware/_internal/body_cap.py +146 -0
- httpware-0.15.2/src/httpware/_internal/exception_mapping.py +54 -0
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/_internal/observability.py +1 -1
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/client.py +178 -293
- httpware-0.15.2/src/httpware/decoders/_caching.py +30 -0
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/decoders/msgspec.py +4 -18
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/decoders/pydantic.py +4 -18
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/errors.py +25 -86
- httpware-0.15.2/src/httpware/middleware/resilience/_event_loop_guard.py +37 -0
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/resilience/budget.py +1 -1
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/resilience/bulkhead.py +42 -53
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/resilience/circuit_breaker.py +8 -15
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/resilience/retry.py +1 -1
- httpware-0.15.0/src/httpware/_internal/exception_mapping.py +0 -28
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/__init__.py +0 -0
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/_internal/redaction.py +0 -0
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/_internal/status.py +0 -0
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/decoders/__init__.py +0 -0
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/decoders/_resolver.py +0 -0
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/resilience/__init__.py +0 -0
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.15.0 → httpware-0.15.2}/src/httpware/middleware/resilience/timeout.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
[](https://pypi.org/project/httpware/)
|
|
39
45
|
[](https://pypi.org/project/httpware/)
|
|
40
|
-
[](https://pepy.tech/projects/httpware)
|
|
41
47
|
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
42
48
|
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
43
49
|
[](https://github.com/modern-python/httpware/blob/main/LICENSE)
|
|
@@ -47,13 +53,19 @@ Description-Content-Type: text/markdown
|
|
|
47
53
|
[](https://github.com/astral-sh/ruff)
|
|
48
54
|
[](https://github.com/astral-sh/ty)
|
|
49
55
|
|
|
50
|
-
**
|
|
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
|
-
- **
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
[](https://pypi.org/project/httpware/)
|
|
4
10
|
[](https://pypi.org/project/httpware/)
|
|
5
|
-
[](https://pepy.tech/projects/httpware)
|
|
6
12
|
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
7
13
|
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
8
14
|
[](https://github.com/modern-python/httpware/blob/main/LICENSE)
|
|
@@ -12,13 +18,19 @@
|
|
|
12
18
|
[](https://github.com/astral-sh/ruff)
|
|
13
19
|
[](https://github.com/astral-sh/ty)
|
|
14
20
|
|
|
15
|
-
**
|
|
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
|
-
- **
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
|
@@ -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
|
|
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``,
|