|
| 1 | +# httpware 0.5.0 — Streaming responses |
| 2 | + |
| 3 | +**0.5.0 is additive. No breaking changes.** Code written against 0.4.0 continues to work unchanged. |
| 4 | + |
| 5 | +This release closes Epic 4 by adding `AsyncClient.stream()` for chunked response bodies, and closes two longstanding deferred-work items along the way. |
| 6 | + |
| 7 | +## New features |
| 8 | + |
| 9 | +- **`AsyncClient.stream(method, url, **kwargs)`** — async context manager that yields an `httpx2.Response` with a non-pre-read body. Consume via `response.aiter_bytes()`, `response.aiter_text()`, `response.aiter_lines()`, or `response.aiter_raw()`. Auto-raises `StatusError` subclasses on 4xx/5xx (with the body pre-read so `exc.response.content` works). Bypasses the middleware chain by design — `Retry`, `Bulkhead`, and user-installed middleware do not see `stream()` calls in v1. |
| 10 | +- **`Retry` refuses streamed-body requests.** When you call `client.post(content=async_gen())` (or `data=`, `files=`), the request is marked via `request.extensions["httpware.streaming_body"]`. If `Retry` would otherwise retry on a failure, it re-raises the original exception with a PEP 678 note instead — preventing the "consumed iterator can't replay" footgun. |
| 11 | + |
| 12 | +## Backwards compatibility |
| 13 | + |
| 14 | +Subclassing/extensions preserve every existing catch-block: |
| 15 | + |
| 16 | +- All previously-shipping methods (`get`, `post`, etc.) behave identically. |
| 17 | +- The internal refactor that extracted `_httpx2_exception_mapper` from `_terminal` is byte-for-byte equivalent in dispatch behavior. Tests prove this. |
| 18 | +- The streaming-body marker (`request.extensions["httpware.streaming_body"]`) only affects requests that genuinely have async-iterable bodies. Existing code passing bytes / dict / files-as-bytes is unaffected. |
| 19 | + |
| 20 | +## Usage |
| 21 | + |
| 22 | +```python |
| 23 | +from httpware import AsyncClient |
| 24 | + |
| 25 | + |
| 26 | +async def main() -> None: |
| 27 | + async with AsyncClient(base_url="https://api.example.com") as client: |
| 28 | + async with client.stream("GET", "/big-file") as response: |
| 29 | + async for chunk in response.aiter_bytes(): |
| 30 | + process(chunk) |
| 31 | +``` |
| 32 | + |
| 33 | +Catch typed status errors on streams the same way as on regular calls: |
| 34 | + |
| 35 | +```python |
| 36 | +from httpware import NotFoundError |
| 37 | + |
| 38 | +try: |
| 39 | + async with client.stream("GET", "/maybe-missing") as response: |
| 40 | + ... |
| 41 | +except NotFoundError as exc: |
| 42 | + body_text = exc.response.text # pre-read; accessible |
| 43 | +``` |
| 44 | + |
| 45 | +## What's still ahead |
| 46 | + |
| 47 | +- Epic 5 (observability hooks + OTel middleware) is unstarted; logging of retry / bulkhead / stream decisions plumbs through then. |
| 48 | +- Whether `stream()` should compose with the middleware chain is deferred to real-user feedback. Adding it later is purely additive (`stream(..., apply_middleware: bool = False)` opt-in). |
| 49 | + |
| 50 | +## References |
| 51 | + |
| 52 | +- Spec: [`planning/specs/2026-06-05-streaming-design.md`](../specs/2026-06-05-streaming-design.md) |
| 53 | +- Plan: [`planning/plans/2026-06-05-streaming-plan.md`](../plans/2026-06-05-streaming-plan.md) |
| 54 | +- Roadmap: [`planning/engineering.md`](../engineering.md) §8 |
0 commit comments