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.
- httpware-0.11.0/PKG-INFO +110 -0
- httpware-0.11.0/README.md +80 -0
- {httpware-0.10.0 → httpware-0.11.0}/pyproject.toml +2 -2
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/__init__.py +2 -0
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/_internal/observability.py +20 -4
- httpware-0.11.0/src/httpware/_internal/redaction.py +116 -0
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/_internal/status.py +9 -4
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/client.py +34 -1
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/decoders/msgspec.py +2 -0
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/decoders/pydantic.py +12 -4
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/errors.py +48 -21
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/__init__.py +13 -0
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/resilience/_backoff.py +8 -5
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/resilience/budget.py +8 -4
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/resilience/circuit_breaker.py +4 -1
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/resilience/retry.py +37 -35
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/resilience/timeout.py +3 -2
- httpware-0.10.0/PKG-INFO +0 -181
- httpware-0.10.0/README.md +0 -151
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/_internal/exception_mapping.py +0 -0
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/decoders/__init__.py +0 -0
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/resilience/__init__.py +0 -0
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
- {httpware-0.10.0 → httpware-0.11.0}/src/httpware/py.typed +0 -0
httpware-0.11.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/httpware/)
|
|
34
|
+
[](https://pypi.org/project/httpware/)
|
|
35
|
+
[](https://pypistats.org/packages/httpware)
|
|
36
|
+
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
37
|
+
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
38
|
+
[](https://github.com/modern-python/httpware/blob/main/LICENSE)
|
|
39
|
+
[](https://github.com/modern-python/httpware/stargazers)
|
|
40
|
+
[](https://context7.com/modern-python/httpware)
|
|
41
|
+
[](https://github.com/astral-sh/uv)
|
|
42
|
+
[](https://github.com/astral-sh/ruff)
|
|
43
|
+
[](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
|
+
[](https://pypi.org/project/httpware/)
|
|
4
|
+
[](https://pypi.org/project/httpware/)
|
|
5
|
+
[](https://pypistats.org/packages/httpware)
|
|
6
|
+
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
7
|
+
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
8
|
+
[](https://github.com/modern-python/httpware/blob/main/LICENSE)
|
|
9
|
+
[](https://github.com/modern-python/httpware/stargazers)
|
|
10
|
+
[](https://context7.com/modern-python/httpware)
|
|
11
|
+
[](https://github.com/astral-sh/uv)
|
|
12
|
+
[](https://github.com/astral-sh/ruff)
|
|
13
|
+
[](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.
|
|
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``
|
|
6
|
-
(``retry.giving_up``, ``bulkhead.rejected``,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
21
|
-
``
|
|
22
|
-
``max_delay
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
34
|
-
[](https://pypi.org/project/httpware/)
|
|
35
|
-
[](https://pypi.org/project/httpware/)
|
|
36
|
-
[](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
|
-
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
4
|
-
[](https://pypi.org/project/httpware/)
|
|
5
|
-
[](https://pypi.org/project/httpware/)
|
|
6
|
-
[](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.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|