httpware 0.5.0__tar.gz → 0.7.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpware
3
- Version: 0.5.0
3
+ Version: 0.7.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. Streaming and observability are not yet shipped.
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.
@@ -89,6 +91,8 @@ async def main() -> None:
89
91
  user = await client.get("/users/1", response_model=User)
90
92
  ```
91
93
 
94
+ Need a custom middleware (auth, tracing, request-ID propagation, etc.)? See the [Middleware guide](docs/middleware.md).
95
+
92
96
  ### Streaming responses
93
97
 
94
98
  For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager:
@@ -112,6 +116,28 @@ It does NOT pass through the middleware chain: `Retry`, `Bulkhead`, and any cust
112
116
 
113
117
  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
118
 
119
+ ## Observability
120
+
121
+ `Retry` and `Bulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed).
122
+
123
+ 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.
124
+
125
+ ```python
126
+ import logging
127
+
128
+ # Enable visibility into retry / bulkhead operational events
129
+ logging.getLogger("httpware.retry").setLevel(logging.WARNING)
130
+ logging.getLogger("httpware.bulkhead").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
+
115
141
  ## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
116
142
 
117
143
  ## 📦 [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. Streaming and observability are not yet shipped.
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.
@@ -61,6 +61,8 @@ async def main() -> None:
61
61
  user = await client.get("/users/1", response_model=User)
62
62
  ```
63
63
 
64
+ Need a custom middleware (auth, tracing, request-ID propagation, etc.)? See the [Middleware guide](docs/middleware.md).
65
+
64
66
  ### Streaming responses
65
67
 
66
68
  For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager:
@@ -84,6 +86,28 @@ It does NOT pass through the middleware chain: `Retry`, `Bulkhead`, and any cust
84
86
 
85
87
  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
88
 
89
+ ## Observability
90
+
91
+ `Retry` and `Bulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed).
92
+
93
+ 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.
94
+
95
+ ```python
96
+ import logging
97
+
98
+ # Enable visibility into retry / bulkhead operational events
99
+ logging.getLogger("httpware.retry").setLevel(logging.WARNING)
100
+ logging.getLogger("httpware.bulkhead").setLevel(logging.WARNING)
101
+ ```
102
+
103
+ For OTel attribute enrichment on the active span — install the extra:
104
+
105
+ ```bash
106
+ pip install httpware[otel]
107
+ ```
108
+
109
+ 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.
110
+
87
111
  ## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
88
112
 
89
113
  ## 📦 [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.5.0"
29
+ version = "0.7.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
- all = ["httpware[pydantic,msgspec]"]
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"
@@ -5,3 +5,4 @@ from importlib.util import find_spec
5
5
 
6
6
  is_msgspec_installed = find_spec("msgspec") is not None
7
7
  is_pydantic_installed = find_spec("pydantic") is not None
8
+ is_otel_installed = find_spec("opentelemetry") is not None
@@ -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