Skip to content

fix(cloudflare): close request scopes for bodyless responses - #1649

Merged
sam-goodwin merged 5 commits into
alchemy-run:mainfrom
agcty:fix/cloudflare-raw-response-status
Oct 7, 2026
Merged

sam-goodwin merged 5 commits into
alchemy-run:mainfrom
agcty:fix/cloudflare-raw-response-status

Conversation

@agcty

@agcty agcty commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

A Worker's request finalizers never ran for HEAD, 204, 205 and 304 responses. The request scope was always handed to the response stream, but those responses send no body, so nothing closed it:

fetch: Effect.gen(function* () {
  yield* Effect.addFinalizer(() => releaseConnection); // never ran on HEAD / 204 / 304
  return HttpServerResponse.fromWeb(nativeResponse);
}),

The scope now goes to the stream only when a body will be sent, matching Effect's own web handler:

- HttpServerResponse.toWeb(EffectHttp.scopeTransferToStream(response), { withoutBody, context })
+ if (!HttpServerResponse.omitsBody(response, withoutBody)) {
+   response = EffectHttp.scopeTransferToStream(response);
+ }
+ HttpServerResponse.toWeb(response, { withoutBody, context })

For a native Response that should go through Effect middleware (status, headers, cookies), use HttpServerResponse.fromWeb(nativeResponse) over raw(nativeResponse). raw stays a passthrough, which keeps WebSocket upgrades working. This PR does not change raw's GET/HEAD inconsistency reported in #1648.

…esponse on GET

A Worker handler that answers with `HttpServerResponse.raw(new Response(...))`
saw a status or headers set later through the Effect response APIs, for
example by a pre-response handler, reach HEAD answers but not GET answers.
`HttpServerResponse.toWeb` hands a Raw web `Response` to the client as the
object it is, so the Effect-level status is dropped on GET, while the HEAD
path (`withoutBody`) builds a fresh response from the Effect-level fields.

The adapter now adopts the native object's status, status text and headers
into the `HttpServerResponse` before the pre-response handlers run, so those
see the real status and whatever they set is a difference against the native
object. On output, a Raw web `Response` nothing changed is returned untouched,
a 101 always is, and otherwise a new `Response` is built over the same body
stream with the Effect-level status and headers, keeping the native
`Set-Cookie` multiplicity and appending Effect-level cookies. GET and HEAD
answer from the same rule.

Closes alchemy-run#1648

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@agcty
agcty marked this pull request as ready for review September 15, 2026 13:26
@sam-goodwin sam-goodwin changed the title fix(cloudflare): apply Effect-level status and headers to a Raw web Response on GET fix(cloudflare): close request scopes for bodyless responses Sep 17, 2026
@sam-goodwin

Copy link
Copy Markdown
Contributor

Thanks for reporting and reproducing this. We revised the approach after comparing it with Effect's existing response APIs.

For ordinary native HTTP responses that need to participate in Effect middleware, HttpServerResponse.fromWeb already imports the native status, headers, cookies, and body before middleware runs:

- HttpServerResponse.raw(nativeResponse)
+ HttpServerResponse.fromWeb(nativeResponse)

That lets Effect handle conversion in both directions without Alchemy maintaining a second implementation. We removed the custom adoption/merge/conversion helpers. During review, we also found that the adoption step overwrote an explicit status set before the handler returned: a requested 201 became the native response's 202.

The remaining production change matches Effect's web handler: transfer request-scope ownership to a stream only when the response will actually send a body. Otherwise HEAD and 204/205/304 responses can leave request finalizers unexecuted.

The fixture now uses fromWeb for ordinary HTTP and keeps raw native passthrough separate. We deployed it to real Cloudflare and local workerd and exercised 18 GET/HEAD cases on each, including status/header overrides, cookies, streaming payloads, bodyless responses, and persisted request-finalizer records. A real WebSocket echo checks that upgrades still work. Removing the scope guard reproduced missing HEAD cleanup; restoring it passed. Workspace type-check passed, and Workers coverage finished with 365 passes and 7 TODO across two runs—the four-minute command limit interrupted the last tracing test, which then passed separately.

This is a narrower change than the original proposal: it addresses the middleware use case through fromWeb, but does not change Effect's raw(nativeResponse) GET/HEAD inconsistency. We updated the title and description and removed the claim to close #1648 so that distinction is explicit. The PR is left open for review.

@alchemy-version-bot

Copy link
Copy Markdown
Contributor

Install the packages built from this commit:

alchemy

pnpm install https://pkg.alchemy.run/alchemy/pr:1649:541f647
@alchemy.run (6)
pnpm install https://pkg.alchemy.run/@alchemy.run/better-auth/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@alchemy.run/cloudflare-runtime/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@alchemy.run/frontend-frameworks/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@alchemy.run/node-utils/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@alchemy.run/floci/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@alchemy.run/pkg/pr:1649:541f647
Transitive Dependencies (18)
pnpm install https://pkg.alchemy.run/@distilled.cloud/acme/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/aws/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/axiom/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/cloudflare/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/core/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/doppler/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/fly-io/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/gcp/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/hetzner/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/iceberg/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/infisical/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/neon/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/planetscale/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/prisma/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/railway/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/stripe/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/zerossl/pr:1649:541f647
pnpm install https://pkg.alchemy.run/@distilled.cloud/github/pr:1649:541f647

Published Oct 7, 2026, 3:48 PM UTC. Expires Oct 14, 2026, 3:48 PM UTC, extended while this pull request is open.

@sam-goodwin
sam-goodwin merged commit 8defa49 into alchemy-run:main Oct 7, 2026
5 checks passed
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