Skip to content

adapter-cloudflare: HEAD requests with Content-Length: 0 return 500 on every route (worktop cache lookup throws pre-routing) #16607

Description

@jaydiamond42

Describe the bug

The adapter-cloudflare worker template runs a worktop cache lookup before any routing:

// skip cache if "cache-control: no-cache" in request
let pragma = req.headers.get('cache-control') || '';
let res = !pragma.includes('no-cache') && (await Cache.lookup(req));

// skip cache if "cache-control: no-cache" in request
let pragma = req.headers.get('cache-control') || '';
let res = !pragma.includes('no-cache') && (await Cache.lookup(req));
if (res) return res;

Cache.lookup is worktop's (worktop/cfw.cache, worktop@0.8.0-next.18 pinned by the adapter). For HEAD requests it rebuilds the incoming request into a GET cache key, using the incoming request as the init source (de-minified from the shipped package):

let isHEAD = typeof req !== 'string' && req.method === 'HEAD';
if (isHEAD) req = new Request(req, { method: 'GET' });
let res = await cache.match(req);

On the production edge, workerd represents an incoming request that declares a body as having a non-null (empty) body stream — and HEAD with Content-Length: 0 is exactly that shape. The header is unusual on HEAD but real traffic sends it constantly: uptime monitors and .NET HttpClient are the two we caught in the wild.

For such a request, new Request(req, { method: 'GET' }) inherits the non-null body and hits the Fetch spec rule that a GET/HEAD request cannot have one:

TypeError: Request with a GET or HEAD method cannot have a body.

The throw happens in the adapter prologue — before server.respond, so before any route, hook, or handleError runs. It surfaces as an unhandled exception ("Exception Thrown" in wrangler pages deployment tail), and every HEAD request that declares a body, to every route of every adapter-cloudflare deployment, returns 500. The site looks healthy in browsers and to plain curl -I, while uptime monitors report it down — and no app-level error handling or observability ever fires, because app code never runs.

Why this hides from every local check:

  • Plain curl -I sends no Content-Length, so it does not reproduce — you must add -H "Content-Length: 0".
  • vite dev has no adapter prologue.
  • wrangler pages dev doesn't reproduce either — the local ingress doesn't materialize the declared body the way the production edge does.
  • Node/undici enforce the same spec rule at construction time, so the failing Request cannot even be built in a unit test. The production edge is the only place this input exists.

Verified on @sveltejs/adapter-cloudflare 7.2.7; 7.2.8/7.2.9 ship a byte-identical built worker template, so the latest release behaves the same. Confirmed live on three separate production Cloudflare Pages projects (re-confirmed today: 35/35 such requests → 500, while plain HEAD → 200/302). GET with Content-Length: 0 is unaffected (verified — worktop only rebuilds HEAD).

Reproduction

This is edge-runtime behavior, so it can't be demonstrated on StackBlitz — the reproduction is any adapter-cloudflare deployment plus one curl flag. No app code is involved; the skeleton app reproduces it on every route:

  1. npx sv create repro (skeleton defaults), swap the adapter to @sveltejs/adapter-cloudflare
  2. npm run build && npx wrangler pages deploy .svelte-kit/cloudflare
  3. Then:
curl -I https://<deployment>/                          # 200 — looks healthy
curl -I -H "Content-Length: 0" https://<deployment>/   # 500 — and this is what monitors send

Any existing adapter-cloudflare deployment shows the same two results. Happy to push a minimal repro repo if that helps triage.

Logs

$ curl -I https://<site>/
HTTP/1.1 302 Found
Location: /login
Content-Security-Policy: base-uri 'self'; ...        <- app responded; its headers are present

$ curl -I -H "Content-Length: 0" https://<site>/
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=UTF-8
Cache-Control: private, max-age=0, no-store, no-cache, must-revalidate, ...
Server: cloudflare                                   <- generic edge error; none of the app's headers

# wrangler pages deployment tail, same request: logged with outcome "Exception Thrown" and
TypeError: Request with a GET or HEAD method cannot have a body.

System Info

System:
  OS: Windows 11 10.0.26200 (build machine - the failure is in production workerd, not local tooling)
Binaries:
  Node: 22.13.1
npmPackages:
  @sveltejs/adapter-cloudflare: 7.2.7 (bug also verified against 7.2.9 - built worker template is byte-identical)
  @sveltejs/kit: 2.50.1
  svelte: 5.48.5
  vite: 6.4.1
  wrangler: 4.80.0
adapter dependency:
  worktop: 0.8.0-next.18 (pinned)

Severity

serious, but I can work around it

Additional Information

Either of these would fix it:

  1. Build the cache key without the incoming request as init source, so a body can never be inherited:

    let res =
      !pragma.includes('no-cache') &&
      (await Cache.lookup(new Request(req.url, { method: 'GET', headers: req.headers })));

    (Cache.save receives the original request and already ignores non-GET methods, so the save path is unchanged.)

  2. Normalize declared-body GET/HEAD requests once at the top of fetch, which also shields server.respond and any future prologue code from the same input:

    if (
      req.body !== null &&
      (req.method === 'HEAD' || (req.method === 'GET' && req.headers.get('content-length') === '0'))
    ) {
      req = new Request(req.url, { method: req.method, headers: req.headers, redirect: req.redirect, cf: req.cf });
    }

Related: #15813 proposes removing this pre-routing cache lookup entirely (for unrelated reasons) — that would also fix this, and this bug is perhaps extra motivation: the lookup currently runs (and can throw) even for clients that will never be served from cache. #9884 tracks another defect in the same code path.

Our production workaround, in case someone else hits this before a fix ships: a postbuild script that wraps the built _worker.js default export with exactly the normalization in suggestion 2 (idempotent; it fails the build if the adapter's export tail ever changes, so an adapter upgrade can't silently drop it). Verified live: before the wrapper, these requests were 500 with "Exception Thrown" in the deployment tail; after, 200/302.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions