Skip to content

fix(python-runtime): stop double-decoding compressed responses; parse from the structural body - #178

Open
hardbyte wants to merge 4 commits into
mainfrom
brian/issue-177-fix-b06343
Open

fix(python-runtime): stop double-decoding compressed responses; parse from the structural body#178
hardbyte wants to merge 4 commits into
mainfrom
brian/issue-177-fix-b06343

Conversation

@hardbyte

Copy link
Copy Markdown
Contributor

Fixes #177.

Problem

_make_request rebuilt a synthetic httpx.Response from the structural transport response. The structural body is already decompressed (it comes from httpx.Response.content), but the headers still advertised Content-Encoding: gzip, so httpx tried to decompress the decoded JSON a second time:

httpx.DecodingError: Error -3 while decompressing data: incorrect header check
→ reflectapi_runtime.exceptions.NetworkError

Fix

Commit-by-commit:

  1. Targeted fix — strip Content-Encoding/Content-Length when rebuilding the synthetic response, with sync/async/custom-transport regression tests.
  2. Structural refactor (the durable fix) — response parsing no longer round-trips through a rebuilt httpx.Response at all. Error handling, JSON parsing, and Pydantic validation now operate directly on the structural (status, body) via module-level _raise_for_error_status / _parse_json_body / _validate_body, shared by the sync, async, and SSE paths (this also deletes the previously copy-pasted helper methods between ClientBase and AsyncClientBase). A synthetic httpx.Response survives in exactly one place: the TransportMetadata.raw_response fallback for custom transports that don't surface a wire object, with stale headers sanitized. metadata.headers still exposes the original wire headers.
  3. Review follow-ups (from an adversarial review pass) — document the decoded-body contract on transport.Response for custom Client implementers; always drop stale Content-Length in the synthetic (middleware-rewritten bodies make it stale even without compression); idiom cleanups (contextlib.suppress).
  4. Demo test suite repair — three demo test modules had failed collection since codegen(python): drop ReflectapiOption, use model_fields_set for partial fields #151 removed ReflectapiOption; deleted the dead ADT tests and ported the rest to ReflectapiPartialModel semantics.

Behavior preserved (verified during review): error message format (httpx.codes.get_reason_phrase matches reason_phrase for all codes), charset handling (httpx's .json() is json.loads(content) — no charset detection), exception-wrapping semantics, empty-body behavior, and chained/brotli/zstd encodings (the header strip is value-agnostic).

Testing

  • Runtime: 372 passed, 4 skipped. 12 new tests: compressed success/error bodies (sync + async), typed error_model from compressed responses, middleware-rewritten bodies under stale wire headers, non-JSON bodies on both paths, sanitized raw_response fallback (encoding stripped, Content-Length recomputed), wire response preferred over synthetic. 9 of the 12 fail on main, confirming they exercise the fix.
  • Demo client: 173 passed, 16 skipped (live-server integration) — previously 148 with 3 modules failing collection.
  • Standalone repro of the exact Python runtime can double-decode compressed responses #177 error passes; no new ruff findings; mypy on client.py 62 → 56 errors (all remaining pre-existing).

hardbyte added 4 commits July 13, 2026 10:14
The structural Response carries an already-decompressed body (httpx
decodes on .content), but the headers may still advertise
Content-Encoding: gzip. Rebuilding an httpx.Response from that pair made
httpx decompress the decoded bytes a second time, raising
DecodingError -> NetworkError before deserialization.

Strip Content-Encoding (and the now-stale Content-Length) when lifting
the structural body into the synthetic httpx.Response used for parsing.
Metadata headers still reflect the original wire headers.

Fixes #177
…rop synthetic httpx.Response round-trip

Response parsing previously flattened the wire httpx.Response into the
structural DTO, then rebuilt a second httpx.Response purely so error
handling and JSON parsing could call .json() on it. That reconstruction
re-ran httpx's content pipeline on already-decoded bytes — the root
cause of #177's double-decompression — and left parsing exposed to any
header/body inconsistency (e.g. middleware-rewritten bodies with stale
wire headers).

Parsing now operates directly on (status, body): module-level
_raise_for_error_status / _parse_json_body / _validate_body replace the
duplicated per-class helper methods in both sync and async clients, and
the SSE error path feeds the read body through the same function. A
synthetic httpx.Response is now built only as the raw_response metadata
fallback for custom transports that don't surface a wire object, with
compression headers stripped so it stays readable.

Tests cover compressed success and error bodies (sync/async), typed
errors from compressed responses, middleware-rewritten bodies under
stale wire headers, non-JSON bodies on both paths, and the sanitized
raw_response fallback.
…tent-Length in synthetic raw_response

- transport.Response docstring now states body must be the decoded
  entity bytes (the runtime never re-applies Content-Encoding to it) —
  an observable contract for custom Client implementers.
- _synthesize_raw_response drops Content-Length unconditionally, not
  only alongside Content-Encoding: middleware-rewritten bodies make it
  stale too. httpx re-derives an accurate value from the actual body.
- Widen _synthesize_raw_response body hint to match its siblings; use
  contextlib.suppress for the intentional error-body swallows.
PR #151 replaced the ReflectapiOption ADT with ReflectapiPartialModel
(three-state fields tracked via Pydantic's model_fields_set), but three
demo test modules still imported the removed class, failing collection
ever since:

- tests/runtime_integration/test_reflectapi_option.py tested only the
  removed ADT's own methods (unwrap/map/eq/repr); the replacement
  semantics are covered by the runtime's test_partial.py and the demo's
  test_models.py — deleted.
- test_advanced_edge_cases.py: ported the advanced cases to the partial
  model API, updating serialization expectations (unset fields are now
  omitted from the wire, not emitted as null) and adding explicit
  null-vs-absent round-trip coverage.
- test_client_server_e2e.py: plain value instead of the ADT wrapper.

Also updates the runtime README bullet that still described
ReflectapiOption. Demo suite now collects fully: 173 passed,
16 skipped (live-server integration).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@github-actions

Copy link
Copy Markdown

📖 Documentation Preview: https://reflectapi-docs-preview-pr-178.partly.workers.dev

Updated automatically from commit b7ef26c

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.

Python runtime can double-decode compressed responses

1 participant