httpware 0.4.0__tar.gz → 0.5.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.5.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
@@ -89,6 +89,25 @@ async def main() -> None:
89
89
  user = await client.get("/users/1", response_model=User)
90
90
  ```
91
91
 
92
+ ### Streaming responses
93
+
94
+ For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager:
95
+
96
+ ```python
97
+ from httpware import AsyncClient
98
+
99
+
100
+ async def main() -> None:
101
+ async with AsyncClient(base_url="https://api.example.com") as client:
102
+ async with client.stream("GET", "/big-file") as response:
103
+ async for chunk in response.aiter_bytes():
104
+ process(chunk)
105
+ ```
106
+
107
+ `stream()` auto-raises `StatusError` subclasses on 4xx/5xx with the response body pre-read, so `exc.response.content` is accessible from the caught exception.
108
+
109
+ 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.)
110
+
92
111
  ## Errors
93
112
 
94
113
  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`.
@@ -61,6 +61,25 @@ 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`.
@@ -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.5.0"
30
30
  dependencies = [
31
31
  "httpx2>=2.0.0,<3.0",
32
32
  ]
@@ -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
@@ -16,6 +16,7 @@ from http import HTTPStatus
16
16
 
17
17
  import httpx2
18
18
 
19
+ from httpware.client import STREAMING_BODY_MARKER
19
20
  from httpware.errors import NetworkError, RetryBudgetExhaustedError, StatusError, TimeoutError # noqa: A004
20
21
  from httpware.middleware import Next
21
22
  from httpware.middleware.resilience._backoff import full_jitter_delay
@@ -43,6 +44,7 @@ DEFAULT_IDEMPOTENT_METHODS = frozenset(
43
44
  )
44
45
 
45
46
  _MAX_ATTEMPTS_INVALID = "max_attempts must be >= 1"
47
+ _STREAMING_BODY_REFUSAL_NOTE = "httpware: not retrying — request body is a stream that cannot replay across attempts"
46
48
 
47
49
 
48
50
  def _parse_retry_after(value: str) -> float | None:
@@ -90,7 +92,7 @@ class Retry:
90
92
  self.budget = budget if budget is not None else RetryBudget()
91
93
  self._sleep = _sleep
92
94
 
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
95
+ 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
96
  """Process a request through the retry loop. See module docstring."""
95
97
  method_eligible = request.method.upper() in self.retry_methods
96
98
  last_exc: BaseException | None = None
@@ -106,12 +108,17 @@ class Retry:
106
108
  else:
107
109
  return await next(request)
108
110
  except StatusError as exc:
109
- if not method_eligible or exc.response.status_code not in self.retry_status_codes:
111
+ retryable_status = exc.response.status_code in self.retry_status_codes
112
+ if not method_eligible or not retryable_status:
113
+ if retryable_status and request.extensions.get(STREAMING_BODY_MARKER):
114
+ exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
110
115
  raise
111
116
  last_exc = exc
112
117
  last_response = exc.response
113
118
  except (NetworkError, TimeoutError) as exc:
114
119
  if not method_eligible:
120
+ if request.extensions.get(STREAMING_BODY_MARKER):
121
+ exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
115
122
  raise
116
123
  last_exc = exc
117
124
  last_response = None
@@ -119,11 +126,20 @@ class Retry:
119
126
  wrapped = TimeoutError("attempt timed out")
120
127
  wrapped.__cause__ = exc # set now; the retry path (last_exc = wrapped) has no `from` clause
121
128
  if not method_eligible:
129
+ if request.extensions.get(STREAMING_BODY_MARKER):
130
+ wrapped.add_note(_STREAMING_BODY_REFUSAL_NOTE)
122
131
  raise wrapped from exc
123
132
  last_exc = wrapped
124
133
  last_response = None
125
134
 
126
135
  # ---- retryable failure path
136
+ if request.extensions.get(STREAMING_BODY_MARKER):
137
+ if last_exc is None: # pragma: no cover — invariant from except branch
138
+ msg = "Retry: streaming-body refusal reached with no last_exc"
139
+ raise AssertionError(msg)
140
+ last_exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
141
+ raise last_exc
142
+
127
143
  if is_last:
128
144
  if last_exc is None: # pragma: no cover — structural invariant from except branch
129
145
  msg = "Retry: last_exc unset on final attempt — unreachable"
File without changes