Skip to content

Commit eab6d22

Browse files
lesnik512claude
andcommitted
docs: promote response-body cap into architecture; retire deferred item
Rewrite architecture/client.md 'Bounded response bodies' and the errors.md ResponseTooLargeError entry for the status-agnostic decoded-byte cap; remove the actioned deferred item; add the 0.15.0 release note; check in the change bundle (design.md + plan.md). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 43a97dc commit eab6d22

6 files changed

Lines changed: 602 additions & 8 deletions

File tree

architecture/client.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,15 @@ Client(httpx2_client=httpx2.Client(trust_env=False))
3131
AsyncClient(httpx2_client=httpx2.AsyncClient(trust_env=False))
3232
```
3333

34-
## Bounded error bodies (`max_error_body_bytes`)
34+
## Bounded response bodies (`max_response_body_bytes`)
3535

36-
Both `Client` and `AsyncClient` accept `max_error_body_bytes: int | None = None`. The default (`None`) is backward-compatible: error bodies are read without a size limit.
36+
Both `Client` and `AsyncClient` accept `max_response_body_bytes: int | None = None`. The default (`None`) is unbounded; a non-`None` value below `1` is rejected with `ValueError` at construction. The cap is **status-agnostic** (a `200` trips it the same as a `500`) and counts **decoded** bytes — the actual in-memory footprint, and the only measure that catches a compression bomb (a 133-byte gzip body decoding to 100 KB).
3737

38-
When set, `stream()` raises `ResponseTooLargeError` on a 4xx/5xx response whose declared `Content-Length` header exceeds the cap — before the body is read. Responses without a declared `Content-Length` (chunked transfer) are still read unbounded: a hard mid-read cap would require httpx2 private API, which this project forbids.
38+
The cap bounds memory that httpware buffers on your behalf, at two sites:
39+
40+
- **The non-streaming terminal** (`send()` and the per-verb helpers). When a cap is set, the terminal switches from `httpx2.send(request)` to `send(request, stream=True)` and accumulates decoded bytes through the shared `_read_capped` helper, failing fast with `ResponseTooLargeError` the moment the cap is crossed. When the cap is `None`, the terminal keeps the plain buffered `send()` fast path — zero streaming overhead.
41+
- **`stream()`'s internal error pre-read** — the 4xx/5xx body httpware reads so `exc.response.content` works is routed through the same `_read_capped`. **User-driven `stream()` iteration is never capped** — you chose streaming to own that memory.
42+
43+
The declared `Content-Length` is used only as an *early reject* (if even the compressed size already exceeds the cap, fail before reading a byte); it is never an early accept, so the accumulator always runs — chunked and bomb bodies are caught, not waved through. `ResponseTooLargeError.reason` is `"declared"` or `"streamed"` accordingly. Entirely public httpx2 API — no private access.
44+
45+
**Caveat:** on the capped path the buffered response is rebuilt via the public `httpx2.Response(content=...)` constructor, which does not carry `.elapsed` (httpx2 only sets it on its own buffered `send()`). Clients that set a cap and read `response.elapsed` will find it absent; the `None`-cap fast path preserves it.

architecture/errors.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ The error-mapping table (what `httpx2` exception maps to which `httpware` except
1818

1919
The "no `__init__` override" rule scopes only to `StatusError` subclasses. Non-status `ClientError` subclasses — `DecodeError`, `MissingDecoderError`, `BulkheadFullError`, `RetryBudgetExhaustedError`, `CircuitOpenError`, `ResponseTooLargeError` — deliberately define `__init__` with keyword-only fields.
2020

21-
`ResponseTooLargeError` is raised from `stream()` when `max_error_body_bytes` is set and a 4xx/5xx response's declared `Content-Length` exceeds the cap. It is a non-status `ClientError`; it does not carry a `StatusError`-style positional `response` and is not in `STATUS_TO_EXCEPTION`.
21+
`ResponseTooLargeError` is raised when `max_response_body_bytes` is set and a response body would exceed the cap — status-agnostic (a `200` can trip it), counting **decoded** bytes. It fires from the non-streaming terminal (`send()`) and from `stream()`'s internal error pre-read; user-driven `stream()` iteration is never capped. The `reason` field discriminates the two trip modes: `"declared"` (the declared `Content-Length` already exceeds the cap, rejected before any byte is read — `content_length` holds it) and `"streamed"` (the decoded body crossed the cap mid-read, the chunked or compression-bomb case, where the true size is unknown by design). It is a non-status `ClientError`; it does not carry a `StatusError`-style positional `response` and is not in `STATUS_TO_EXCEPTION`. Because it is neither a `StatusError`, `NetworkError`, nor `TimeoutError`, it is not retried and does not count toward the circuit breaker.
2222

2323
## Security: request headers are reachable via `exc.response.request`
2424

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
---
2+
status: draft
3+
date: 2026-06-23
4+
slug: response-body-cap
5+
summary: Replace error-only max_error_body_bytes with a status-agnostic, decoded-byte max_response_body_bytes cap enforced by a streaming capped-accumulator terminal. Fill at ship time.
6+
supersedes: null
7+
superseded_by: null
8+
pr: null
9+
outcome: null
10+
---
11+
12+
# Design: Status-agnostic response-body cap
13+
14+
## Summary
15+
16+
Replace the shipped `max_error_body_bytes` knob with a status-agnostic
17+
`max_response_body_bytes` cap that actually bounds memory on the non-streaming
18+
path. Today the cap only fires inside `stream()`, only on 4xx/5xx, and only as a
19+
declared-`Content-Length` pre-check — so a non-streaming `send()` buffers the
20+
whole body before httpware ever gets control, and even `stream()` reads chunked
21+
or compression-bombed error bodies unbounded. The new design routes both the
22+
internal terminal and `stream()`'s error pre-read through a single shared
23+
`_read_capped` helper that streams the response, accumulates **decoded** bytes
24+
against the cap, and fails fast with `ResponseTooLargeError` the moment the cap
25+
is crossed. Entirely public httpx2 API — no `httpx2._`. Off by default (`None`).
26+
27+
## Motivation
28+
29+
The 2026-06-14 deep audit flagged (Medium) that `max_error_body_bytes` is not a
30+
real cap: for a non-streaming `send()`, `httpx2.Client.send(request)` buffers the
31+
entire body into memory before httpware reaches the decode seam, so there is no
32+
enforcement point at all on the hot path. The existing guard lives only at
33+
`stream()` entry and only rejects when `Content-Length` is declared.
34+
35+
Two concrete holes:
36+
37+
1. **The success path is unprotected and is the larger surface.** A typed
38+
`send(response_model=X)` against a `200` with a multi-GB body exhausts the
39+
heap. Memory exhaustion has no status code; an error-only cap bolts the
40+
smaller door and leaves the bigger one open.
41+
2. **Compression bombs defeat the `Content-Length` pre-check.** Verified: a
42+
133-byte gzip body decodes to 100,000 bytes (`aiter_bytes()` yields the
43+
*decoded* stream; `Content-Length` reports the *compressed* 133). Real bombs
44+
run ~1000:1. A header pre-check waves these straight through.
45+
46+
Feasibility — the reason this was deferred — is resolved: the audit feared a
47+
true mid-read cap needs httpx2 private API. It does not. `httpx2.{Async,}Client`
48+
expose `send(request, stream=True)`, `Response.aiter_bytes()/iter_bytes()`, and a
49+
public `Response(content=...)` constructor. That is the whole mechanism.
50+
51+
## Non-goals
52+
53+
- **A request-body cap.** This bounds response bodies only.
54+
- **Capping user-driven `stream()` iteration.** When the caller iterates chunks
55+
themselves they own the memory; capping it would defeat `stream()`.
56+
- **A general per-connection limit.** That is httpx2's `limits`; orthogonal.
57+
- **Reporting the true oversized body size.** When the accumulator trips we stop
58+
at the first chunk over the line and do not know (and will not fabricate) the
59+
total.
60+
- **Preserving `.elapsed` on the capped path.** See Risk; an inherent cost of
61+
rebuilding the `Response` via public API.
62+
63+
## Design
64+
65+
### 1. One knob: `max_response_body_bytes` (replaces `max_error_body_bytes`)
66+
67+
Both `Client` and `AsyncClient` take `max_response_body_bytes: int | None = None`.
68+
`None` (default) is unbounded — backward-compatible behavior. The old
69+
`max_error_body_bytes` is **deleted outright** (no deprecation shim — acceptable
70+
pre-1.0). Construction validates `>= 1` and raises
71+
`ValueError("max_response_body_bytes must be >= 1")`, matching the
72+
`failure_threshold` idiom in `circuit_breaker.py`. `0`/negative are rejected;
73+
`None` is the only way to disable.
74+
75+
### 2. The cap counts decoded bytes; `Content-Length` is early-reject only
76+
77+
The accumulator counts what `aiter_bytes()` yields (decoded / decompressed),
78+
because decoded size is the actual memory footprint and is the only thing that
79+
stops a compression bomb. The declared `Content-Length` header (the *compressed*
80+
size) is used **only** as an early reject — if even the compressed size already
81+
exceeds the cap, the decoded body certainly will, so we fail before reading a
82+
byte. It is **never** an early accept: a small/absent `Content-Length` says
83+
nothing about decoded size, so the accumulator always runs regardless.
84+
85+
### 3. Shared capped reader + pure accumulator core
86+
87+
A pure core, trivially property-testable:
88+
89+
```python
90+
def _accumulate_capped(chunks: Iterable[bytes], cap: int) -> bytes:
91+
buf = bytearray()
92+
for chunk in chunks:
93+
buf += chunk
94+
if len(buf) > cap:
95+
raise _CapExceeded(read=len(buf)) # internal signal
96+
return bytes(buf)
97+
```
98+
99+
`bytearray` grown in place (no transient list + `b"".join` double allocation),
100+
one `bytes()` at the end. A sync `_read_capped` and async `_read_capped_async`
101+
wrap it with the early reject and the `Response` rebuild:
102+
103+
```python
104+
async def _read_capped_async(response, cap, request) -> httpx2.Response:
105+
cl = _parse_content_length(response.headers.get("content-length"))
106+
if cl is not None and cl > cap:
107+
raise ResponseTooLargeError(status_code=response.status_code, limit=cap,
108+
content_length=cl, reason="declared")
109+
try:
110+
content = _accumulate_capped_sync_over(response.aiter_bytes(), cap) # async variant
111+
except _CapExceeded:
112+
raise ResponseTooLargeError(status_code=response.status_code, limit=cap,
113+
content_length=cl, reason="streamed")
114+
return httpx2.Response(status_code=response.status_code, headers=response.headers,
115+
content=content, request=request,
116+
extensions=_safe_extensions(response.extensions),
117+
history=response.history)
118+
```
119+
120+
`_read_capped` takes a *Response*, not a client — so it is agnostic to whether
121+
the response came from the request-based terminal `send(stream=True)` or
122+
`stream()`'s method+url path. It never closes the stream; the caller owns
123+
lifecycle. `_safe_extensions` copies `http_version`/`reason_phrase` and drops the
124+
now-stale `network_stream` (the buffered Response never uses it).
125+
126+
### 4. Terminal: branch on `cap is None`
127+
128+
`_terminal` keeps the plain fast path when the cap is off, so non-cap users pay
129+
zero streaming overhead and keep `.elapsed`. Only when a cap is set does it
130+
stream and route through `_read_capped`, owning the stream lifecycle:
131+
132+
```python
133+
async def _terminal(self, request):
134+
async with _httpx2_exception_mapper():
135+
if self._max_response_body_bytes is None:
136+
response = await self._httpx2_client.send(request) # unchanged fast path
137+
else:
138+
resp = await self._httpx2_client.send(request, stream=True)
139+
try:
140+
response = await _read_capped_async(resp, self._max_response_body_bytes, request)
141+
finally:
142+
await resp.aclose()
143+
_raise_on_status_error(response)
144+
return response
145+
```
146+
147+
### 5. `stream()`: error pre-read routed through the same helper
148+
149+
`stream()`'s existing 4xx/5xx pre-read (`await response.aread()`, guarded today
150+
by the `Content-Length`-only check) is replaced by `_read_capped`. The user-driven
151+
success path is untouched. This is the only place `stream()` itself buffers, so
152+
the cap reaches it there and nowhere else; `exc.response.content` still works,
153+
now bounded, and chunked/bombed error bodies are caught instead of waved through.
154+
155+
### 6. `ResponseTooLargeError` gains an explicit `reason`
156+
157+
Status-agnostic now (`status_code` can be `200`). Two trip modes carry different
158+
information, so the discriminator is explicit rather than inferred:
159+
160+
- `limit: int` — the cap (always known).
161+
- `status_code: int` — always known; distinguishes a 200 trip from a 5xx.
162+
- `content_length: int | None` — the server's *declared* header, nullable,
163+
informational only.
164+
- `reason: typing.Literal["declared", "streamed"]``"declared"` = early reject
165+
on `Content-Length`; `"streamed"` = accumulator crossed the cap (the
166+
bomb/chunked case). No `bytes_read`/"actual size" — never measured, never
167+
fabricated.
168+
169+
Stays a non-status `ClientError` with the existing `__init__` + `__reduce__`
170+
(per the `errors.md` rule). Message reads correctly per mode.
171+
172+
### 7. Resilience interaction (falls out of the hierarchy, no special-casing)
173+
174+
Because `ResponseTooLargeError` is a `ClientError` (not
175+
`StatusError`/`NetworkError`/`TimeoutError`):
176+
177+
- **Retry** (`_RETRYABLE_EXCEPTIONS`): not retryable — an over-cap body recurs;
178+
retrying wastes bandwidth.
179+
- **Circuit breaker**: not a counted failure — hits `except BaseException`, slot
180+
released, neither success nor failure recorded. Cannot trip the breaker.
181+
- **Bulkhead**: releases its slot normally.
182+
183+
**Cap-wins / fail-hard:** an otherwise-retryable 5xx whose body exceeds the cap
184+
trips `_read_capped` before status classification, so it surfaces as
185+
`ResponseTooLargeError` (non-retryable) rather than the `StatusError`. Accepted:
186+
the cap is a hard memory-safety limit, retrying would re-fetch the same giant
187+
body, and producing the `StatusError` would require the very buffering we are
188+
refusing. A pathological case (transient error carrying a multi-GB body); a user
189+
who sets a cap is explicitly refusing it.
190+
191+
## Testing
192+
193+
- **Pure core — Hypothesis** (`tests/test_capped_read_props.py`): over arbitrary
194+
chunk partitions of a body × arbitrary cap, `_accumulate_capped` raises iff
195+
`len(body) > cap` and returns `body` byte-for-byte otherwise (chunk-boundary
196+
independence — the one subtle invariant).
197+
- **Integration (`MockTransport`, sync + async parity):** within-cap passes;
198+
exactly-at-cap passes (boundary); declared `Content-Length` over cap →
199+
`reason="declared"`, zero bytes read; chunked / no `Content-Length` over cap →
200+
`reason="streamed"`; gzip bomb (133 → 100 K) → `reason="streamed"`;
201+
empty/204/HEAD pass; `ValueError` on `cap < 1`.
202+
- **Resilience:** retry does not retry a `ResponseTooLargeError`; breaker does
203+
not trip; an over-cap retryable 5xx surfaces as `ResponseTooLargeError`.
204+
- **`stream()`:** error pre-read is bounded (declared + streamed); user-driven
205+
success streaming is never capped.
206+
- `just lint && just test` green; coverage preserved.
207+
208+
## Risk
209+
210+
- **`.elapsed` dropped on the capped path** (likely × low). Rebuilding the
211+
`Response` via public API loses `.elapsed`, which httpx2 only sets on its own
212+
buffered send. Only affects clients that set a cap *and* read `.elapsed`.
213+
Mitigation: the `cap is None` fast path preserves it for everyone else;
214+
document the caveat in `architecture/client.md`.
215+
- **Breaking removal of `max_error_body_bytes`** (certain × low). A shipped,
216+
exported, documented param disappears. Acceptable pre-1.0; called out in
217+
release notes. No silent behavior change — the name is gone, construction
218+
fails loudly if still passed.
219+
- **Stale `extensions` on the rebuilt Response** (unlikely × low). Mitigated by
220+
`_safe_extensions` dropping `network_stream`.
221+
- **Streaming-path overhead vs `send()`** (certain × low). Only paid when a cap
222+
is set; the fast path is untouched.
223+
224+
## Operations
225+
226+
None — no out-of-repo steps.
227+
228+
## Out of scope
229+
230+
- Deprecation shim for `max_error_body_bytes` (deleted, not aliased).
231+
- Request-body caps, per-connection limits, capping user-driven `stream()`.

0 commit comments

Comments
 (0)