Skip to content

fix(runtime-sdk): preserve encoded ASGI response bodies - #284

Open
nrenzoni wants to merge 1 commit into
cloudflare:mainfrom
nrenzoni:nrenzoni/asgi-response-encoding
Open

nrenzoni wants to merge 1 commit into
cloudflare:mainfrom
nrenzoni:nrenzoni/asgi-response-encoding

Conversation

@nrenzoni

@nrenzoni nrenzoni commented Sep 30, 2026 •

Copy link
Copy Markdown

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:

  • the complete/buffered response;
  • the TransformStream response 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's GZipMiddleware and 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 Response constructors 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:

Workers have to compress data according to the content-encoding header when transmitting, to serve data that is already compressed, this property has to be set to "manual", otherwise the default is "automatic".

Reference: https://developers.cloudflare.com/workers/runtime-apis/response/#the-encodebody-option

The intended ownership boundary is:

  1. The application/middleware chooses and produces its representation.
  2. ASGI body events carry that representation and matching response headers.
  3. The bridge forwards the representation without applying the encoding again.

Relationship to withdrawn PR #170

#170 addressed the same double-compression symptom by stripping accept-encoding from 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:

#170 This proposal
Changes the request visible to the application Leaves request headers intact
Prevents middleware from negotiating gzip Leaves application negotiation intact
Avoids compression by suppressing application behavior Preserves the application's already-encoded response bytes

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:

import gzip
from workers import asgi

PAYLOAD = b"ASGI response already encoded by the application\n" * 128
ENCODED = gzip.compress(PAYLOAD, mtime=0)

async def app(scope, receive, send):
    if scope["type"] == "lifespan":
        raise NotImplementedError("No lifespan handler in this reproducer")
    await send({
        "type": "http.response.start",
        "status": 200,
        "headers": [
            (b"content-type", b"text/plain"),
            (b"content-encoding", b"gzip"),
        ],
    })
    await send({"type": "http.response.body", "body": ENCODED})

Default = asgi.entrypoint(app)

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 is PAYLOAD, 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.fetch service-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:

  • buffered gzip;
  • streaming gzip;
  • buffered uncompressed (identity);
  • streaming uncompressed (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-sdk unless otherwise noted. Baseline source was upstream commit c6e08350b51f5c0f18c747012e960fef55b69a96 (SDK 1.9.1).

Causal regression proof

uv run pytest 'tests/test_in_workerd.py::test_in_workerd[asgi-3.13]' -vv

With 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' -vv

All 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

uv run pytest -q --ignore=tests/test_fastapi.py

647 passed, 51 skipped. (Opt-in infrastructure cases are not exercised.)

The baseline full-suite command uv run pytest -q reported 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:

uv run pytest tests/test_fastapi.py -q

Pinned code-quality hooks

uvx pre-commit run --files src/workers/asgi.py \
  tests/workerd-test/asgi/worker.py \
  tests/workerd-test/asgi/tests/test_asgi.py \
  tests/workerd-test/asgi/asgi.wd-test README.md

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 × two Accept-Encoding settings. 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.

Environment Unmodified adapter Fixed adapter
Local workerd 4 pass / 8 fail 12 pass / 0 fail
Deployed Worker, final capture 4 pass / 8 fail 12 pass / 0 fail

Three subsequent fresh deployed matrices also passed 12/12 each.

Live fixture: https://asgi-encoding-validation.nrenzoni1.workers.dev

  • Baseline deployment: eba68990-012d-49d7-b600-675b07079aaa.
  • Fixed deployment: 60690905-a7a2-41fc-8206-ac1b72f89941.
  • Compatibility date: 2026-09-26; Wrangler 4.144.0; FastAPI 0.141.1; Starlette 1.7.0.

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:

  1. Whether manual Fetch encoding is the appropriate ownership boundary for ASGI-produced bytes.
  2. Whether the loopback HTTP regression belongs in this existing workerd fixture, or another existing HTTP test convention is preferred.
  3. Any compatibility consideration this default change should explicitly document.

No version bump, generated changelog, public API, client patch, or unrelated lifespan/request-streaming change is included.

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@nrenzoni

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request Sep 30, 2026
@nrenzoni
nrenzoni marked this pull request as ready for review September 30, 2026 12:22
@ryanking13

ryanking13 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

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 encodeBody="manual" when needed (as we document it in our docs), but not sure if we always want to set this.

@nrenzoni

nrenzoni commented Oct 6, 2026

Copy link
Copy Markdown
Author

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 Content-Encoding header. This follows the ASGI response-start specification, which explicitly leaves Content-Encoding under application control. Yes, an opt-in manual mode would let the bridge forward those bytes without encoding them again.

Would you like me to update this PR with an encode_body option on asgi.fetch and asgi.entrypoint, including tests and documentation, or would you prefer to implement the configuration API yourself (especially in this day and age with agentic coding)? I’m happy either way and can adapt to whichever API shape you prefer.

@ryanking13

Copy link
Copy Markdown
Contributor

Would you like me to update this PR with an encode_body option on asgi.fetch and asgi.entrypoint, including tests and documentation, or would you prefer to implement the configuration API yourself (especially in this day and age with agentic coding)? I’m happy either way and can adapt to whichever API shape you prefer.

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.

@nrenzoni

nrenzoni commented Oct 7, 2026

Copy link
Copy Markdown
Author

Would you like me to update this PR with an encode_body option on asgi.fetch and asgi.entrypoint, including tests and documentation, or would you prefer to implement the configuration API yourself (especially in this day and age with agentic coding)? I’m happy either way and can adapt to whichever API shape you prefer.

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!

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.

2 participants