|
| 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