Skip to content

fix: don't forward stale Content-Encoding/Content-Length to the client - #1

Open
IntenZe1337 wants to merge 1 commit into
jodavan:mainfrom
IntenZe1337:fix/content-encoding-passthrough
Open

fix: don't forward stale Content-Encoding/Content-Length to the client#1
IntenZe1337 wants to merge 1 commit into
jodavan:mainfrom
IntenZe1337:fix/content-encoding-passthrough

Conversation

@IntenZe1337

Copy link
Copy Markdown

Problem

Non-streaming responses are returned with the upstream headers copied verbatim:

return Response(content=response.content, status_code=..., headers=dict(response.headers))

httpx transparently decompresses the response body, so by the time we build that
Response the content is already plaintext. But dict(response.headers) still carries
the upstream Content-Encoding: gzip and the compressed Content-Length — headers
that describe a body which no longer exists.

Any client that sends Accept-Encoding: gzip then tries to gunzip plaintext and fails.

Reproduction

$ curl --compressed -sS http://localhost:8082/v1/messages \
    -H 'content-type: application/json' \
    -H 'x-api-key: dummy' \
    -H 'anthropic-version: 2023-06-01' \
    -d '{"model":"claude-3-5-haiku-20241022","max_tokens":16,
         "messages":[{"role":"user","content":"hi"}]}'
curl: (61) Error while processing content unencoding: invalid stored block lengths

CURLE_BAD_CONTENT_ENCODING, exit code 61. Without --compressed the same request
succeeds, which is what makes this easy to miss during manual testing — but most real
HTTP clients negotiate gzip by default.

Fix

Strip the transport-describing / hop-by-hop headers before forwarding and let the ASGI
server compute correct values for the body actually sent:

HOP_BY_HOP_HEADERS = frozenset({
    "content-encoding", "content-length", "transfer-encoding", "connection", "keep-alive",
})

def passthrough_headers(headers) -> dict:
    return {k: v for k, v in headers.items() if k.lower() not in HOP_BY_HOP_HEADERS}

Applied to the two non-streaming return paths: /v1/messages and
/v1/messages/count_tokens. Streaming responses build their headers explicitly and were
never affected.

connection, keep-alive and transfer-encoding are included because they are
per-connection headers that must not be relayed across a proxy hop (RFC 9110 §7.6.1);
they were not the cause of the observed failure but belong in the same set.

Scope

20 lines added, 2 changed, proxy.py only. No behaviour change for clients that do not
request compression.

httpx transparently decompresses the upstream response body, so by the time
we build the Response the body is plaintext. Forwarding the upstream headers
verbatim via dict(response.headers) keeps Content-Encoding: gzip and the
compressed Content-Length, which describe a body that no longer exists.

Any client that sends Accept-Encoding: gzip then tries to gunzip plaintext
and fails. With curl this is CURLE_BAD_CONTENT_ENCODING (exit 61):

    $ curl --compressed -sS http://localhost:8082/v1/messages \
        -H 'content-type: application/json' \
        -H 'x-api-key: ...' -H 'anthropic-version: 2023-06-01' \
        -d '{"model":"...","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'
    curl: (61) Error while processing content unencoding: invalid stored block lengths

Strip the hop-by-hop / transport-describing headers and let the ASGI server
compute correct values for the body actually sent. Streaming responses were
never affected; this covers the two non-streaming return paths
(/v1/messages and /v1/messages/count_tokens).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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