Skip to content

Modern HTTP request coverage: first-class QUERY method + keepalive/Beacon requests #462

Description

@rejifald

Summary

Two adjacent gaps in the core transport's HTTP-method coverage, surfaced together because both are "modern request shapes the engine doesn't fully understand":

  1. QUERY — the new safe/idempotent/cacheable-with-a-body method (draft-ietf-httpbis-safe-method-w-body) works by accident but isn't a first-class citizen: the engine still treats "anything ≠ GET/HEAD" as an unsafe write.
  2. Beacon / keepalive — there is no way to make an unload-safe (navigator.sendBeacon-style) request. The fetch adapter never sets keepalive, so page-unload telemetry is cancelled mid-flight.

They're related but independently shippable — happy to split into two issues if preferred.


Part 1 — First-class QUERY method

QUERY is a safe, idempotent, cacheable method that carries a request body (think "GET with a body" for complex/large queries that don't fit in a URL).

What already works ✅

  • cfg.method is a free string (packages/core/src/types.ts:198), so method: 'QUERY' is accepted — not rejected.
  • encodeRequestBody only drops the body for GET/HEAD (packages/core/src/http-adapter.ts:324), so a QUERY body is sent.
  • The cache key already folds the request body into canonicalRequest (packages/core/src/cache.ts:140), so opt-in caching (cache: { methods: ['QUERY'] }) would key correctly on the query payload — two different QUERY bodies to the same URL won't collide.

Gaps

  1. Idempotency mis-classification. applyIdempotency short-circuits only GET/HEAD and treats everything else as a write, injecting an Idempotency-Key:

    // packages/core/src/engine.ts:161
    if (method === 'GET' || method === 'HEAD') return; // writes only

    QUERY is safe — stamping it with an idempotency key is semantically wrong (only fires when idempotency is configured, so low blast radius, but it's the tell that the engine's notion of "safe method" is hard-coded to GET/HEAD).

  2. Not cacheable by default. Cache defaults to ['GET','HEAD'] (packages/core/src/cache.ts:359, documented packages/core/src/types.ts:404), even though QUERY responses are spec-cacheable. Requiring explicit opt-in is defensible, but it should at least be documented that QUERY is a valid cache.methods entry and keys on the body.

  3. No shared notion of method safety. GET/HEAD is special-cased inline in at least three places (idempotency engine.ts:161, body-stripping http-adapter.ts:324, redirect handling http-adapter.ts:243). There's no single isSafe(method) / isIdempotent(method) helper, so adding QUERY (and future methods) means hunting every site.

  4. DX / discoverability. method: string gives no autocomplete or guidance; no docs page mentions QUERY.

Proposal

  • Introduce a tiny internal method-classification helper (isSafeMethod / isIdempotentMethod) and route the GET/HEAD special-cases through it, adding QUERY to the safe+idempotent set.
  • Skip idempotency-key injection for safe methods (GET/HEAD/QUERY).
  • Document QUERY as a supported method and a valid cache.methods entry (note: it keys on the request body).
  • (Optional) widen the method type to a KnownMethod | (string & {}) union so common methods autocomplete without closing the door on custom verbs.

Acceptance criteria

  • method: 'QUERY' with a body sends the body (regression-guard the existing behavior).
  • With idempotency configured, a QUERY call does not get an Idempotency-Key.
  • cache: { methods: ['QUERY'] } caches responses keyed on the request body; distinct bodies don't collide.
  • Docs mention QUERY (method support + caching note).

Part 2 — Beacon / keepalive (unload-safe) requests

navigator.sendBeacon(url, data)fetch(url, { keepalive: true }): a fire-and-forget request the browser is obligated to complete even as the page unloads (pagehide / visibilitychange: hidden). This is the standard mechanism for exit telemetry.

Gap

The fetch adapter's RequestInit never sets keepalive:

// packages/core/src/http-adapter.ts:53
const init: FetchInitWithDispatcher = compact({
    method,
    headers,
    body,
    signal: req.signal,
    dispatcher: opts?.dispatcher,
    redirect: 'manual',
    // no keepalive
});

And there's no field to request it — neither AdapterRequest (packages/core/src/types.ts:196) nor the per-call StitchInput (packages/core/src/types.ts:23) carries keepalive.

Impact: any stitch fired on page unload — third-party analytics, and the library's own tracing/otlp sinks — is aborted mid-flight when the user navigates away, silently dropping the last (often most important) event.

Proposal

  • Add an opt-in keepalive?: boolean on AdapterRequest and a way to set it (config-level on the stitch and/or per-call on StitchInput), threaded through buildRequest → adapter, and passed into the fetch init.
  • Document the constraints so it's not mistaken for a free "always on" switch:
    • fetch + keepalive has a 64 KiB total request-body cap (across all in-flight keepalive requests).
    • It can't stream the request body → mutually exclusive with upload streaming.
    • Only the fetch transport honors it; buffered/other adapters (axios, xhr) should ignore or reject it.
  • Because of the body cap + fire-and-forget nature, keepalive is per-stitch/per-call opt-in, never a default.
  • (Nice-to-have) a small recipe/example: a telemetry stitch flushed on visibilitychange with keepalive: true.

Acceptance criteria

  • A stitch/call can request keepalive, and the fetch adapter sets keepalive: true on the underlying fetch init.
  • Default behavior is unchanged when unset (byte-identical init).
  • Non-fetch adapters don't silently pretend to honor it.
  • Docs cover the 64 KiB cap, the no-streaming-body constraint, and an unload-flush example.

Notes

  • No existing issue covers either gap (checked open + closed for QUERY method / keepalive / beacon).
  • File references are against main at the time of filing; both areas are pure core-transport changes (no new deps), consistent with the zero-dep / browser-first bars.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions