Repository navigation
Conversation
|
All contributors have signed the CLA ✍️ ✅ |
|
I have read the CLA Document and I hereby sign the CLA |
|
I am not sure if we want to make this as a default behavior. Similar to what I commented in #170, users should be aware that the Cloudflare Workers can perform the compression and make sure they do not double encode them. I would be happy to make this configurable, so that users can set |
|
Thanks! I’m happy to make this configurable while preserving the current automatic behavior by default. My use case is Connect RPC, which emits already-compressed response bytes with the corresponding Would you like me to update this PR with an |
Let me open a PR including some of your tests. As you mentioned, in this age of agentic coding, that would make the process faster. |
Awesome, I'm looking forward to the fix! |
Summary
Preserve the response bytes produced by an ASGI application when converting its response into a Workers Fetch
Response. If the application supplies gzip-compressed bytes, the bridge must not gzip those bytes again.This adds
encodeBody="manual"to the two constructors that carry ASGI response bodies:TransformStreamresponse used for streaming.The null-body constructor is unchanged. There is no public API change, dependency addition, request-header modification, or client workaround.
Problem and expected behavior
An ASGI application can emit gzip-compressed bytes together with
Content-Encoding: gzip. For example, Starlette'sGZipMiddlewareand Connect for Python unary (single-request, single-response) RPC responses already perform this encoding before sending ASGI body events.The current bridge passes those bytes and headers to Fetch
Responseconstructors without an encoding option. Workers defaults to automatic encoding, so the already-compressed bytes can be compressed a second time. A client that decodes the declared HTTP encoding once receives bytes that are still gzip-compressed rather than the application's original payload.Cloudflare explicitly documents manual mode for pre-compressed bodies:
Reference: https://developers.cloudflare.com/workers/runtime-apis/response/#the-encodebody-option
The intended ownership boundary is:
Relationship to withdrawn PR #170
#170 addressed the same double-compression symptom by stripping
accept-encodingfrom the ASGI request scope. The discussion questioned whether the SDK should suppress application compression, and the author withdrew that approach as doing too much at the SDK layer.This proposal takes a different approach:
The problem is not limited to optional FastAPI middleware: an ASGI protocol implementation can produce encoded bytes as part of its normal response handling. Disabling middleware does not address that general forwarding contract.
Minimal reproduction
Mount this plain ASGI app through
workers.asgi.entrypoint:Request it with
Accept-Encoding: gzip, capture the raw body with a client that does not automatically decode it, and apply exactly one gzip decode. The expected result isPAYLOAD, not bytes that are still gzip-compressed after one decode. The regression fixtures additionally cover the streamed form of this response and uncompressed (identity) control responses.Why the regression crosses an HTTP socket
The existing
SELF.fetchservice-binding tests do not exercise HTTP response serialization. They can preserve the application's body bytes in-process even when those bytes would be encoded incorrectly at the HTTP boundary.The ASGI workerd config therefore adds an external HTTP service and a loopback HTTP socket back to the same Worker. The route is test client -> loopback HTTP socket -> ASGI Worker -> serialized HTTP response, rather than test client -> in-process service binding -> Worker. The tests fetch through that real HTTP boundary, then assert the consumer-decoded bytes exactly equal the original payload.
The four cases cover:
identity);identity).The streaming fixture splits one gzip member inside its header and before its footer, then sends an empty terminal ASGI body without Content-Length. It does not independently compress each chunk. Status, content type, content encoding, Vary, and a custom response header are also checked. Content-Length is not asserted to survive Fetch unchanged: the runtime controls length/framing, especially for streamed bodies.
(There is no mocked Response, monkeypatched encoding path, or source-text assertion: the regression exercises the runtime API and actual HTTP serialization.)
Test Plan and observed results
Commands run from
packages/runtime-sdkunless otherwise noted. Baseline source was upstream commitc6e08350b51f5c0f18c747012e960fef55b69a96(SDK 1.9.1).Causal regression proof
uv run pytest 'tests/test_in_workerd.py::test_in_workerd[asgi-3.13]' -vvWith both manual encoding options removed, the finalized in-worker suite reports 23 passed, 2 failed: precisely the buffered/streaming gzip payload assertions. Both uncompressed controls pass. Restoring the options makes the suite pass.
Supported runtime variants
uv run pytest tests/test_in_workerd.py -k 'asgi and not disconnect' -vvAll three host pytest cases pass, covering Worker Python 3.12, 3.13, and 3.14. Each case runs the ASGI tests inside workerd through the existing harness, exercising snapshot creation and loading; these are not merely three individual ASGI assertions. Python 3.12 has a documented false-pass limitation for async in-worker tests, so the failure-before proof deliberately uses 3.13 rather than relying on 3.12 alone.
Broader existing suites
647 passed, 51 skipped. (Opt-in infrastructure cases are not exercised.)
The baseline full-suite command
uv run pytest -qreported 645 passed, 51 skipped, 2 failed, 156 errors. The two failures were the new gzip regressions on the unmodified adapter. The 156 errors came from the FastAPI fixture resolving a newer release whose OpenTelemetry import invokes uuid4 during Worker startup, where entropy is forbidden. This failure occurs before response handling and is separate from this patch.With only that fixture dependency temporarily pinned to
fastapi==0.141.1, the unchanged upstream FastAPI suite passed 156/156:Pinned code-quality hooks
All applicable hooks passed. The upstream hook configuration excludes runtime-sdk from mypy.
External local and deployed HTTP checks
A separate fixture uses both plain ASGI and actual FastAPI/Starlette
GZipMiddleware, buffered and streaming, with requests accepting uncompressed (identity) or gzip responses. The 12-case matrix is six response scenarios × twoAccept-Encodingsettings. A stdlib urllib probe captures raw entity bytes without transparent decompression and checks exact payloads after decoding the declared encoding once, response metadata, middleware negotiation, and Vary.Three subsequent fresh deployed matrices also passed 12/12 each.
Live fixture: https://asgi-encoding-validation.nrenzoni1.workers.dev
eba68990-012d-49d7-b600-675b07079aaa.60690905-a7a2-41fc-8206-ac1b72f89941.Deployment caveat: An initial patched capture failed two forced-gzip/identity-request cases; the final capture and three subsequent runs passed all 12 cases. The cause remains unexplained. The committed workerd regression provides the causal failure-before/pass-after proof.
Original application-level motivation
A separate Buf-generated ASGI service using Connect for Python (
connectrpc/connect-py), which provides Python clients and ASGI servers for the Connect, gRPC, and gRPC-Web RPC protocols, was exercised with the unmodified published Python client (connectrpc==0.12.1) running outside Workers over real HTTP, sharing only generated message types. No compression failures remain in the final local or deployed matrices. For context, both matrices improved from 86 pass / 34 fail / 2 diagnostic to 102 pass / 18 fail / 2 diagnostic. The two diagnostic entries are informational raw-wire probes, not pass/fail cases.The remaining 18 failures are native-gRPC rows and interactive Connect/gRPC-Web duplex rows, not encoding failures. This PR does not claim to fix ASGI trailers or buffered request uploads. The Connect fork/generated fixture is supporting evidence, not a new dependency of the regression suite or part of this patch.
Anticipated review questions
Why manual for all ASGI body responses, not only when gzip is present?
The bridge should preserve the representation emitted by the application, whether it is identity, gzip, or another encoding. Making the option conditional on gzip would special-case one codec rather than express the ASGI-to-Fetch forwarding contract. Uncompressed (
identity) controls pass. Gzip is the encoding directly tested here; other codecs are not claimed as independently verified.Does this disable Cloudflare's edge compression?
The option controls encoding when the ASGI bridge constructs the Fetch response: it tells Fetch that the supplied bytes already match the application’s declared encoding. This is distinct from subsequent HTTP/edge negotiation and does not configure zone compression policy. Edge-policy behavior has not been comprehensively verified. The live checks also observed runtime-dependent negotiation of otherwise plain responses; the proposal does not promise identical edge compression choices across local and deployed environments.
Could users simply remove GZipMiddleware?
That is an application workaround, not a solution for all ASGI applications that emit pre-encoded responses. The bridge must not silently encode protocol/application bytes twice.
Why leave null-body responses unchanged?
There is no entity to encode. Existing null-body tests remain in the exercised suite; this patch does not alter those status semantics.
Does this prove streamed responses arrive incrementally?
No. It proves correct bytes through the streaming response path, including terminal completion. It does not establish chunk arrival timing or interactive duplex behavior.
Scope and review request
Please review:
No version bump, generated changelog, public API, client patch, or unrelated lifespan/request-streaming change is included.