Skip to content

Support a connection-phase timeout distinct from request timeout #400

Description

@pepijn-verburg-philips

Summary

Add a per-fetch connection-phase timeout that is separate from the existing whole-request timeout.

This is useful for liveness-sensitive clients and offline-capable caches: they need to attempt the live peer first, classify it as currently unreachable within a short connection budget, and then serve an eligible stale response. The same clients may still need a substantially longer budget for a connected peer to process the HTTP request and return its response head.

Problem

requestTimeout currently bounds connection acquisition, request dispatch, and the wait for the response head as one operation. This makes two useful policies impossible to express together:

  • allow approximately 2 seconds to establish a connection to a peer;
  • allow approximately 30 seconds for a connected peer to return an HTTP response.

A shorter requestTimeout makes cache fallback responsive when a previously discoverable peer is no longer reachable, but it also misclassifies a healthy connected peer with a slow handler. A longer timeout preserves valid slow responses but delays stale-cache fallback while QUIC connection establishment waits for its normal failure detection.

A separate application-level reachability preflight is not ideal. It duplicates connection work, races with the subsequent fetch, and cannot use the connection pool's authoritative knowledge of whether the request reused a live connection or actually needed a dial.

Proposed API and behavior

Add an optional fetch setting such as connectTimeout/connectTimeoutMs, threaded consistently through the shared TypeScript API and platform adapters into the Rust client.

Apply it only while a pool miss or stale pooled entry is establishing a new connection around Endpoint::connect:

  • A verified-live pooled connection is reused without applying the connection timeout.
  • HTTP stream opening, handshake, request dispatch, and response-head waiting remain governed by requestTimeout.
  • Connection-timeout expiry returns a typed connection failure distinguishable from the existing request/response timeout.
  • Cancellation stops the in-progress connection attempt and does not leave a connection or pool entry behind.
  • Existing behavior is unchanged when the new option is omitted.

This should remain a reachability deadline rather than claiming authoritative peer-offline detection. A stale discovery record plus network silence cannot prove that a peer is offline; it can only establish that the peer was unreachable within the configured connection budget.

Example

await node.fetch(target, {
  connectTimeout: 2_000,
  requestTimeout: 30_000,
});

An offline-capable cache can map connection failure to stale fallback, while a successfully connected peer retains the full request budget.

Acceptance criteria

  1. A new connection to an unreachable peer fails within the configured connection timeout.
  2. A fetch over a live pooled connection is not capped by the connection timeout.
  3. A connected peer with a deliberately slow response head remains governed by requestTimeout, not connectTimeout.
  4. A closed pooled connection is removed and its replacement attempt receives the connection timeout.
  5. Concurrent callers retain the pool's single-flight behavior and observe the same connection result.
  6. Cancellation and connection-timeout paths leave no leaked pool entries or native handles.
  7. Shared, Deno, Node, and Tauri APIs expose consistent behavior and typed errors.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions