Skip to content

fix: handle OpenAI response decoding errors gracefully#51

Open
withsivram wants to merge 1 commit intochopratejas:mainfrom
withsivram:fix/openai-decode-crash-issue-45
Open

fix: handle OpenAI response decoding errors gracefully#51
withsivram wants to merge 1 commit intochopratejas:mainfrom
withsivram:fix/openai-decode-crash-issue-45

Conversation

@withsivram
Copy link
Copy Markdown

Summary

  • Root cause: The proxy forwarded the client's Accept-Encoding header verbatim to upstream APIs. When Codex GUI sends Accept-Encoding: gzip, deflate, br, zstd but httpx doesn't support zstd decompression, the upstream responds with zstd-compressed data that json.loads() cannot decode, crashing with UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb5 in position 1.
  • Primary fix: Added _prepare_forwarding_headers() helper method that strips accept-encoding and transfer-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.
  • Defense-in-depth: Added UnicodeDecodeError to except clauses on all response.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 raw bytes.decode("utf-8") call to use errors="replace".
  • Tests: Added 10 new tests covering the header stripping behavior, Codex GUI header simulation, and compressed data decode error scenarios.

Test plan

  • All 2332 non-integration unit tests pass (0 failures)
  • 16 compression header tests pass (6 existing + 10 new)
  • Manual test: Run headroom proxy with OPENAI_BASE_URL=http://127.0.0.1:8787/v1 and Codex GUI to confirm no crash on /v1/responses endpoint
  • Manual test: Verify streaming responses from OpenAI Responses API work correctly through the proxy
  • Manual test: Verify non-streaming responses still return correct JSON

Fixes #45

🤖 Generated with Claude Code

…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]>
Copilot AI review requested due to automatic review settings March 20, 2026 16:51
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 existing host/content-length stripping) 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.

Comment thread headroom/proxy/server.py
Comment on lines +1975 to +1989
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
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

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

Copilot uses AI. Check for mistakes.
headers = HeadroomProxy._prepare_forwarding_headers(request)

assert "transfer-encoding" not in headers

Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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"

Copilot uses AI. Check for mistakes.
@gglucass
Copy link
Copy Markdown
Contributor

gglucass commented Mar 23, 2026

Thanks @withsivram but after testing this doesn't work for me yet with Codex GUI.

@gglucass
Copy link
Copy Markdown
Contributor

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.

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.

[BUG] Headroom crashes when decoding Open AI data when used with Codex GUI

3 participants