httpware 0.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpware
3
- Version: 0.4.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. 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,10 +91,51 @@ async def main() -> None:
89
91
  user = await client.get("/users/1", response_model=User)
90
92
  ```
91
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: `Retry`, `Bulkhead`, and any custom middleware are bypassed. (Retry separately refuses to retry any request — stream or non-stream — whose body was an async-iterable, since streams can't replay across attempts.)
112
+
92
113
  ## Errors
93
114
 
94
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`.
95
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
+
96
139
  ## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
97
140
 
98
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. 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,10 +61,51 @@ async def main() -> None:
61
61
  user = await client.get("/users/1", response_model=User)
62
62
  ```
63
63
 
64
+ ### Streaming responses
65
+
66
+ For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager:
67
+
68
+ ```python
69
+ from httpware import AsyncClient
70
+
71
+
72
+ async def main() -> None:
73
+ async with AsyncClient(base_url="https://api.example.com") as client:
74
+ async with client.stream("GET", "/big-file") as response:
75
+ async for chunk in response.aiter_bytes():
76
+ process(chunk)
77
+ ```
78
+
79
+ `stream()` auto-raises `StatusError` subclasses on 4xx/5xx with the response body pre-read, so `exc.response.content` is accessible from the caught exception.
80
+
81
+ It does NOT pass through the middleware chain: `Retry`, `Bulkhead`, and any custom middleware are bypassed. (Retry separately refuses to retry any request — stream or non-stream — whose body was an async-iterable, since streams can't replay across attempts.)
82
+
64
83
  ## Errors
65
84
 
66
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`.
67
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
+
68
109
  ## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
69
110
 
70
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.4.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
- 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)
@@ -1,7 +1,8 @@
1
1
  """AsyncClient — the thin httpx2 wrapper."""
2
2
 
3
+ import contextlib
3
4
  import typing
4
- from collections.abc import Sequence
5
+ from collections.abc import AsyncIterator, Sequence
5
6
  from http import HTTPStatus
6
7
 
7
8
  import httpx2
@@ -44,6 +45,48 @@ def _default_pydantic_decoder() -> ResponseDecoder:
44
45
  return PydanticDecoder()
45
46
 
46
47
 
48
+ @contextlib.asynccontextmanager
49
+ async def _httpx2_exception_mapper() -> AsyncIterator[None]:
50
+ """Map httpx2 exceptions to httpware exceptions. Shared by AsyncClient._terminal and stream()."""
51
+ try:
52
+ yield
53
+ except httpx2.TimeoutException as exc:
54
+ raise TimeoutError(str(exc)) from exc
55
+ except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
56
+ raise TransportError(str(exc)) from exc
57
+ except httpx2.NetworkError as exc:
58
+ raise NetworkError(str(exc)) from exc
59
+ except httpx2.HTTPError as exc:
60
+ raise TransportError(str(exc)) from exc
61
+
62
+
63
+ def _raise_on_status_error(response: httpx2.Response) -> None:
64
+ """Raise the appropriate StatusError subclass for a 4xx/5xx response. No-op for 2xx/3xx."""
65
+ status = response.status_code
66
+ if HTTPStatus.BAD_REQUEST <= status < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
67
+ exc_class = STATUS_TO_EXCEPTION.get(
68
+ status,
69
+ ClientStatusError if status < HTTPStatus.INTERNAL_SERVER_ERROR else ServerStatusError,
70
+ )
71
+ raise exc_class(response)
72
+
73
+
74
+ STREAMING_BODY_MARKER = "httpware.streaming_body"
75
+ """Key set on ``httpx2.Request.extensions`` by ``_request_with_body`` when content/data/files is an async-iterable.
76
+
77
+ ``Retry.__call__`` reads this marker to refuse retrying a streamed-body request
78
+ (the consumed iterator cannot replay across attempts)."""
79
+
80
+
81
+ def _is_streaming_body(value: typing.Any) -> bool:
82
+ """Return True if value is an async-iterable that cannot be safely replayed for retry."""
83
+ if value is None:
84
+ return False
85
+ if isinstance(value, (bytes, bytearray, memoryview, str, dict)):
86
+ return False
87
+ return hasattr(value, "__aiter__")
88
+
89
+
47
90
  class AsyncClient:
48
91
  """Async HTTP client: thin wrapper around httpx2 with typed decoding and middleware."""
49
92
 
@@ -106,26 +149,13 @@ class AsyncClient:
106
149
 
107
150
  async def _terminal(self, request: httpx2.Request) -> httpx2.Response:
108
151
  try:
109
- response = await self._httpx2_client.send(request)
110
- except httpx2.TimeoutException as exc:
111
- raise TimeoutError(str(exc)) from exc
112
- except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
113
- raise TransportError(str(exc)) from exc
114
- except httpx2.NetworkError as exc:
115
- raise NetworkError(str(exc)) from exc
116
- except httpx2.HTTPError as exc:
117
- raise TransportError(str(exc)) from exc
152
+ async with _httpx2_exception_mapper():
153
+ response = await self._httpx2_client.send(request)
118
154
  except RuntimeError as exc:
119
155
  if "closed" in str(exc):
120
156
  raise TransportError(str(exc)) from exc
121
157
  raise
122
- status = response.status_code
123
- if HTTPStatus.BAD_REQUEST <= status < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
124
- exc_class = STATUS_TO_EXCEPTION.get(
125
- status,
126
- ClientStatusError if status < HTTPStatus.INTERNAL_SERVER_ERROR else ServerStatusError,
127
- )
128
- raise exc_class(response)
158
+ _raise_on_status_error(response)
129
159
  return response
130
160
 
131
161
  @typing.overload
@@ -150,7 +180,7 @@ class AsyncClient:
150
180
  """Delegate request construction to the wrapped httpx2.AsyncClient."""
151
181
  return self._httpx2_client.build_request(method, url, **kwargs)
152
182
 
153
- async def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method signatures
183
+ async def _request_with_body( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
154
184
  self,
155
185
  method: str,
156
186
  url: str,
@@ -186,6 +216,8 @@ class AsyncClient:
186
216
  if files is not None:
187
217
  kwargs["files"] = files
188
218
  request = self._httpx2_client.build_request(method, url, **kwargs)
219
+ if _is_streaming_body(content) or _is_streaming_body(data) or _is_streaming_body(files):
220
+ request.extensions[STREAMING_BODY_MARKER] = True
189
221
  return await self.send(request, response_model=response_model)
190
222
 
191
223
  @typing.overload
@@ -663,6 +695,66 @@ class AsyncClient:
663
695
  response_model=response_model,
664
696
  )
665
697
 
698
+ @contextlib.asynccontextmanager
699
+ async def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
700
+ self,
701
+ method: str,
702
+ url: str,
703
+ *,
704
+ params: typing.Any | None = None,
705
+ headers: typing.Any | None = None,
706
+ cookies: typing.Any | None = None,
707
+ timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
708
+ extensions: typing.Any | None = None,
709
+ json: typing.Any | None = None,
710
+ content: typing.Any | None = None,
711
+ data: typing.Any | None = None,
712
+ files: typing.Any | None = None,
713
+ ) -> AsyncIterator[httpx2.Response]:
714
+ """Stream an HTTP response. Bypasses the middleware chain.
715
+
716
+ Yields an httpx2.Response; consume the body via response.aiter_bytes(),
717
+ response.aiter_text(), response.aiter_lines(), or response.aiter_raw().
718
+ The body is NOT pre-read for 2xx/3xx (streaming preserved); the response
719
+ is closed when the context exits.
720
+
721
+ Bypasses the middleware chain (no Retry, no Bulkhead, no user-installed
722
+ middleware) for v1 — see planning/specs/2026-06-05-streaming-design.md.
723
+
724
+ Auto-raises StatusError subclasses on 4xx/5xx (NotFoundError,
725
+ ServiceUnavailableError, etc.) — consistent with client.get()/post()/etc.
726
+ On error the response body is pre-read so exc.response.content is
727
+ accessible. You lose the streaming property on errors; rare in practice.
728
+
729
+ Maps httpx2 exceptions raised during the request OR body consumption to
730
+ httpware exceptions via _httpx2_exception_mapper.
731
+ """
732
+ kwargs: dict[str, typing.Any] = {}
733
+ if params is not None:
734
+ kwargs["params"] = params
735
+ if headers is not None:
736
+ kwargs["headers"] = headers
737
+ if cookies is not None:
738
+ kwargs["cookies"] = cookies
739
+ if timeout is not httpx2.USE_CLIENT_DEFAULT:
740
+ kwargs["timeout"] = timeout
741
+ if extensions is not None:
742
+ kwargs["extensions"] = extensions
743
+ if json is not None:
744
+ kwargs["json"] = json
745
+ if content is not None:
746
+ kwargs["content"] = content
747
+ if data is not None:
748
+ kwargs["data"] = data
749
+ if files is not None:
750
+ kwargs["files"] = files
751
+
752
+ async with _httpx2_exception_mapper(), self._httpx2_client.stream(method, url, **kwargs) as response:
753
+ if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
754
+ await response.aread() # pre-read body so exc.response.content works
755
+ _raise_on_status_error(response)
756
+ yield response
757
+
666
758
  async def __aenter__(self) -> typing.Self:
667
759
  """Enter the async context manager; return self."""
668
760
  return self
@@ -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,14 @@ 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
21
+ from httpware.client import STREAMING_BODY_MARKER
19
22
  from httpware.errors import NetworkError, RetryBudgetExhaustedError, StatusError, TimeoutError # noqa: A004
20
23
  from httpware.middleware import Next
21
24
  from httpware.middleware.resilience._backoff import full_jitter_delay
@@ -43,6 +46,9 @@ DEFAULT_IDEMPOTENT_METHODS = frozenset(
43
46
  )
44
47
 
45
48
  _MAX_ATTEMPTS_INVALID = "max_attempts must be >= 1"
49
+ _STREAMING_BODY_REFUSAL_NOTE = "httpware: not retrying — request body is a stream that cannot replay across attempts"
50
+
51
+ _LOGGER = logging.getLogger("httpware.retry")
46
52
 
47
53
 
48
54
  def _parse_retry_after(value: str) -> float | None:
@@ -90,7 +96,7 @@ class Retry:
90
96
  self.budget = budget if budget is not None else RetryBudget()
91
97
  self._sleep = _sleep
92
98
 
93
- async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002, C901, PLR0912 — complexity budget: 3 error clauses + idempotency gate + budget gate + Retry-After branch + backoff
99
+ async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002, C901, PLR0912, PLR0915 — complexity budget: 3 error clauses + idempotency gate + streaming-body refusal + budget gate + Retry-After branch + backoff
94
100
  """Process a request through the retry loop. See module docstring."""
95
101
  method_eligible = request.method.upper() in self.retry_methods
96
102
  last_exc: BaseException | None = None
@@ -106,12 +112,17 @@ class Retry:
106
112
  else:
107
113
  return await next(request)
108
114
  except StatusError as exc:
109
- if not method_eligible or exc.response.status_code not in self.retry_status_codes:
115
+ retryable_status = exc.response.status_code in self.retry_status_codes
116
+ if not method_eligible or not retryable_status:
117
+ if retryable_status and request.extensions.get(STREAMING_BODY_MARKER):
118
+ exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
110
119
  raise
111
120
  last_exc = exc
112
121
  last_response = exc.response
113
122
  except (NetworkError, TimeoutError) as exc:
114
123
  if not method_eligible:
124
+ if request.extensions.get(STREAMING_BODY_MARKER):
125
+ exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
115
126
  raise
116
127
  last_exc = exc
117
128
  last_response = None
@@ -119,19 +130,64 @@ class Retry:
119
130
  wrapped = TimeoutError("attempt timed out")
120
131
  wrapped.__cause__ = exc # set now; the retry path (last_exc = wrapped) has no `from` clause
121
132
  if not method_eligible:
133
+ if request.extensions.get(STREAMING_BODY_MARKER):
134
+ wrapped.add_note(_STREAMING_BODY_REFUSAL_NOTE)
122
135
  raise wrapped from exc
123
136
  last_exc = wrapped
124
137
  last_response = None
125
138
 
126
139
  # ---- retryable failure path
140
+ if request.extensions.get(STREAMING_BODY_MARKER):
141
+ if last_exc is None: # pragma: no cover — invariant from except branch
142
+ msg = "Retry: streaming-body refusal reached with no last_exc"
143
+ raise AssertionError(msg)
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
+ )
156
+ raise last_exc
157
+
127
158
  if is_last:
128
159
  if last_exc is None: # pragma: no cover — structural invariant from except branch
129
160
  msg = "Retry: last_exc unset on final attempt — unreachable"
130
161
  raise AssertionError(msg)
131
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
+ )
132
176
  raise last_exc
133
177
 
134
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
+ )
135
191
  raise RetryBudgetExhaustedError(
136
192
  last_response=last_response,
137
193
  last_exception=last_exc,
File without changes