Skip to content

feat: AsyncClient.stream() + Retry refuses streamed-body requests (0.5.0, Epic 4)#26

Merged
lesnik512 merged 9 commits into
mainfrom
feat/v0.5-streaming
Jun 5, 2026
Merged

feat: AsyncClient.stream() + Retry refuses streamed-body requests (0.5.0, Epic 4)#26
lesnik512 merged 9 commits into
mainfrom
feat/v0.5-streaming

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Summary

Closes Epic 4 (Streaming). Adds `AsyncClient.stream()` for chunked response bodies and closes two longstanding deferred-work items.

  • **`httpware.AsyncClient.stream(method, url, kwargs)` — async context manager yielding an `httpx2.Response` with a non-pre-read body. 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.
  • `Retry` refuses streamed-body requests. When you call `client.post(content=async_gen())` (or `data=`, `files=`), the request is marked via `request.extensions[STREAMING_BODY_MARKER]` (= `"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.
  • Internal refactor: extracted `_httpx2_exception_mapper` (`@asynccontextmanager`) and `_raise_on_status_error` from `_terminal`. Both `_terminal` and `stream()` now share dispatch logic — one source of truth.
  • Closes two deferred-work items: "Retry + streaming bodies (Epic 4 interaction)" and "`httpx2.StreamError` family escape".

Test Plan

  • `just lint-ci` — clean
  • `just test` — 239 passing, 100% line coverage (was 209; +30 new tests)
  • All 5 architecture invariants verified clean (no `httpx2._`, no `from future import annotations`, no `print()`, no global logging, no `# type:`/`# mypy:` ignore)
  • Optional-extras isolation verified — `stream()` is pure stdlib (no new optional deps)
  • `mkdocs build --strict` — 0 warnings
  • `_terminal` refactor preserves byte-for-byte behavior (terminal-mapping tests pass unmodified)
  • Cancellation propagates cleanly through `async with client.stream(...)` body consumption
  • User exceptions in `async with` block propagate unchanged (not caught by exception mapper)

Backwards compatibility

Purely additive:

  • All previously-shipping methods (`get`, `post`, etc.) behave identically.
  • The `_terminal` refactor is byte-for-byte equivalent in dispatch behavior.
  • The streaming-body marker only attaches when `content`/`data`/`files` is genuinely async-iterable. Existing code using bytes / dict / files-as-bytes is unaffected.
  • New public symbol: `httpware.client.STREAMING_BODY_MARKER` (constant). `AsyncClient.stream()` is a new method on the existing public class.

Refs

Closes Epic 4. Next: 0.5.0 release prep (tag + GH release).

🤖 Generated with Claude Code

lesnik512 and others added 9 commits June 5, 2026 18:56
…c 4 story 4-3)

Single-method addition to AsyncClient. Bypasses the middleware chain
(documented rationale: streams don't compose with Retry/Bulkhead/decoder).
Maps httpx2 exceptions raised during request + body consumption to
httpware exceptions, mirroring _terminal's dispatch ordering.

Closes the deferred-work item about httpx2.StreamError-family escape.
The Retry-refuses-streamed-body item stays open as a follow-up PR.

No auto-raise on 4xx/5xx (matches httpx convention; deliberate divergence
from client.get/post/etc. which DO auto-raise — rationale in spec).
No StreamResponse wrapper type. No response_model= parameter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per user pushback in brainstorming:
- Auto-raise on 4xx/5xx for streams (with body pre-read so exc.response.content works).
- Extract shared _httpx2_exception_mapper helper used by _terminal + stream().
- Retry refuses streamed-body requests (closes the deferred-work item).
- Keep middleware bypass for v1 (YAGNI; revisit later).

Detection mechanism for streamed-body refusal: request.extensions
"httpware.streaming_body" marker, set by _request_with_body when
content/data/files is an async-iterable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…refuses-streamed-body (0.5.0)

6 TDD tasks on feat/v0.5-streaming. Task 1 is a pure refactor extracting
_httpx2_exception_mapper + _raise_on_status_error helpers so _terminal
and the new stream() share dispatch logic. Tasks 2-3 wire the
streaming-body marker (in _request_with_body) + Retry's refusal-with-
PEP-678-note. Task 4 adds AsyncClient.stream() with auto-raise + body
pre-read on 4xx/5xx. Task 5 syncs README/docs/index.md/engineering.md/
deferred-work.md + drafts 0.5.0 release notes. Task 6 verifies +
pushes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_error

Pure refactor of _terminal. Two module-level helpers (one @asynccontextmanager
for the httpx2 exception dispatch, one function for the 4xx/5xx StatusError
raise). _terminal now reads as: enter the mapper, send, raise on status.

Sets up Task 4: AsyncClient.stream() will reuse both helpers verbatim
instead of duplicating the dispatch logic. Behavior is byte-for-byte
identical to today; the existing terminal tests cover it.
Adds a _is_streaming_body helper and a marker step in _request_with_body:
when content / data / files is an async-iterable, set
request.extensions['httpware.streaming_body'] = True before sending.

Sets up Task 3: Retry will read the marker and refuse to retry streamed-body
requests (they can't replay across attempts). Today the marker has no
consumer; it's harmless metadata.
Closes the deferred-work item 'Retry + streaming bodies'. When the
request was constructed with an async-iterable content/data/files,
_request_with_body marked request.extensions['httpware.streaming_body']
= True. Retry now reads the marker and re-raises the original failure
with a PEP-678 note ('not retrying — request body is a stream that
cannot replay across attempts') instead of retrying with a consumed
iterator.

Check happens BEFORE budget.try_withdraw() so a refused retry doesn't
consume a budget token.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds AsyncClient.stream(method, url, **kwargs) as a
@contextlib.asynccontextmanager method on the client. Mirrors
httpx2.AsyncClient.stream() but auto-raises StatusError subclasses
on 4xx/5xx (consistent with client.get/post/etc.) with body
pre-read so exc.response.content is accessible.

Bypasses the middleware chain (v1 design decision — revisit if user
feedback warrants). Uses the shared _httpx2_exception_mapper and
_raise_on_status_error helpers extracted in the earlier refactor
commit, so dispatch logic stays in lockstep with _terminal.

Body consumption errors during 'async for chunk in response.aiter_bytes()'
propagate through the yield and get mapped to httpware exceptions
consistently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- README + docs/index.md: add 'Streaming responses' subsection
- planning/engineering.md §1 + §8: mention stream() in project intent;
  mark Epic 4 SHIPPED in roadmap
- planning/deferred-work.md: close the 'Retry + streaming bodies' open
  item and update the v0.2-pivot StreamError-escape entry; add a new
  'Closed by the 0.5.0 streaming release' section
- planning/releases/0.5.0.md: new release notes
…to module constants

Final-review feedback: the "httpware.streaming_body" marker key was
duplicated at 5 sites (1 write in client.py + 4 reads in retry.py) and
the PEP-678 refusal note was duplicated at 4 sites in retry.py. Per
project convention (module-level UPPER_CASE constants over inlined
string literals; same pattern as _MAX_ATTEMPTS_INVALID,
_MAX_CONCURRENT_INVALID, _DEFAULT_DECODER_MISSING_MESSAGE), hoist both:

- STREAMING_BODY_MARKER in client.py (public so retry.py can import it;
  this IS the contract between the two modules)
- _STREAMING_BODY_REFUSAL_NOTE in retry.py (private; only used there)

retry.py now imports STREAMING_BODY_MARKER from client. No circular
import: client doesn't import retry transitively.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lesnik512 lesnik512 self-assigned this Jun 5, 2026
@lesnik512 lesnik512 merged commit ec373a7 into main Jun 5, 2026
5 checks passed
@lesnik512 lesnik512 deleted the feat/v0.5-streaming branch June 5, 2026 17:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant