fix: handle OpenAI response decoding errors gracefully#51
fix: handle OpenAI response decoding errors gracefully#51withsivram wants to merge 1 commit intochopratejas:mainfrom
Conversation
…ejas#45) When a client like Codex GUI sends Accept-Encoding headers with values httpx doesn't support (e.g. zstd), the upstream API may respond with compressed data that cannot be decoded, causing a UnicodeDecodeError crash in json.loads() via Python's detect_encoding(). Root cause: The proxy forwarded the client's Accept-Encoding header verbatim to upstream APIs. If the client advertised zstd support but httpx doesn't handle zstd decompression, the raw compressed bytes would reach json.loads() which fails on non-UTF-8 binary data. Fix: - Add _prepare_forwarding_headers() helper that strips accept-encoding and transfer-encoding (hop-by-hop headers) from forwarded requests, letting httpx negotiate its own supported encodings - Replace all 16 manual header-stripping sites with the new helper - Add UnicodeDecodeError to except clauses on all response.json() calls as a defense-in-depth measure - Use errors='replace' for raw content decoding of batch results Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
There was a problem hiding this comment.
Pull request overview
This PR updates the Headroom proxy’s upstream request forwarding to avoid propagating client-side compression preferences that can lead to undecodable upstream responses (notably when clients advertise zstd), and adds defensive decoding/error handling plus regression tests tied to issue #45.
Changes:
- Added a centralized
_prepare_forwarding_headers()helper and replaced repeated per-handler header filtering with this helper. - Stripped
accept-encoding/transfer-encoding(plus existinghost/content-lengthstripping) from forwarded requests to prevent upstream compression mismatches. - Added tests for the forwarding-header behavior and for the “compressed bytes reaching
json.loads” failure mode.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
headroom/proxy/server.py |
Introduces _prepare_forwarding_headers() and uses it across request handlers; expands JSON parsing exception handling and makes JSONL decoding more robust. |
tests/test_proxy_compression_headers.py |
Adds tests covering the new forwarding-header helper and demonstrating decode failures from compressed/binary payloads. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Strips hop-by-hop headers and accept-encoding to prevent | ||
| content-encoding mismatches. When a client (e.g. Codex GUI) sends | ||
| Accept-Encoding values the proxy's HTTP client doesn't support | ||
| (like zstd), the upstream may return compressed data that cannot | ||
| be decoded, causing UnicodeDecodeError crashes. | ||
|
|
||
| By removing accept-encoding, httpx negotiates its own supported | ||
| encodings with the upstream API. | ||
| """ | ||
| headers = dict(request.headers.items()) | ||
| headers.pop("host", None) | ||
| headers.pop("content-length", None) | ||
| headers.pop("accept-encoding", None) | ||
| headers.pop("transfer-encoding", None) | ||
| return headers |
There was a problem hiding this comment.
_prepare_forwarding_headers() claims to strip hop-by-hop headers, but it only removes host/content-length/accept-encoding/transfer-encoding. Per RFC 7230, hop-by-hop headers include (at least) connection, keep-alive, proxy-authenticate, proxy-authorization, te, trailer, and upgrade, and any headers listed in the Connection header should also be stripped. Consider expanding this helper to remove the full set (and any Connection-nominated headers) to avoid forwarding hop-by-hop semantics upstream.
| headers = HeadroomProxy._prepare_forwarding_headers(request) | ||
|
|
||
| assert "transfer-encoding" not in headers | ||
|
|
There was a problem hiding this comment.
The new tests cover stripping accept-encoding/host/content-length/transfer-encoding, but there’s no coverage for other hop-by-hop headers (e.g., Connection/Keep-Alive/Upgrade/TE/Trailer/Proxy-Authorization) that a forwarding helper typically must drop. If _prepare_forwarding_headers is expanded to strip these, add a focused test case here to lock the behavior in.
| def test_strips_other_hop_by_hop_headers(self): | |
| """Other hop-by-hop headers must also be stripped when forwarding.""" | |
| request = self._make_mock_request( | |
| { | |
| "connection": "keep-alive, upgrade, te, trailer", | |
| "keep-alive": "timeout=5", | |
| "upgrade": "websocket", | |
| "te": "trailers", | |
| "trailer": "X-Custom-Trailer", | |
| "proxy-authorization": "Basic abc123", | |
| "content-type": "application/json", | |
| "authorization": "Bearer preserved", | |
| } | |
| ) | |
| headers = HeadroomProxy._prepare_forwarding_headers(request) | |
| assert "connection" not in headers | |
| assert "keep-alive" not in headers | |
| assert "upgrade" not in headers | |
| assert "te" not in headers | |
| assert "trailer" not in headers | |
| assert "proxy-authorization" not in headers | |
| # Non hop-by-hop / sensitive headers should remain | |
| assert headers["content-type"] == "application/json" | |
| assert headers["authorization"] == "Bearer preserved" |
|
Thanks @withsivram but after testing this doesn't work for me yet with Codex GUI. |
|
I spent quite some time on Codex GUI support but it looks like it will not be supported for now as Codex GUI uses some undocumented version of websockets that we cannot integrate with. |
Summary
Accept-Encodingheader verbatim to upstream APIs. When Codex GUI sendsAccept-Encoding: gzip, deflate, br, zstdbut httpx doesn't support zstd decompression, the upstream responds with zstd-compressed data thatjson.loads()cannot decode, crashing withUnicodeDecodeError: 'utf-8' codec can't decode byte 0xb5 in position 1._prepare_forwarding_headers()helper method that stripsaccept-encodingandtransfer-encoding(hop-by-hop headers) from all forwarded requests, letting httpx negotiate its own supported encodings with the upstream API. Replaced all 16 manual header-stripping call sites with this centralized helper.UnicodeDecodeErrortoexceptclauses on allresponse.json()calls so that even if compressed data somehow reaches the JSON parser, it is handled gracefully instead of crashing the proxy. Also switched a rawbytes.decode("utf-8")call to useerrors="replace".Test plan
headroom proxywithOPENAI_BASE_URL=http://127.0.0.1:8787/v1and Codex GUI to confirm no crash on/v1/responsesendpointFixes #45
🤖 Generated with Claude Code