httpware 0.5.0__tar.gz → 0.6.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.5.0 → httpware-0.6.0}/PKG-INFO +28 -4
- {httpware-0.5.0 → httpware-0.6.0}/README.md +24 -2
- {httpware-0.5.0 → httpware-0.6.0}/pyproject.toml +3 -2
- {httpware-0.5.0 → httpware-0.6.0}/src/httpware/_internal/import_checker.py +1 -0
- httpware-0.6.0/src/httpware/_internal/observability.py +42 -0
- {httpware-0.5.0 → httpware-0.6.0}/src/httpware/middleware/resilience/bulkhead.py +16 -0
- {httpware-0.5.0 → httpware-0.6.0}/src/httpware/middleware/resilience/retry.py +40 -0
- {httpware-0.5.0 → httpware-0.6.0}/src/httpware/__init__.py +0 -0
- {httpware-0.5.0 → httpware-0.6.0}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.5.0 → httpware-0.6.0}/src/httpware/client.py +0 -0
- {httpware-0.5.0 → httpware-0.6.0}/src/httpware/decoders/__init__.py +0 -0
- {httpware-0.5.0 → httpware-0.6.0}/src/httpware/decoders/msgspec.py +0 -0
- {httpware-0.5.0 → httpware-0.6.0}/src/httpware/decoders/pydantic.py +0 -0
- {httpware-0.5.0 → httpware-0.6.0}/src/httpware/errors.py +0 -0
- {httpware-0.5.0 → httpware-0.6.0}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.5.0 → httpware-0.6.0}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.5.0 → httpware-0.6.0}/src/httpware/middleware/resilience/__init__.py +0 -0
- {httpware-0.5.0 → httpware-0.6.0}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.5.0 → httpware-0.6.0}/src/httpware/middleware/resilience/budget.py +0 -0
- {httpware-0.5.0 → httpware-0.6.0}/src/httpware/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: httpware
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Resilience-first async HTTP client framework for Python
|
|
5
5
|
Keywords: http,async,client,resilience,retry,circuit-breaker,middleware,httpx,pydantic
|
|
6
6
|
Author: Artur Shiriev
|
|
@@ -15,14 +15,16 @@ Classifier: Topic :: Software Development :: Libraries
|
|
|
15
15
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
16
16
|
Classifier: Framework :: AsyncIO
|
|
17
17
|
Requires-Dist: httpx2>=2.0.0,<3.0
|
|
18
|
-
Requires-Dist: httpware[pydantic,msgspec] ; extra == 'all'
|
|
18
|
+
Requires-Dist: httpware[pydantic,msgspec,otel] ; extra == 'all'
|
|
19
19
|
Requires-Dist: msgspec>=0.18 ; extra == 'msgspec'
|
|
20
|
+
Requires-Dist: opentelemetry-api>=1.20 ; extra == 'otel'
|
|
20
21
|
Requires-Dist: pydantic>=2.0,<3.0 ; extra == 'pydantic'
|
|
21
22
|
Requires-Python: >=3.11, <4
|
|
22
23
|
Project-URL: repository, https://github.com/modern-python/httpware
|
|
23
24
|
Project-URL: docs, https://httpware.readthedocs.io
|
|
24
25
|
Provides-Extra: all
|
|
25
26
|
Provides-Extra: msgspec
|
|
27
|
+
Provides-Extra: otel
|
|
26
28
|
Provides-Extra: pydantic
|
|
27
29
|
Description-Content-Type: text/markdown
|
|
28
30
|
|
|
@@ -37,7 +39,7 @@ Description-Content-Type: text/markdown
|
|
|
37
39
|
|
|
38
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 small resilience suite — `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — under `httpware.middleware.resilience`.
|
|
39
41
|
|
|
40
|
-
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
|
|
42
|
+
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
|
|
41
43
|
|
|
42
44
|
## Install
|
|
43
45
|
|
|
@@ -45,7 +47,7 @@ Description-Content-Type: text/markdown
|
|
|
45
47
|
pip install httpware # core only — no decoder
|
|
46
48
|
pip install httpware[pydantic] # + PydanticDecoder (the default-decoder path)
|
|
47
49
|
pip install httpware[msgspec] # + MsgspecDecoder
|
|
48
|
-
pip install httpware[all] # everything declared above (pydantic, msgspec)
|
|
50
|
+
pip install httpware[all] # everything declared above (pydantic, msgspec, otel)
|
|
49
51
|
```
|
|
50
52
|
|
|
51
53
|
`AsyncClient()` with no `decoder=` argument defaults to constructing a `PydanticDecoder`; that path requires the `pydantic` extra and raises `ImportError` at `AsyncClient.__init__` if it is missing.
|
|
@@ -112,6 +114,28 @@ It does NOT pass through the middleware chain: `Retry`, `Bulkhead`, and any cust
|
|
|
112
114
|
|
|
113
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`.
|
|
114
116
|
|
|
117
|
+
## Observability
|
|
118
|
+
|
|
119
|
+
`Retry` and `Bulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed).
|
|
120
|
+
|
|
121
|
+
Logger names (`httpware.retry`, `httpware.bulkhead`) and event names (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`, `bulkhead.rejected`) are the stable public contract.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
import logging
|
|
125
|
+
|
|
126
|
+
# Enable visibility into retry / bulkhead operational events
|
|
127
|
+
logging.getLogger("httpware.retry").setLevel(logging.WARNING)
|
|
128
|
+
logging.getLogger("httpware.bulkhead").setLevel(logging.WARNING)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
For OTel attribute enrichment on the active span — install the extra:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pip install httpware[otel]
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
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.
|
|
138
|
+
|
|
115
139
|
## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
|
|
116
140
|
|
|
117
141
|
## 📦 [PyPI](https://pypi.org/project/httpware)
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
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 small resilience suite — `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — under `httpware.middleware.resilience`.
|
|
11
11
|
|
|
12
|
-
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
|
|
12
|
+
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
|
|
13
13
|
|
|
14
14
|
## Install
|
|
15
15
|
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
pip install httpware # core only — no decoder
|
|
18
18
|
pip install httpware[pydantic] # + PydanticDecoder (the default-decoder path)
|
|
19
19
|
pip install httpware[msgspec] # + MsgspecDecoder
|
|
20
|
-
pip install httpware[all] # everything declared above (pydantic, msgspec)
|
|
20
|
+
pip install httpware[all] # everything declared above (pydantic, msgspec, otel)
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
`AsyncClient()` with no `decoder=` argument defaults to constructing a `PydanticDecoder`; that path requires the `pydantic` extra and raises `ImportError` at `AsyncClient.__init__` if it is missing.
|
|
@@ -84,6 +84,28 @@ It does NOT pass through the middleware chain: `Retry`, `Bulkhead`, and any cust
|
|
|
84
84
|
|
|
85
85
|
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`.
|
|
86
86
|
|
|
87
|
+
## Observability
|
|
88
|
+
|
|
89
|
+
`Retry` and `Bulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed).
|
|
90
|
+
|
|
91
|
+
Logger names (`httpware.retry`, `httpware.bulkhead`) and event names (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`, `bulkhead.rejected`) are the stable public contract.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import logging
|
|
95
|
+
|
|
96
|
+
# Enable visibility into retry / bulkhead operational events
|
|
97
|
+
logging.getLogger("httpware.retry").setLevel(logging.WARNING)
|
|
98
|
+
logging.getLogger("httpware.bulkhead").setLevel(logging.WARNING)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
For OTel attribute enrichment on the active span — install the extra:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install httpware[otel]
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
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.
|
|
108
|
+
|
|
87
109
|
## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
|
|
88
110
|
|
|
89
111
|
## 📦 [PyPI](https://pypi.org/project/httpware)
|
|
@@ -26,7 +26,7 @@ classifiers = [
|
|
|
26
26
|
"Topic :: Internet :: WWW/HTTP",
|
|
27
27
|
"Framework :: AsyncIO",
|
|
28
28
|
]
|
|
29
|
-
version = "0.
|
|
29
|
+
version = "0.6.0"
|
|
30
30
|
dependencies = [
|
|
31
31
|
"httpx2>=2.0.0,<3.0",
|
|
32
32
|
]
|
|
@@ -34,7 +34,8 @@ dependencies = [
|
|
|
34
34
|
[project.optional-dependencies]
|
|
35
35
|
pydantic = ["pydantic>=2.0,<3.0"]
|
|
36
36
|
msgspec = ["msgspec>=0.18"]
|
|
37
|
-
|
|
37
|
+
otel = ["opentelemetry-api>=1.20"]
|
|
38
|
+
all = ["httpware[pydantic,msgspec,otel]"]
|
|
38
39
|
|
|
39
40
|
[project.urls]
|
|
40
41
|
repository = "https://github.com/modern-python/httpware"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Observability emission helper — structured logging + opt-in OpenTelemetry span events.
|
|
2
|
+
|
|
3
|
+
See planning/specs/2026-06-05-observability-design.md for the contract.
|
|
4
|
+
|
|
5
|
+
Logger names (``httpware.retry``, ``httpware.bulkhead``) and event names
|
|
6
|
+
(``retry.giving_up``, ``bulkhead.rejected``, etc.) are the public observability
|
|
7
|
+
surface. They are stable: renames are breaking changes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import typing
|
|
12
|
+
|
|
13
|
+
from httpware._internal import import_checker
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _emit_event(
|
|
17
|
+
logger: logging.Logger,
|
|
18
|
+
event_name: str,
|
|
19
|
+
*,
|
|
20
|
+
level: int,
|
|
21
|
+
message: str,
|
|
22
|
+
attributes: dict[str, typing.Any],
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Emit one observability event to both channels.
|
|
25
|
+
|
|
26
|
+
1. Always emits a structured log record at ``level`` with ``extra=attributes``
|
|
27
|
+
(so log aggregators that index ``extra`` see structured fields).
|
|
28
|
+
2. If ``opentelemetry-api`` is installed, calls
|
|
29
|
+
``trace.get_current_span().add_event(event_name, attributes=attributes)``.
|
|
30
|
+
When no tracer is active, ``get_current_span()`` returns a ``NonRecordingSpan``
|
|
31
|
+
whose ``add_event`` is a documented no-op — so the call is unconditional
|
|
32
|
+
behind the install gate.
|
|
33
|
+
|
|
34
|
+
The lazy ``from opentelemetry import trace`` inside the if-block preserves
|
|
35
|
+
the optional-extras isolation invariant: ``import httpware`` must not pull
|
|
36
|
+
``opentelemetry`` into ``sys.modules`` when the extra is absent.
|
|
37
|
+
"""
|
|
38
|
+
logger.log(level, message, extra=attributes)
|
|
39
|
+
if import_checker.is_otel_installed:
|
|
40
|
+
from opentelemetry import trace # noqa: PLC0415 — lazy by design (optional-extras isolation)
|
|
41
|
+
|
|
42
|
+
trace.get_current_span().add_event(event_name, attributes=attributes)
|
|
@@ -12,9 +12,11 @@ AsyncClient(middleware=[shared]) calls to enforce a joint cap across clients.
|
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
import asyncio
|
|
15
|
+
import logging
|
|
15
16
|
|
|
16
17
|
import httpx2
|
|
17
18
|
|
|
19
|
+
from httpware._internal.observability import _emit_event
|
|
18
20
|
from httpware.errors import BulkheadFullError
|
|
19
21
|
from httpware.middleware import Next
|
|
20
22
|
|
|
@@ -22,6 +24,8 @@ from httpware.middleware import Next
|
|
|
22
24
|
_MAX_CONCURRENT_INVALID = "max_concurrent must be >= 1"
|
|
23
25
|
_ACQUIRE_TIMEOUT_INVALID = "acquire_timeout must be >= 0"
|
|
24
26
|
|
|
27
|
+
_LOGGER = logging.getLogger("httpware.bulkhead")
|
|
28
|
+
|
|
25
29
|
|
|
26
30
|
class Bulkhead:
|
|
27
31
|
"""Concurrency limiter middleware backed by ``asyncio.Semaphore``.
|
|
@@ -64,6 +68,18 @@ class Bulkhead:
|
|
|
64
68
|
async with asyncio.timeout(self._acquire_timeout):
|
|
65
69
|
await self._sem.acquire()
|
|
66
70
|
except TimeoutError as exc:
|
|
71
|
+
_emit_event(
|
|
72
|
+
_LOGGER,
|
|
73
|
+
"bulkhead.rejected",
|
|
74
|
+
level=logging.WARNING,
|
|
75
|
+
message="bulkhead rejected request — acquire_timeout exceeded",
|
|
76
|
+
attributes={
|
|
77
|
+
"max_concurrent": self._max_concurrent,
|
|
78
|
+
"acquire_timeout": self._acquire_timeout,
|
|
79
|
+
"method": request.method,
|
|
80
|
+
"url": str(request.url),
|
|
81
|
+
},
|
|
82
|
+
)
|
|
67
83
|
raise BulkheadFullError(
|
|
68
84
|
max_concurrent=self._max_concurrent,
|
|
69
85
|
acquire_timeout=self._acquire_timeout,
|
|
@@ -11,11 +11,13 @@ import asyncio
|
|
|
11
11
|
import builtins
|
|
12
12
|
import datetime
|
|
13
13
|
import email.utils
|
|
14
|
+
import logging
|
|
14
15
|
from collections.abc import Awaitable, Callable
|
|
15
16
|
from http import HTTPStatus
|
|
16
17
|
|
|
17
18
|
import httpx2
|
|
18
19
|
|
|
20
|
+
from httpware._internal.observability import _emit_event
|
|
19
21
|
from httpware.client import STREAMING_BODY_MARKER
|
|
20
22
|
from httpware.errors import NetworkError, RetryBudgetExhaustedError, StatusError, TimeoutError # noqa: A004
|
|
21
23
|
from httpware.middleware import Next
|
|
@@ -46,6 +48,8 @@ DEFAULT_IDEMPOTENT_METHODS = frozenset(
|
|
|
46
48
|
_MAX_ATTEMPTS_INVALID = "max_attempts must be >= 1"
|
|
47
49
|
_STREAMING_BODY_REFUSAL_NOTE = "httpware: not retrying — request body is a stream that cannot replay across attempts"
|
|
48
50
|
|
|
51
|
+
_LOGGER = logging.getLogger("httpware.retry")
|
|
52
|
+
|
|
49
53
|
|
|
50
54
|
def _parse_retry_after(value: str) -> float | None:
|
|
51
55
|
"""Parse a Retry-After header value. Returns None on malformed input."""
|
|
@@ -138,6 +142,17 @@ class Retry:
|
|
|
138
142
|
msg = "Retry: streaming-body refusal reached with no last_exc"
|
|
139
143
|
raise AssertionError(msg)
|
|
140
144
|
last_exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
|
|
145
|
+
_emit_event(
|
|
146
|
+
_LOGGER,
|
|
147
|
+
"retry.streaming_refused",
|
|
148
|
+
level=logging.WARNING,
|
|
149
|
+
message="retry refused — request body is a stream that cannot replay",
|
|
150
|
+
attributes={
|
|
151
|
+
"method": request.method,
|
|
152
|
+
"url": str(request.url),
|
|
153
|
+
"last_exception_type": type(last_exc).__qualname__,
|
|
154
|
+
},
|
|
155
|
+
)
|
|
141
156
|
raise last_exc
|
|
142
157
|
|
|
143
158
|
if is_last:
|
|
@@ -145,9 +160,34 @@ class Retry:
|
|
|
145
160
|
msg = "Retry: last_exc unset on final attempt — unreachable"
|
|
146
161
|
raise AssertionError(msg)
|
|
147
162
|
last_exc.add_note(f"httpware: gave up after {attempt + 1} attempts")
|
|
163
|
+
_emit_event(
|
|
164
|
+
_LOGGER,
|
|
165
|
+
"retry.giving_up",
|
|
166
|
+
level=logging.WARNING,
|
|
167
|
+
message=f"retry gave up after {attempt + 1} attempts",
|
|
168
|
+
attributes={
|
|
169
|
+
"attempts": attempt + 1,
|
|
170
|
+
"method": request.method,
|
|
171
|
+
"url": str(request.url),
|
|
172
|
+
"last_status": last_response.status_code if last_response is not None else None,
|
|
173
|
+
"last_exception_type": type(last_exc).__qualname__,
|
|
174
|
+
},
|
|
175
|
+
)
|
|
148
176
|
raise last_exc
|
|
149
177
|
|
|
150
178
|
if not self.budget.try_withdraw():
|
|
179
|
+
_emit_event(
|
|
180
|
+
_LOGGER,
|
|
181
|
+
"retry.budget_refused",
|
|
182
|
+
level=logging.WARNING,
|
|
183
|
+
message=f"retry budget refused after {attempt + 1} attempts",
|
|
184
|
+
attributes={
|
|
185
|
+
"attempts": attempt + 1,
|
|
186
|
+
"method": request.method,
|
|
187
|
+
"url": str(request.url),
|
|
188
|
+
"last_status": last_response.status_code if last_response is not None else None,
|
|
189
|
+
},
|
|
190
|
+
)
|
|
151
191
|
raise RetryBudgetExhaustedError(
|
|
152
192
|
last_response=last_response,
|
|
153
193
|
last_exception=last_exc,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|