Better Fetch

· Paul Crossland

Who Settles the Body After the Caller Is Gone?

Fresh Undici changes expose body-ownership failures across redirects, retries, and deduplicated requests—and how to test them.

A fetch follows a redirect, but the final request never starts. Another fetch returns headers, then its body reader waits forever after the retry controller gives up. Two callers share one in-flight request; the first caller times out, and the caller with no timeout fails too.

All three can look like origin latency. The deeper problem is local: a response body outlived the component that appeared to own it. In a pooled client, that can pin a connection, strand a consumer, or let one consumer cancel work still needed by another.

The practical thesis is: every response attempt needs an explicit body owner and terminal disposition, while every consumer needs an independent settlement outcome; redirect, retry, deduplication, and cancellation layers may transfer ownership, but none may leave a body unread, unfailed, or governed by a caller that has already detached.

This is reliability guidance for public or properly authorized fetching. Body cleanup is not permission to replay unsafe operations, follow an out-of-scope redirect, or retry past a login, denial, challenge, payment boundary, publisher restriction, or 429. Preserve those as policy or scheduling outcomes.

Fresh evidence from three ownership boundaries

Primary sourceDateWhat it contributes
Undici v8.10.22026-09-04Its security notes describe a terminal retry failure after response headers were exposed leaving the original body orphaned, so consumers could wait indefinitely. The release propagates the terminal error to that body.
Undici pull request 57672026-09-04Reports that a followed 3xx body larger than the internal buffer can pause the only pooled connection even though callers never receive that intermediate response. The open fix discards those bytes without pausing so the body reaches its end and releases the connection.
Undici pull request 57652026-09-04Reports the first caller in a deduplicated group receiving the real dispatch controller, so its abort rejects every waiter. The open fix detaches that consumer and aborts the shared dispatch only when no consumers remain.

The two September 4 pull requests remain open at publication time. They are strong reasons to test the exact library and runtime build, not claims that every package already contains the proposed behavior.

Recent Better Fetch posts already cover HTTP/2 stream settlement, browser download acceptance, network-archive gaps, and runtime canaries. Repeating “consume response bodies” or “log the client version” would add little. The new information surplus is the body-ownership matrix: hidden intermediate bodies, caller-visible bodies, and one body shared by several consumers require different terminal actions even when they use the same pool.

Hidden bodies still consume transport capacity

Automatic redirect following hides the intermediate response from application code. That does not remove its bytes from the connection. Under HTTP/1.1, the client generally has to consume the framed body or retire the connection before it can safely reuse that socket.

The fresh redirect report shows a particularly damaging sequence. The client buffers an intermediate 301 body, the buffer reaches its high-water mark, backpressure pauses the socket, and the redirect path starts waiting for a connection for the next request. With a one-connection pool, the follow-up waits behind the same connection whose unread body prevents release. No application body reader can help because the intermediate response was never exposed.

The redirect layer therefore owns disposition. It can:

  • drain and discard within byte and time budgets, preserving reuse;
  • destroy or retire the connection when safe draining exceeds those budgets;
  • expose the response when redirect mode requires caller handling;
  • stop before the next hop when destination or credential policy denies it.

Draining is not free. A server can attach a large body to a redirect, so an unlimited drain spends bandwidth and time. Destroying the connection avoids that cost but adds handshake load and loses reuse. Make the choice explicit by response class, protocol, body framing, and configured budget. Do not buffer discarded bytes into application memory merely to prove they were ignored.

Once headers are exposed, failure belongs to that body

Retry logic creates another ownership transition. Before headers reach the caller, a client may be able to abandon one attempt and produce a later attempt under a narrowly defined, safe retry policy. After the caller receives a response object, however, its body stream is the public handle for that attempt.

If a later retry decision reaches a terminal error, resolving or rejecting only the retry controller is insufficient. The already-exposed body must receive an end, error, or explicit cancellation. Otherwise application code can hold a valid status and headers while text(), JSON parsing, or a stream reader waits forever. A replacement response should not quietly appear after an earlier response has been exposed; that would blur two attempts into one application artifact.

Track headers_exposed as a commit point. Before it, retry code may replace an attempt only when method, body replayability, policy, and limits allow. After it, any terminal transport or retry failure must settle the exposed stream and mark the artifact incomplete. Partial bytes cannot enter extraction or cache as a complete response.

A shared dispatch is not owned by its first caller

Request deduplication changes the cardinality again. Several equivalent callers can wait on one network dispatch. The first caller is only the primary for setup; it is not automatically the lifetime owner of everybody else's work.

An abort should detach the requesting consumer, settle that consumer with its own reason, and decrement the live-consumer count. The underlying dispatch should continue while at least one eligible consumer remains. When the count reaches zero, one component must cancel the transport, remove the pending deduplication entry, and settle cleanup exactly once.

This requires more than matching URLs. Coalescing eligibility must include the authoritative origin, method, relevant headers, credentials and session segment, cache mode, representation variant, and response policy. Never share authenticated or personalized work across principals merely because request strings look equal.

Issue a body-settlement receipt

Attach one compact receipt to every attempt and roll it up to the logical fetch:

  • logical request, attempt, redirect-hop, deduplication-group, consumer, connection, and stream identifiers;
  • runtime and client versions, dispatcher configuration, protocol, origin key, and pool limits;
  • method class, replayability verdict, redirect policy, retry rule, and matched access-policy outcome;
  • body visibility: hidden_intermediate, exposed_to_consumer, shared_dispatch, or not_created;
  • owner component and every ownership transfer with timestamps;
  • framing evidence, declared length, bytes received, delivered, discarded, and buffered;
  • pause and resume count, drain byte and time budgets, and budget-exceeded action;
  • consumer count at dispatch, header exposure, abort, body end, and transport cancellation;
  • terminal disposition: delivered_end, delivered_error, drained, connection_retired, consumer_detached, shared_abort, policy_stop, or unknown;
  • connection reusable verdict, pool wait after the attempt, extraction eligibility, and cache eligibility.

Alert on contradictions: a hidden body with no disposition, exposed headers with an unsettled body, zero consumers with a live deduplication entry, a reusable connection whose prior body did not end, or pool wait rising while origin latency and active connection count remain flat.

A fixture matrix for body ownership

Run the exact client, interceptors, dispatcher, and runtime used in production against owned endpoints:

  1. Use a one-connection HTTP/1.1 pool. Return small, buffer-sized, and larger bounded bodies on followed redirects, then require the final request to complete without an unexplained pool wait.
  2. Make a redirect body exceed the drain budget. Require a bounded connection-retirement verdict, not an infinite drain or a success-shaped timeout.
  3. Redirect across owned origins. Require a fresh destination and credential-policy decision before sending the next hop, regardless of body cleanup success.
  4. Expose response headers, deliver some body bytes, then force a terminal retry failure. Require the same body reader to reject promptly and prohibit extraction or cache admission.
  5. Fail before exposing headers and verify that only replayable, policy-approved operations enter the bounded retry path. Unsafe or uncertain operations must not be replayed automatically.
  6. Coalesce two equivalent requests, abort the first consumer, and require the second to receive the response from one origin request. Then abort all consumers and require one transport cancellation plus removal of the pending entry.
  7. Repeat with consumers that differ by principal, cookies, authorization class, Accept variant, or cache policy. Require separate dispatches rather than unsafe coalescing.
  8. Exercise slow readers, early cancellation, chunked bodies, declared-length mismatch, compressed bodies, connection close, and HTTP/2 streams. Every attempt must reach one terminal disposition even when connection reuse semantics differ.
  9. Return login, denial, challenge, payment, publisher-policy, and 429 fixtures. Require stop or scheduling outcomes; body settlement must not trigger broader identity, route, or retry behavior.

The operator decision rule

When a fetch hangs after headers, during a redirect, or behind a healthy-looking pool, find the body with no terminal owner. If an intermediate response is hidden, the redirect layer must drain within policy or retire the connection. If headers were exposed, propagate the terminal outcome to that exact body and quarantine partial data. If several callers share one dispatch, detach only the aborting consumer until none remain. If ownership cannot be reconstructed, treat the artifact and connection as unknown rather than manufacturing success with another retry.

A response body is not just payload. Until it ends, errors, or is deliberately abandoned with transport cleanup, it is a lease on consumer progress and often on connection capacity. Make that lease explicit across redirects, retries, and deduplication, and a mysterious source timeout becomes a bounded local lifecycle failure instead of an immortal reader or an exhausted fetch pool.