Skip to content

feat!: status-agnostic response-body cap (max_response_body_bytes)#78

Merged
lesnik512 merged 9 commits into
mainfrom
feat/response-body-cap
Jun 23, 2026
Merged

feat!: status-agnostic response-body cap (max_response_body_bytes)#78
lesnik512 merged 9 commits into
mainfrom
feat/response-body-cap

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Summary

Replaces the error-only max_error_body_bytes knob with a status-agnostic, decoded-byte max_response_body_bytes cap that is actually enforced on the non-streaming send() path and against compression bombs. Entirely public httpx2 API — no httpx2._. Off by default (None).

Closes the 2026-06-14 deep-audit "Non-streaming hard response-body cap" deferred item.

⚠️ Breaking (pre-1.0): max_error_body_bytes is removed with no alias; passing it raises TypeError. Migrate to max_response_body_bytes.

Why

max_error_body_bytes only fired inside stream(), only on 4xx/5xx, and only as a declared-Content-Length pre-check. A non-streaming send() buffered the whole body before httpware got control (no cap at all on the hot path), and a 133-byte gzip body decoding to 100 KB (real bombs ~1000:1) slipped straight past a header check.

What changed

  • Status-agnostic — a 200 is capped the same as a 500; the success path is the larger memory-exhaustion surface.
  • Counts decoded bytes (the in-memory footprint) — the only measure that catches a compression bomb. Content-Length is kept as an early reject only, never an early accept.
  • Enforced via a shared _read_capped accumulator at the non-streaming terminal (send(request, stream=True) + accumulate) and on stream()'s internal error pre-read. User-driven stream() iteration is never capped.
  • Default (None-cap) keeps the plain send() fast path — zero streaming overhead, .elapsed preserved.
  • ResponseTooLargeError gains a reason discriminator: "declared" vs "streamed".
  • Construction validates >= 1 (ValueError).

Semantics

  • ResponseTooLargeError is a non-status ClientError: not retried, does not count toward the circuit breaker.
  • An over-cap retryable 5xx surfaces as ResponseTooLargeError (cap-wins / fail-hard), not the status error.
  • Capped path rebuilds the Response via httpx2.Response(content=...), which has no .elapsed (documented caveat); None-cap path preserves it.

Tests

756 passing, 100% coverage. Adds: Hypothesis property test for chunk-boundary independence of the pure _accumulate_capped core; sync+async unit tests for _read_capped (declared / streamed / gzip-bomb / boundary / empty); terminal send() cap + validation; bounded stream() error pre-read + uncapped user streaming; resilience-interaction locks (no-retry / no-breaker-trip / cap-wins).

Docs

architecture/client.md (Bounded response bodies) and architecture/errors.md (ResponseTooLargeError) rewritten; deferred item retired; planning/releases/0.15.0.md added; design + plan bundle under planning/changes/2026-06-23.03-response-body-cap/.

🤖 Generated with Claude Code

lesnik512 and others added 9 commits June 23, 2026 17:07
Status-agnostic semantics + explicit declared/streamed trip mode, ahead of
the max_response_body_bytes cap rework. Existing stream() Content-Length
checks pass reason="declared"; restructured in a later task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_read_capped / _read_capped_async wrap the pure core with the Content-Length
early-reject and the buffered Response rebuild; _safe_extensions drops the
stale network_stream. Caller owns the stream lifecycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Status-agnostic, decoded-byte cap enforced at the non-streaming terminal via a
streaming capped-accumulator (send(stream=True) + _read_capped). Branches on
cap is None so the default path keeps plain send() and .elapsed. Construction
validates >= 1. The old error-only param is removed (pre-1.0, no shim).

BREAKING CHANGE: max_error_body_bytes is replaced by max_response_body_bytes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both stream() error branches route the 4xx/5xx pre-read through the shared
accumulator when a cap is set, so chunked/compression-bombed error bodies are
caught instead of read unbounded; exc.response.content stays populated.
User-driven success streaming is never capped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cap trips are not retried, do not trip the circuit breaker, and an over-cap
retryable 5xx surfaces as ResponseTooLargeError (cap-wins). No prod change —
these assert the behavior that falls out of the ClientError hierarchy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two bugs in the capped read path found in review of #78:

- The rebuilt Response kept content-encoding while holding already-decoded
  content, so httpx2 re-decompressed and crashed on every compressed body under
  the cap. Strip content-encoding / transfer-encoding / (compressed)
  content-length via _buffered_headers; httpx2 recomputes content-length.
- A bodiless response (HEAD / 204 / 304) with a large declared Content-Length
  was falsely rejected. _response_has_body short-circuits these: read the empty
  body and return the original response unchanged, preserving its headers (HEAD
  legitimately echoes the entity length).

Adds within-cap compressed-body and bodiless regression tests at the
_read_capped, send(), and stream() levels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lesnik512 lesnik512 merged commit 8dcf227 into main Jun 23, 2026
5 checks passed
@lesnik512 lesnik512 deleted the feat/response-body-cap branch June 23, 2026 16:43
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