Skip to content

fetch: add connectTimeout, socketTimeout, and a whole-request timeout - #33338

Open
robobun wants to merge 2 commits into
mainfrom
farm/d548e3d0/fetch-granular-timeouts
Open

fetch: add connectTimeout, socketTimeout, and a whole-request timeout#33338
robobun wants to merge 2 commits into
mainfrom
farm/d548e3d0/fetch-granular-timeouts

Conversation

@robobun

@robobun robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

fetch() has one timeout for everything, which forces callers to pick a single number that has to be generous enough for the slowest response body they expect. That same number then also governs how long a request spends failing to open a socket on a bad network. Splitting the two is what httpx, requests, reqwest, OkHttp and undici all do; this adds the equivalent to Bun.

Two problems turned up while implementing it.

timeout: <number> was silently a no-op

Only false and 0 ever did anything (they disarmed the socket idle timer). Every other number was read, coerced, and thrown away:

// Bun 1.4.0: prints `"hello world"` after 3004 ms.
const server = Bun.serve({
  port: 0,
  fetch: () => new Response(new ReadableStream({
    async start(c) {
      c.enqueue(Buffer.from("hello "));
      await Bun.sleep(3000);
      c.enqueue(Buffer.from("world"));
      c.close();
    },
  })),
});
const res = await fetch(server.url, { timeout: 100 });
console.log(await res.text());

This is #16682, open since February. The option is also absent from @types/bun, so the only way to discover it is from the issue tracker.

There was no connect timeout at all

The idle timer is armed in on_open, so DNS resolution and the TCP handshake are uncovered. A socket stuck in SYN_SENT hangs until the kernel exhausts its SYN retransmits, around two minutes on Linux:

// Blackholed SYN. Nothing in the fetch options can bound this.
await fetch(`http://127.0.0.1:${portWithFullAcceptQueue}/`, { timeout: 500 });

ss -tanp confirms the client socket sits in SYN-SENT the whole time. AbortSignal is the only escape, and it can't distinguish "could not connect" from "the body is still streaming".

What changed

Three deadlines, as flat sibling options, which is how every surveyed HTTP client spells them:

timeout?: boolean | number;      // whole-request wall-clock deadline
connectTimeout?: number | false; // DNS + TCP + the TLS handshake
socketTimeout?: number | false;  // inactivity in either direction
covers default
timeout the whole request, fetch() until the body finishes no deadline
connectTimeout DNS + TCP + the TLS handshake no deadline
socketTimeout any stretch with no bytes moving in either direction 5 minutes

All three are milliseconds. connectTimeout rejects with TimeoutError: The connection timed out., so a retry policy can tell "the network is down" from "the server is slow"; the other two reject with TimeoutError: The operation timed out.

socketTimeout and timeout answer different questions, and callers often want both. socketTimeout fires when the connection goes quiet, and is re-armed on every byte, so a response that trickles in steadily never trips it. timeout fires regardless of activity, and is the one that catches a server that is technically alive but far too slow.

await fetch(url, { connectTimeout: 5_000, socketTimeout: 30_000, timeout: 120_000 });

timeout: false (or 0) disables every timeout for the request, which is the long-poll/SSE escape hatch people found in #16682, so it keeps working. An explicit socketTimeout outranks it, so { timeout: false, socketTimeout: 30_000 } does what it reads like.

Why these three axes, and why `timeout` is the whole-request one: a survey of 16 HTTP clients across 11 languages

Every value below was read from the library's source, not from docs or recollection.

connect inactivity total
py requests timeout[0] timeout[1], read-only
py httpx connect 5s read 5s + write 5s
py aiohttp sock_connect 30s sock_read, read-only total 300s
go net/http dialer Timeout 30s + TLSHandshakeTimeout 10s — (h1) Client.Timeout
rs reqwest connect_timeout read_timeout, read-only timeout
java OkHttp connectTimeout 10s readTimeout 10s + writeTimeout 10s callTimeout
kotlin Ktor connectTimeoutMillis socketTimeoutMillis, bidirectional requestTimeoutMillis
java java.net.http connectTimeout HttpRequest.timeout
js undici connectTimeout 10s bodyTimeout 300s, read-only — (refuses, on purpose)
js axios timeout, bidirectional (node) timeout (browser only)
js got lookup + connect + secureConnect socket, bidirectional request
c# .NET ConnectTimeout, infinite HttpClient.Timeout 100s
ruby Net::HTTP open_timeout 60s read_timeout 60s + write_timeout 60s
php Guzzle connect_timeout 300s read_timeout, stream handler only timeout
c libcurl CURLOPT_CONNECTTIMEOUT 300s — (throughput floor instead) CURLOPT_TIMEOUT
swift URLSession timeoutIntervalForRequest 60s, bidirectional timeoutIntervalForResource 7d

Three things this settles:

  1. A total axis is close to universal (11 of 16). The four without one are requests, httpx, undici and Net::HTTP, which happen to be the clients the original two-axis version of this PR was modelled on.
  2. Bidirectional inactivity is well-precedented; read-only is the minority. Ktor, URLSession, got and axios-on-node all re-arm on bytes in either direction. Splitting read from write (OkHttp, httpx, Ruby) only distinguishes a stalled upload, which a read-only timer misses entirely. So idle stays bidirectional, and there is no separate read.
  3. A bare timeout is the whole-request one wherever that axis exists. Go, reqwest, .NET, libcurl, Guzzle and got's request all read a bare number that way.

Closest prior art: Ktor ships this exact trio (connectTimeoutMillis / socketTimeoutMillis / requestTimeoutMillis), and URLSession ships the inactivity + whole-request split (timeoutIntervalForRequest is documented as "reset whenever data is transmitted"; timeoutIntervalForResource is the hard deadline).

socketTimeout takes its name from Ktor and got, the two clients with the same bidirectional semantics. idleTimeout was the other candidate, but Bun.serve({ idleTimeout }) is in seconds, so a millisecond idle sitting next to it would have been a units trap.

How

connect is a one-shot EventLoopTimer embedded in FetchTasklet, armed in queue(). The HTTP thread sets a new connected atomic on the existing Signals::Store at HTTPClient::first_call, the single funnel every transport reaches before writing a request (on_open for plain TCP, on_handshake for TLS, plus the h2 session attach/adopt/enqueue, h3's on_hsk_done, and the pooled-proxy-tunnel branch that skips first_call). If the deadline expires while that atomic is still false, the timer reuses the existing abort-during-connect path to tear the socket down.

us_timer_t would have been the obvious home for this on the HTTP thread, but src/uws_sys/Timer.rs marks it deprecated (one timerfd per timer on Linux), and uSockets' sweep never walks head_connecting_sockets, so a DNS-deferred connect is invisible to it anyway. The JS-thread timer is the same mechanism AbortSignal.timeout already uses to escape a stalled connect, so it has millisecond precision and no new teardown path.

timeout is a second EventLoopTimer on the tasklet with the same lifecycle as the connect deadline, but no phase check: it fires in whatever phase the request is in, which is exactly what makes it catch a server that is alive but too slow. Both deadlines are dropped together by cancel_timeouts(), which clear_data() always reaches.

socketTimeout becomes a per-request override of BUN_CONFIG_HTTP_IDLE_TIMEOUT via a new HTTPClient::idle_timeout_seconds. HTTPClient::set_timeout and h2's ClientSession::rearm_timeout both route through a new effective_idle_timeout_seconds(); the h2 path, which shares one socket across multiplexed streams, now arms the longest timeout any attached client asked for instead of a boolean, and still disarms only when every client opted out. The clamp-and-round logic that HTTPThread::on_start applied to the env var moves into a shared normalize_idle_timeout_seconds so both assignment sites produce a value the uSockets timer can represent.

Deliberate limits

  • connectTimeout bounds the initial connection. A redirect that reopens a connection is not re-bounded; connected is sticky for the life of the request.
  • connectTimeout and timeout are measured from fetch(), so time spent queued behind BUN_CONFIG_MAX_HTTP_REQUESTS counts against them.
  • socketTimeout inherits the socket timer's coarse resolution (4s ticks, 60s above 240s) and can fire up to one tick early. That is pre-existing behaviour of BUN_CONFIG_HTTP_IDLE_TIMEOUT, now just reachable per-request. Documented as a backstop, not a precise deadline.
  • No default connect timeout. Adding one would change behaviour for bun install and node:http too, which deserves its own PR (undici defaults to 10s).

Rebase onto #33647

#33647 landed the per-request idle_timeout_seconds plumbing (HTTPClient / AsyncHTTP / FetchOptions, effective_idle_timeout_seconds, normalize_idle_timeout_seconds, and the h2 rearm_timeout aggregation) along with the #16682 fix that a numeric timeout extends the socket-idle deadline. This PR now carries only what is net-new on top of it, squashed into one commit.

Conflict resolution took main for every shared hunk. Two adjustments followed:

  • timeout_ms_arg now treats Infinity as "no deadline", matching the semantic fetch: let an explicit timeout extend the socket idle deadline #33647 established and tests for.
  • The h2 idle-aggregation test's "short explicit deadline" sub-cases switched {timeout: 1000} to {socketTimeout: 1000}, since timeout now arms a per-request wall-clock deadline that is independent of the shared socket timer that test exercises.

Verification

test/js/web/fetch/fetch-timeout-options.test.ts, 21 tests. The connect cases use a raw TCP listener that accepts and never answers the ClientHello, the same hermetic trick bun-install-stalled-tls.test.ts already uses: first_call is never reached, which is exactly the state a dropped SYN produces, but deterministic enough to assert on. Several tests are negative contracts (the deadline must not fire on a healthy-but-slow request, on connect: 0, or once connected).

$ bun bd test test/js/web/fetch/fetch-timeout-options.test.ts
 21 pass, 0 fail

$ USE_SYSTEM_BUN=1 bun test test/js/web/fetch/fetch-timeout-options.test.ts
 6 pass, 15 fail
Regression sweep

Every suite below was run against a debug build of this branch and against a debug build of main, and the failing sets are identical. The failures that do exist are environmental (the container has no route to TEST-NET-1, an intercepting HTTP proxy, and debug+ASAN timing assertions).

suite result
fetch.test.ts 42 fail on this branch, the same 42 on main
fetch-http2-client.test.ts 60 pass
fetch-http3-client.test.ts 52 pass
fetch-keepalive.test.ts 5 pass
fetch-abort-queued / -stream-body / -socket-close-race pass
bun-install-stalled-tls.test.ts pass
fetch-leak.test.ts, abort-signal-leak.test.ts, fetch-preconnect.test.ts identical failures on main
bun-types.test.ts 12 pass

Notes


[review] gate passed · iteration 10 · 18 files touched

fails on main (without fix)
ASAN without fix: 13 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/web/fetch/fetch-http2-client.test.ts" test/js/web/fetch/fetch-timeout-options.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (3baeef979)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [1528.90ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [1405.57ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body streams as raw DATA frames [689.73ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [2078.38ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is deliver
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (bef0c7479)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > connection-specific request headers are stripped before HPACK [57.65ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [83.18ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body larger than initial send window [82.97ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > keep-alive: sequential requests reuse one h2 session [82.86ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > multiple Set-Cookie response headers survive HPACK decode [71.20ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response trailers are consumed without breaking the body [73.06ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > raw frame server > RST_STREAM PROTOCOL_ERROR is not retried [99.71ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is delivered as DATA frame
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/web/fetch/fetch-http2-client.test.ts" test/js/web/fetch/fetch-timeout-options.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (3baeef979)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [1499.30ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [1473.98ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [2035.30ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body streams as raw DATA frames [843.61ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is deliver
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 700ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/71] gen ErrorCode+*.h
[2/20] cxx obj/src/jsc/bindings/webcore/streams/JSStreamPipeToOperation.cpp.o
[3/20] cxx obj/src/jsc/bindings/bindings.cpp.o
[4/20] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-2.cpp.o
[5/20] cxx obj/src/jsc/bindings/webcore/streams/JSWritableStreamDefaultController.cpp.o
[6/20] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[7/20] cxx obj/src/jsc/bindings/webcore/streams/JSReadableStream.cpp.o
[8/20] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-1.cpp.o
[9/20] gen cpp.rs (cppbind)
[10/20] cxx obj/src/jsc/bindings/ZigGlobalObject.cpp.o
[11/20] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[12/20] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-3.cpp.o
[13/20] cxx obj/unified/UnifiedSour
... (truncated)
diff hotspot
docs/runtime/networking/fetch.mdx               |  76 +++++-
 packages/bun-types/globals.d.ts                 |  92 +++++++
 src/event_loop/EventLoopTimer.rs                |   2 +
 src/http/HTTPContext.rs                         |   4 +
 src/http/Signals.rs                             |   9 +
 src/http/h2_client/ClientSession.rs             |   5 +
 src/http/h3_client/ClientSession.rs             |  11 +
 src/http/h3_client/callbacks.rs                 |   1 +
 src/http/lib.rs                                 |  13 +
 src/http_types/FetchRedirect.rs                 |   1 +
 src/jsc/AbortSignal.rs                          |   7 +-
 src/jsc/bindings/ErrorCode.cpp                  |   3 +
 src/jsc/bindings/webcore/AbortSignal.h          |   1 +
 src/runtime/dispatch.rs                         |  13 +
 src/runtime/webcore/fetch.rs                    | 149 +++++++++--
 src/runtime/webcore/fetch/FetchTasklet.rs       | 164 ++++++++++--
 test/js/web/fetch/fetch-http2-client.test.ts    |  14 +-
 test/js/web/fetch/fetch-timeout-options.test.ts | 325 ++++++++++++++++++++++++
 18 files changed, 836 insertions(+), 54 deletions(-)

gate history · 2 passed · 0 rejected · iteration 10

evidence per changed file
file                                       reads  edits  tests
docs/runtime/networking/fetch.mdx              5      5      0
packages/bun-types/globals.d.ts                9     11      0
src/event_loop/EventLoopTimer.rs               2      4      0
src/http/HTTPContext.rs                        4      4      0
src/http/Signals.rs                            2      9      0
src/http/h2_client/ClientSession.rs            7     11      0
src/http/h3_client/ClientSession.rs            2      3      0
src/http/h3_client/callbacks.rs                1      1      0
src/http/lib.rs                                8     15      0
src/http_types/FetchRedirect.rs                1      2      0
src/jsc/AbortSignal.rs                         1      1      0
src/jsc/bindings/ErrorCode.cpp                 1      1      0
src/jsc/bindings/webcore/AbortSignal.h         1      1      0
src/runtime/dispatch.rs                        2      4      0
src/runtime/webcore/fetch.rs                  11     15      0
src/runtime/webcore/fetch/FetchTasklet.rs     17     36      0
(+ 2 more files)

@robobun
robobun requested a review from alii as a code owner July 5, 2026 01:38
@github-actions github-actions Bot added the claude label Jul 5, 2026
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:30 PM PT - Jul 9th, 2026

@robobun, your commit 3baeef9 has 1 failures in Build #71122 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33338

That installs a local version of the PR into your bun-33338 executable, so you can run:

bun-33338 --bun

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds structured fetch timeout handling for connect, idle, and total deadlines, with new abort-reason plumbing, HTTP client state tracking, timer dispatch, docs, typings, and tests.

Changes

Fetch connect/idle timeout support

Layer / File(s) Summary
Public docs and TypeScript timeout contract
docs/runtime/networking/fetch.mdx, packages/bun-types/globals.d.ts
Documents timeout, connectTimeout, and socketTimeout forms and adds the corresponding request-init types.
Event loop timer tags and dispatch
src/event_loop/EventLoopTimer.rs, src/runtime/dispatch.rs
Adds fetch connect and total timer tags and routes both timer fires to the tasklet handlers.
ConnectionTimeout abort reason
src/http_types/FetchRedirect.rs, src/jsc/bindings/webcore/AbortSignal.h, src/jsc/bindings/ErrorCode.cpp, src/jsc/AbortSignal.rs
Adds a ConnectionTimeout abort reason and maps it to a timeout error.
HTTP connected-state and idle timeout plumbing
src/http/Signals.rs, src/http/lib.rs, src/http/AsyncHTTP.rs, src/http/HTTPThread.rs, src/http/HTTPContext.rs
Adds connected-state tracking, per-request idle timeout overrides, normalization helpers, startup normalization, and tunnel-reuse marking.
HTTP/2 and HTTP/3 connection marking
src/http/h2_client/ClientSession.rs, src/http/h3_client/ClientSession.rs, src/http/h3_client/callbacks.rs
Marks clients connected across h2/h3 attach, enqueue, and handshake paths, and updates h2 timeout rearming.
fetch() timeout argument parsing
src/runtime/webcore/fetch.rs
Parses timeout, connectTimeout, and socketTimeout values and threads the parsed values into FetchOptions.
FetchTasklet timeout timers and cleanup
src/runtime/webcore/fetch/FetchTasklet.rs
Adds connect and total timeout timers, cleanup and abort handling, and idle-timeout wiring into async HTTP options.
Fetch timeout option tests
test/js/web/fetch/fetch-timeout-options.test.ts
Adds coverage for connect, idle, and total behavior, overrides, disabled timers, and invalid arguments.

Possibly related PRs

  • oven-sh/bun#30376: Also changes the HTTP idle-timeout plumbing and stalled-connection timeout behavior around idle_timeout_seconds.
  • oven-sh/bun#33231: Also changes FetchTasklet request-abort handling in the same fetch timeout path.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR addresses the reported fetch timeout behavior with explicit timeout handling and regression tests.
Out of Scope Changes check ✅ Passed The changes stay focused on fetch timeout semantics, supporting types, runtime plumbing, docs, and tests.
Title check ✅ Passed The title clearly summarizes the main change: adding connect, socket, and whole-request fetch timeouts.
Description check ✅ Passed The description is detailed and includes the feature rationale, implementation notes, and verification results.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webcore/fetch.rs`:
- Around line 207-210: The doc comment above the socket idle timer logic in
fetch.rs exceeds the 3-line comment limit. Condense the explanation near the
idle timer rounding logic so it stays within three lines while preserving the
key point in the existing comment block around the timer sweep behavior.

In `@src/runtime/webcore/fetch/FetchTasklet.rs`:
- Around line 2287-2345: Reset the connect timeout state on each redirect hop in
FetchTasklet so a new TCP/TLS dial is not treated as already connected by
inherited Signals.connected. Update the redirect/reset path to clear or
reinitialize the connected flag before re-queuing the next request, and make
sure arm_connect_timeout and on_connect_timeout still govern the per-hop
deadline correctly. If the intended behavior is whole-request scope instead,
document that explicitly in the timeout/redirect flow.

In `@test/js/web/fetch/fetch-timeout-options.test.ts`:
- Around line 51-76: The test in fetch-timeout-options.test.ts includes a
vacuous stderr assertion that checks for no "panic", which should be removed.
Update the stalled-connect test around the Bun.spawn/fetch path to rely on
concrete assertions already present, such as the exact stdout string and
exitCode from the proc result, and drop the stderr containment check entirely or
replace it with a meaningful assertion tied to the observed timeout behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c9e0e56c-3e54-4dff-9379-97b0a271e3dc

📥 Commits

Reviewing files that changed from the base of the PR and between d9b2811 and a8d2f4f.

📒 Files selected for processing (18)
  • docs/runtime/networking/fetch.mdx
  • packages/bun-types/globals.d.ts
  • src/event_loop/EventLoopTimer.rs
  • src/http/AsyncHTTP.rs
  • src/http/HTTPContext.rs
  • src/http/HTTPThread.rs
  • src/http/Signals.rs
  • src/http/h2_client/ClientSession.rs
  • src/http/h3_client/ClientSession.rs
  • src/http/h3_client/callbacks.rs
  • src/http/lib.rs
  • src/http_types/FetchRedirect.rs
  • src/jsc/bindings/ErrorCode.cpp
  • src/jsc/bindings/webcore/AbortSignal.h
  • src/runtime/dispatch.rs
  • src/runtime/webcore/fetch.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • test/js/web/fetch/fetch-timeout-options.test.ts

Comment thread src/runtime/webcore/fetch.rs Outdated
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated
Comment thread test/js/web/fetch/fetch-timeout-options.test.ts Outdated
Comment thread test/js/web/fetch/fetch-timeout-options.test.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/runtime/webcore/fetch/FetchTasklet.rs (1)

825-830: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the new timeout comments within the 3-line limit.

These changed comment blocks exceed the repo rule; the same intent fits in shorter comments.

Proposed cleanup
-            // The request settled. `clear_data()` would get here eventually, but
-            // the tasklet outlives the settle by a turn of the event loop
-            // (`deinit` is re-dispatched to the JS thread), and a deadline firing
-            // in that window would abort an already-finished request.
+            // The tasklet outlives settlement by one event-loop turn; cancel now
+            // so a deadline cannot abort an already-finished request.
-    /// The `timeout.connect` deadline expired. Only a request still dialling
-    /// (no socket yet, or a TLS handshake that never completed) is cancelled;
-    /// once the transport is up the deadline is meaningless and the socket's
-    /// idle timer takes over.
-    ///
-    /// One-shot, so the deadline covers the first connection only. `Signals
-    /// .connected` is never cleared (see `HTTPClient::mark_connected`), so a
-    /// redirect that reopens a connection is not re-bounded.
+    /// Abort if `timeout.connect` expires before the first connection completes.
+    /// After `Connected` is set, the socket idle timer owns timeout handling;
+    /// redirects/retries are not re-bounded because this deadline is one-shot.

As per coding guidelines, "Keep code comments to 3 lines max".

Also applies to: 2314-2321

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/webcore/fetch/FetchTasklet.rs` around lines 825 - 830, The new
timeout comment block in FetchTasklet::deinit exceeds the repo’s 3-line comment
limit and should be shortened while preserving the same intent. Reword the
explanatory comment near is_done/cancel_connect_timeout() into a compact 1–3
line version, and apply the same cleanup to the other affected comment block
referenced in the review so the timeout rationale stays clear without extra
lines.

Source: Coding guidelines

src/http/lib.rs (1)

291-299: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Condense the new timeout doc comments to the 3-line limit.

Lines 291-299 and 3941-3951 exceed the repo’s comment-length rule; keep the durable invariant, but trim the prose.

Proposed cleanup
-/// Fit `raw` seconds to what the uSockets timers can actually represent.
-///
-/// uSockets' long-timeout counter is `% 240` minutes (`us_socket_long_timeout`
-/// in packages/bun-usockets/src/socket.c), so values above 239 min wrap around
-/// and fire early. Above 240s `socket.set_timeout` floors to whole minutes, so
-/// round up first — otherwise the armed timer is *shorter* than requested.
-/// Shared by the process-wide `BUN_CONFIG_HTTP_IDLE_TIMEOUT` (normalised once
-/// in `HTTPThread::on_start`) and the per-request `fetch({ timeout: { idle } })`
-/// override, so both arming paths see an already-representable value.
+/// Normalize idle timeouts to uSockets' representable range: max 239 minutes.
+/// Values above 240s are rounded up to whole minutes so the armed timer is
+/// never shorter than requested.
-    /// The transport is usable and the request is about to go out: TCP
-    /// connected, TLS handshaken for https, or a pooled socket handed straight
-    /// to us. This is where `timeout.connect` stops applying and the socket's
-    /// idle timer takes over, so it must be reached on *every* path that can
-    /// send a request — a path that forgets it spuriously times out a healthy
-    /// connection.
-    ///
-    /// Write-once by design: `timeout.connect` bounds the request's *first*
-    /// connection, not a later one reopened by `do_redirect` or a retry. Making
-    /// it per-hop would need the HTTP thread to tell the JS thread to re-arm its
-    /// deadline on every new dial, which no existing channel carries.
+    /// Mark the transport usable: TCP connected, TLS handshaken, or pooled.
+    /// This stops `timeout.connect` for the request's first connection; redirects
+    /// and retries are not re-bounded because no re-arm channel exists today.

As per coding guidelines, "Keep code comments to 3 lines max".

Also applies to: 3941-3951

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/http/lib.rs` around lines 291 - 299, The timeout documentation comment in
the HTTP idle-timeout path is too long and must be condensed to fit the repo’s
3-line comment limit. Trim the prose in the comment near the timer normalization
logic while preserving the key invariant about uSockets’ representable timeout
range and the shared normalization used by HTTPThread::on_start and fetch
timeout handling. Apply the same shortening to the other duplicated timeout
comment block so both remain brief and consistent.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/http/lib.rs`:
- Around line 291-299: The timeout documentation comment in the HTTP
idle-timeout path is too long and must be condensed to fit the repo’s 3-line
comment limit. Trim the prose in the comment near the timer normalization logic
while preserving the key invariant about uSockets’ representable timeout range
and the shared normalization used by HTTPThread::on_start and fetch timeout
handling. Apply the same shortening to the other duplicated timeout comment
block so both remain brief and consistent.

In `@src/runtime/webcore/fetch/FetchTasklet.rs`:
- Around line 825-830: The new timeout comment block in FetchTasklet::deinit
exceeds the repo’s 3-line comment limit and should be shortened while preserving
the same intent. Reword the explanatory comment near
is_done/cancel_connect_timeout() into a compact 1–3 line version, and apply the
same cleanup to the other affected comment block referenced in the review so the
timeout rationale stays clear without extra lines.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7c84a860-9064-4d9a-80e3-200dbe518cc2

📥 Commits

Reviewing files that changed from the base of the PR and between a8d2f4f and 42e2ae4.

📒 Files selected for processing (4)
  • src/http/lib.rs
  • src/runtime/webcore/fetch.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • test/js/web/fetch/fetch-timeout-options.test.ts

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

The two outside-diff findings are addressed in 37f331b.

Those comments were flagged because the previous commit added code-level documentation of the connect-timeout scope (which this review asked for), and that ran past the 3-line limit. Rather than fix only the two that were flagged, I swept the whole diff: every comment block it adds is now within the limit.

$ git diff main...HEAD -- '*.rs' | <scan for added comment runs > 3 lines>
over-limit comment blocks in the PR diff: 0

Nine blocks were trimmed, in lib.rs, Signals.rs, HTTPContext.rs, h2_client/ClientSession.rs and FetchTasklet.rs. The load-bearing invariants survive:

  • HTTPClient::mark_connected: "Must be reached on every path that can send a request; write-once, so only the first connection is bounded."
  • FetchTasklet::on_connect_timeout: "One-shot, and Signals.connected is never cleared, so a redirect that reopens a connection is not re-bounded."

cargo fmt --check and cargo check --workspace are clean, and bun bd test test/js/web/fetch/fetch-timeout-options.test.ts is still 14/14.

Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/http_types/FetchRedirect.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both nits from my last pass are addressed in fb3aa1c (the shared cancel_request_body helper and the AbortSignal.rs discriminant mapping) and I found nothing new — deferring only because this adds public API surface and threads a new timer through FetchTasklet across all three transports, which a maintainer should sign off on.

Extended reasoning...

Overview

This PR adds fetch({ timeout: { connect, idle } }), fixing #16682 (numeric timeout was a no-op) and adding a first-class connect deadline. It touches 19 files: fetch option parsing (fetch.rs), a new EventLoopTimer embedded in FetchTasklet with arm/cancel/fire lifecycle, a new Signals.connected atomic set from the HTTP thread at every transport-established point (h1 first_call, h2 adopt/enqueue/attach, h3 on_hsk_done, and the pooled-proxy-tunnel branch in HTTPContext), a per-request idle_timeout_seconds override plumbed through AsyncHTTP/HTTPClient with the h2 session's rearm_timeout reworked to take the max across multiplexed clients, a new CommonAbortReason::ConnectionTimeout variant across Rust/C++, docs, types, and 14 tests.

Since my last review, fb3aa1c extracted cancel_request_body(reason) so the connect-timeout abort path now cancels a streaming request body the same way abort_listener does, and added the explicit 3 => / 4 => arms plus a debug_assert! catch-all in AbortSignal::reason_if_aborted. Both of my prior findings are resolved; the bug hunter found nothing new on this revision.

Security risks

None identified. The new option only tightens deadlines (no new network reach, no auth/crypto surface). Input validation rejects negative/NaN/fractional values and clamps to u32::MAX. The connected atomic is a monotonic write-once flag used only to suppress an abort, so a missed mark_connected() call degrades to a spurious timeout rather than anything unsafe.

Level of scrutiny

High. This is production-critical HTTP client code with cross-thread coordination: a JS-thread EventLoopTimer node is embedded in a heap-allocated FetchTasklet and must be unlinked from the VM timer heap before the tasklet is freed. The PR handles this carefully (clear_data() calls cancel_connect_timeout() first, on_progress_update disarms on is_done, impl_timer_owner! provides the field-offset recovery), and the h2 rearm_timeout refactor changes shared-socket semantics. It also introduces new user-facing API whose shape (object vs. scalar, false vs. 0, whole-request vs. per-hop connect scope) is a design decision a maintainer should ratify.

Other factors

Test coverage is thorough (positive, negative-contract, argument validation, subprocess isolation for env-var overrides), the PR description documents deliberate limits and a regression sweep against main, and all prior review threads (CodeRabbit and mine) are resolved. My deferral is purely about scope — new public API + timer lifecycle in a hot path — not about any outstanding defect.

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green, the red lanes are infra

Summarising three builds so this doesn't have to be re-derived.

:darwin: * aarch64 - test-bun — Buildkite artifact-download timeout, no test runs.

INFO  Found 2 artifacts. Starting to download to: .../bun/bun/release
Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

The job dies before the test runner starts, so it cannot be reacting to anything in this diff. It is also flaky per-shard rather than deterministic: on build 68402 one darwin 26 aarch64 shard passed and the other failed this way, on the same commit.

:debian: 13 x64-asan - test-bun on build 68411 — flake.

That build ran on 42e2ae4, which changed zero functional lines of src/ (it was comments plus removing one test assertion):

$ git show 42e2ae4a83 -- 'src/**/*.rs' | grep -E "^[+-]" | grep -vE "^[+-]{3}|^[+-]\s*//|^[+-]\s*$"
(no output)

A no-op commit cannot change how SSL_read handles a failed per-loop buffer allocation. The failing assertion (tls-syscall-fault.test.ts, expects outOfMemory: true) is also one day old, added by #33326. The x64-asan lane is 20/20 green on both the commit that carries all the functional changes (a8d2f4f) and on current HEAD.

Where each lane actually stands

build sha x64-asan (20 shards) darwin aarch64 everything else
68402 a8d2f4f 20/20 pass 1 pass, 1 artifact-dl fail 282 jobs passed
68411 42e2ae4 1 flake (see above) cancelled
68422 fb3aa1c 20/20 pass 2 artifact-dl fails rest green / in flight

x64-asan is the lane that would catch a problem with the new EventLoopTimer embedded in FetchTasklet, and it is clean on current HEAD.

I'm not pushing a ci: retrigger: a fresh run would land on the same darwin agents, and the artifact-download timeout is an infra issue rather than something a re-roll fixes.

Ready for review

All review threads from CodeRabbit and claude[bot] are resolved, including two real findings in the last round: the connect-timeout abort path now cancels a streaming request body (fetch spec §abort-fetch step 5) via a shared cancel_request_body() helper, and AbortSignal::reason_if_aborted maps the new ConnectionTimeout discriminant back instead of silently folding it into ConnectionClosed.

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Build 68422 has now finished, which closes out the analysis above: 284 jobs passed, 2 failed, and both failures are the same :darwin: 26 aarch64 - test-bun artifact-download timeout. Neither job produced a single line of test output — the runner never starts:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).
🚨 Error: The command exited with status 1

The darwin-14 lanes that were still queued when I wrote the comment above have since gone green.

Not re-rolling: that shard has failed 3 of the 4 times it has run across these builds, so a fresh build has roughly a 1-in-16 chance of both shards clearing, and a retry doesn't fix an agent-side download timeout anyway. Happy to push one if a maintainer would rather see a fully green run.

The diff itself is green everywhere it actually executed, including all 20 x64-asan shards — the lane that would catch a problem with the new EventLoopTimer embedded in FetchTasklet.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation looks solid and every prior finding has been addressed, but this adds new public API surface to fetch() (timeout: { connect, idle, total }, plus a semantic change to what a bare timeout: N means) and embeds two EventLoopTimer nodes in the cross-thread FetchTasklet — both the API shape and the timer lifecycle deserve a maintainer's eyes before this lands.

Extended reasoning...

Overview

This PR adds per-phase timeouts to fetch(): connect (DNS+TCP+TLS), idle (socket inactivity), and — new since my last review, in 0c0826etotal (whole-request wall-clock). It touches 19 files across the HTTP client stack (src/http/lib.rs, h1/h2/h3 client sessions, Signals, HTTPThread), the event loop (EventLoopTimer, dispatch.rs), the fetch entry point (fetch.rs, FetchTasklet.rs), C++ abort-reason plumbing (ErrorCode.cpp, AbortSignal.h), and adds docs, types, and a 15-test file. All prior review threads (mine and CodeRabbit's) are resolved: the vacuous panic assertion, the 4-line comments, the missing request-body cancellation in on_connect_timeout, and the stale CommonAbortReason reverse mapping have all been addressed. The bug hunter found nothing this round.

Security risks

None material. Timeout values are validated (NaN/negative/fractional rejected, clamped to u32::MAX), the new atomic Signals.connected is write-once from the HTTP thread and read-only from the JS thread with acquire/release ordering, and no user-controlled input reaches anything security-sensitive. The change does not touch TLS verification, credential handling, or path parsing.

Level of scrutiny

High. This is not a mechanical change:

  • New public API design. The shape of { connect, idle, total }, and especially what a bare timeout: N means, is a user-facing contract. 0c0826e changed that from "sets both connect and idle" (still what the PR description says) to "means { total: N }" (what the code and docs now say). That's a reasonable choice — it matches Go's Client.Timeout and reqwest — but it's exactly the kind of decision .claude/docs/landing-prs.md reserves for maintainer agreement.
  • Memory-safety-sensitive lifecycle. Two EventLoopTimer nodes are now embedded in FetchTasklet, which is refcounted and freed after cross-thread completion. The PR handles this carefully (cancel_timeouts() at the top of clear_data() and on is_done in on_progress_update()), and the impl_timer_owner! container-recovery pattern matches existing usage, but the interaction between the JS-thread timer heap and the ThreadSafeRefCount teardown is subtle enough to warrant a maintainer's read.
  • Cross-transport behavior change. h2_client::rearm_timeout moved from a boolean any-client-wants-timeout to a max-of-all-clients aggregation. That's semantically different for multiplexed streams and worth a second opinion.

Other factors

Test coverage is good (positive, negative, and validation cases; verified fail-on-system-Bun), CI is green everywhere it actually ran, and the author has been thorough and responsive to every review comment. The PR description is slightly out of date relative to HEAD (it predates total and still describes the old scalar semantics). This is a well-executed feature PR — it just isn't in the "simple/mechanical/obvious" category that's safe to approve without a human.

@robobun robobun changed the title fetch: add per-request connect and idle timeouts fetch: add connectTimeout, socketTimeout, and a whole-request timeout Jul 6, 2026
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

The API shape is settled, and it changed

@claude[bot] deferred on exactly the right thing: the option shape was a design decision, not a mechanical one. It has since been made. Two maintainers weighed in, and the result is a different (better) API than the one reviewed above, so the PR title, body, docs and types have all been rewritten to match. Summarising, so nobody reviews the old shape:

Three flat sibling options, no nested object:

timeout?: boolean | number;      // whole-request wall-clock deadline
connectTimeout?: number | false; // DNS + TCP + the TLS handshake
socketTimeout?: number | false;  // inactivity in either direction

Three things moved since the last review:

  1. timeout is now a whole-request deadline, not a per-phase shorthand. This is the axis the original PR was missing entirely: socketTimeout cannot express "give up after N seconds no matter what", because a server trickling one byte every 29s re-arms it forever. A survey of 16 HTTP clients across 11 languages (in the PR body, every default read from source) found 11 of them have this axis. The four that don't are requests, httpx, undici and Net::HTTP, which happen to be the clients the original two-axis design was modelled on.

  2. The nested timeout: { ... } object is gone. Flat siblings are how every surveyed client spells these, and one way to say each thing beats two.

  3. idle became socketTimeout. Bun.serve({ idleTimeout }) is in seconds, so a millisecond idle sitting next to it in the same codebase was a units trap waiting to happen. socketTimeout matches Ktor's socketTimeoutMillis and got's socket, the two clients with identical bidirectional semantics.

timeout: false (and 0) keeps its existing meaning of disabling every timeout for the request, because that is the long-poll/SSE escape hatch people found in #16682. An explicit socketTimeout outranks it, so { timeout: false, socketTimeout: 30_000 } does what it reads like; there's a test pinning that.

On the other two concerns raised: the EventLoopTimer lifecycle is unchanged in shape (there are now two nodes rather than one, dropped together by cancel_timeouts(), which clear_data() — the only path that frees the tasklet — always reaches), and the h2 rearm_timeout max-of-all-clients aggregation is unchanged. Both still deserve the maintainer read they flagged.

$ bun bd test test/js/web/fetch/fetch-timeout-options.test.ts
 21 pass, 0 fail

$ USE_SYSTEM_BUN=1 bun test test/js/web/fetch/fetch-timeout-options.test.ts
 6 pass, 15 fail

bun-types passes, and fetch-abort-stream-body, fetch-keepalive, fetch-http2-client and bun-install-stalled-tls are all unchanged.

Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated
Comment thread packages/bun-types/globals.d.ts Outdated
Comment thread test/js/web/fetch/fetch-timeout-options.test.ts Outdated
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: diff green where it runs, darwin 26 aarch64 - test-bun is a Buildkite infra timeout

Build 69293 (sha 3adc6a3) fails on two :darwin: 26 aarch64 - test-bun shards, both for the same reason, and no test executes in either:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

The job dies before the runner starts, so it cannot be reacting to this diff. It is also per-shard flaky: it hit builds 68402 and 68422 as well, while 68411's run of the same commit family was green on that lane. Everything else on 69293 is passing or still in flight.

I've already spent my one self-serve re-roll (an earlier ci: retrigger, since folded into a real commit), and a fresh run lands on the same darwin agents, so I'm not pushing again: a retrigger doesn't fix an agent-side download timeout.

The diff is green on every lane that actually runs tests, including all x64-asan shards — the one that would catch a problem with the new EventLoopTimers in FetchTasklet. Ready for a maintainer; a merge re-runs CI on fresh agents anyway.

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Build 69293 has now finished: 281 jobs passed, 3 failed, all three on darwin-aarch64.

  • 2 × :darwin: 26 aarch64 — the Buildkite artifact-download timeout above; neither ran a single test.
  • 1 × :darwin: 14 aarch64test/integration/next-pages/test/dev-server.test.ts, where Puppeteer fails to open its Chrome DevTools WebSocket three times in a row (WebSocket connection to 'ws://127.0.0.1:56766/devtools/browser/...' failed: Connection ended). That's the browser launch, not anything in this diff's path: WebSocket doesn't route through FetchTasklet, and the new deadlines are only ever armed when a caller passes them.

x64-asan finished 19/20 passed, 1 expired (agent scheduling, not a test failure). This diff's own test file, fetch-timeout-options.test.ts, does not appear in any failure annotation on any build.

My one self-serve re-roll is spent, so I'm not pushing again. Ready for a maintainer; a merge re-runs CI on fresh agents.

@Jarred-Sumner
Jarred-Sumner force-pushed the farm/d548e3d0/fetch-granular-timeouts branch from 3adc6a3 to 55de3ef Compare July 7, 2026 10:01
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Rebased onto main (conflict-free, diff unchanged) to restart CI. But I don't think this fixes #16682 yet, and I'd hold it until it does.

#16682's reproduction is fetch(url, { timeout: <long> }) (or signal: AbortSignal.timeout(<long>)) still getting killed by Bun's 300s socket-idle default. Tracing that on this branch:

  • src/runtime/webcore/fetch.rs, in 'extract_timeout (~L912): a numeric timeout hits total_timeout_ms = ms; break 'extract_timeout; (~L934–941). That break exits the block before the only place idle_timeout_seconds is assigned (~L970, inside the socketTimeout arm).
  • So fetch(url, { timeout: 3_600_000 }) still leaves idle_timeout_seconds = None, the 300s default idle timer applies unchanged, and the request aborts at ~5 minutes — the exact reported failure. The reporter has to discover a brand-new option name (socketTimeout) to get what they asked for under the existing timeout name.
  • Correspondingly, none of the 14 tests in test/js/web/fetch/fetch-timeout-options.test.ts reproduce the issue. There's no test of the form: fetch(url, { timeout: LARGE }) against a server that stays quiet longer than BUN_CONFIG_HTTP_IDLE_TIMEOUT, asserting it succeeds instead of aborting at the idle default. That test would fail on this branch today, and it's the one that proves Respect timeout passed to fetch #16682 is fixed.

Given this also adds new user-facing option names (connectTimeout, socketTimeout), I'd suggest splitting: land the actual bugfix first — the existing timeout number should extend/override the idle deadline (or at minimum, an explicit user timeout/signal must not be preempted by the default idle timer) — with the reproducing test above. The new granular options can then be judged on their own as an API-design PR.

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

You're right, and thanks for the precise trace — that's exactly the gap. Fixed in 1dd3797.

A numeric timeout now also raises the socket-idle timer to match, so the default can never preempt the caller's explicit deadline:

total_timeout_ms = ms;
// #16682: the caller's deadline must not be preempted by the
// default socket-idle timer, so raise it to match. An explicit
// `socketTimeout` still overrides this below.
idle_timeout_seconds = Some(ms_to_idle_timeout_seconds(ms));

And the reproduction test you described, proven load-bearing by ablation (deleting the one-line fix makes it fail):

// https://github.com/oven-sh/bun/issues/16682
test("a numeric timeout longer than the socket-idle default is respected", async () => {
  // Process-wide socket-idle default is 1s; the request asks for 10 minutes.
  // Without the fix, the idle default preempts the caller's deadline at ~1–5s.
  await using proc = Bun.spawn({
    cmd: [bunExe(), "-e",
      `const r = await fetch(${JSON.stringify(server.url.href)}, { timeout: 600_000 });
       console.log(await r.text());`],
    env: { ...bunEnv, BUN_CONFIG_HTTP_IDLE_TIMEOUT: "1" },
    ...
$ <ablate the one line> && bun bd test ... -t "longer than the socket-idle default"
(fail) a numeric timeout longer than the socket-idle default is respected [6087ms]

$ <restore> && bun bd test ...
 21 pass, 0 fail

On signal: AbortSignal.timeout(N): that path doesn't reach 'extract_timeout at all — the signal is opaque, so there's no way to read N off it to raise the idle timer. That case still needs { timeout: false } or { socketTimeout: N } alongside the signal. If you want the signal case covered too (e.g. "any signal suppresses the default idle timer"), that's a separate, wider change and I'll do it as a follow-up.

On splitting: happy to do it either way.

  • Keep it together: the fix is now in this PR and the reproduction test proves it. One PR, closes Respect timeout passed to fetch #16682.
  • Split: I can open a minimal PR with just (a) the per-request idle_timeout_seconds override plumbing (HTTPClientAsyncHTTPFetchOptions), (b) the 'extract_timeout one-liner, and (c) this one test — roughly a 5-file diff. This PR then becomes pure API-design for connectTimeout/socketTimeout and the whole-request deadline, stacked on the bugfix.

Let me know which you'd prefer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/runtime/networking/fetch.mdx`:
- Around line 223-240: Update the `socketTimeout` note in the `fetch()` docs to
mention the undocumented upper clamp: very large values are silently capped by
the runtime (the `normalize_idle_timeout_seconds` behavior) to just under 240
minutes to avoid timer-wheel wraparound. Add a brief sentence near the existing
rounding/granularity explanation so users know oversized `socketTimeout` values
will be truncated rather than honored exactly.

In `@packages/bun-types/globals.d.ts`:
- Around line 1930-1965: Clarify the `FetchInit.timeout` documentation so
`@default true` no longer reads as contradictory to the prose in `globals.d.ts`.
Reword the `timeout` JSDoc to explicitly state that the default/unset value is
`true`, which means “use the built-in timeout behavior without a whole-request
deadline,” while numeric values set a whole-request deadline and `false`/`0`
disable all timeouts. Keep the behavior description aligned within the `timeout`
property block.

In `@src/http/h2_client/ClientSession.rs`:
- Around line 520-533: The rearm_timeout logic in ClientSession currently uses
max() across active streams and pending attachments, which incorrectly delays
socket idle expiration to the longest request timeout. Update rearm_timeout to
drive the socket from the earliest active idle deadline instead, and make sure
it still handles the shared H2 socket semantics in ClientSession without
breaking longer-lived siblings; if a single timeout value cannot express this
safely, add per-stream idle tracking or equivalent earliest-deadline scheduling
around stream_ref(...).client_ref(), pending_client_mut(...), and
self.socket.set_timeout().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2f252f6c-9995-4144-b100-c278657c2bf5

📥 Commits

Reviewing files that changed from the base of the PR and between 0c0826e and 55de3ef.

📒 Files selected for processing (19)
  • docs/runtime/networking/fetch.mdx
  • packages/bun-types/globals.d.ts
  • src/event_loop/EventLoopTimer.rs
  • src/http/AsyncHTTP.rs
  • src/http/HTTPContext.rs
  • src/http/HTTPThread.rs
  • src/http/Signals.rs
  • src/http/h2_client/ClientSession.rs
  • src/http/h3_client/ClientSession.rs
  • src/http/h3_client/callbacks.rs
  • src/http/lib.rs
  • src/http_types/FetchRedirect.rs
  • src/jsc/AbortSignal.rs
  • src/jsc/bindings/ErrorCode.cpp
  • src/jsc/bindings/webcore/AbortSignal.h
  • src/runtime/dispatch.rs
  • src/runtime/webcore/fetch.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • test/js/web/fetch/fetch-timeout-options.test.ts

Comment thread docs/runtime/networking/fetch.mdx
Comment thread packages/bun-types/globals.d.ts
Comment thread src/http/h2_client/ClientSession.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/http/h2_client/ClientSession.rs (1)

524-534: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Honor disable_timeout in rearm_timeout()
effective_idle_timeout_seconds() returns 0 for disabled requests, but max() treats that as a no-op, so any attached or pending client with disable_timeout can still inherit a sibling’s finite session timeout and be closed early. Track a disabled participant separately and disarm the socket timer when one is present.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/http/h2_client/ClientSession.rs` around lines 524 - 534, The timeout
rearm logic in rearm_timeout currently only uses max over
effective_idle_timeout_seconds(), so a client with disable_timeout can still
inherit a nonzero session timeout from other streams or pending attaches. Update
ClientSession::rearm_timeout to track whether any stream or pending client has
disable_timeout enabled (via stream_ref(...).client_ref() and
pending_client_mut(...)) and, if so, disarm or clear the socket timeout instead
of calling self.socket.set_timeout with a finite value; otherwise keep the
existing max-based timeout behavior.
src/runtime/webcore/fetch.rs (1)

936-944: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Document that a numeric timeout silently raises the effective socketTimeout.

This new branch raises idle_timeout_seconds to match total_timeout_ms whenever a numeric timeout is supplied, so the caller's whole-request deadline isn't preempted by the default 5-minute (or BUN_CONFIG_HTTP_IDLE_TIMEOUT) idle timer — this is the intended #16682 fix.

However, neither docs/runtime/networking/fetch.mdx nor packages/bun-types/globals.d.ts mentions this. Both currently document timeout and socketTimeout as fully independent axes, with socketTimeout defaulting to a fixed "5 minutes"/300_000. A caller who sets { timeout: 900_000 } (15 min) without touching socketTimeout will now get their idle-quiet protection silently relaxed from 5 minutes to 15 minutes to match — which is a meaningful, currently-undocumented change to the "no bytes moving" safety net callers may be relying on.

Recommend adding a sentence to the <Note> block in fetch.mdx and to the timeout/socketTimeout JSDoc in globals.d.ts clarifying that an explicit numeric timeout raises the effective socketTimeout to match unless socketTimeout is also set explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/webcore/fetch.rs` around lines 936 - 944, Document the
interaction between numeric timeout and socketTimeout in fetch: the current
fetch runtime branch in fetch.rs intentionally raises idle_timeout_seconds to
match total_timeout_ms when timeout is a number, so callers do not assume the
default idle timer stays at 5 minutes. Update the <Note> in
docs/runtime/networking/fetch.mdx and the timeout/socketTimeout JSDoc in
packages/bun-types/globals.d.ts to state that an explicit numeric timeout
silently raises the effective socketTimeout unless socketTimeout is also
provided explicitly. Refer to the fetch timeout handling and the
timeout/socketTimeout API docs so the change is easy to locate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/http/h2_client/ClientSession.rs`:
- Around line 524-534: The timeout rearm logic in rearm_timeout currently only
uses max over effective_idle_timeout_seconds(), so a client with disable_timeout
can still inherit a nonzero session timeout from other streams or pending
attaches. Update ClientSession::rearm_timeout to track whether any stream or
pending client has disable_timeout enabled (via stream_ref(...).client_ref() and
pending_client_mut(...)) and, if so, disarm or clear the socket timeout instead
of calling self.socket.set_timeout with a finite value; otherwise keep the
existing max-based timeout behavior.

In `@src/runtime/webcore/fetch.rs`:
- Around line 936-944: Document the interaction between numeric timeout and
socketTimeout in fetch: the current fetch runtime branch in fetch.rs
intentionally raises idle_timeout_seconds to match total_timeout_ms when timeout
is a number, so callers do not assume the default idle timer stays at 5 minutes.
Update the <Note> in docs/runtime/networking/fetch.mdx and the
timeout/socketTimeout JSDoc in packages/bun-types/globals.d.ts to state that an
explicit numeric timeout silently raises the effective socketTimeout unless
socketTimeout is also provided explicitly. Refer to the fetch timeout handling
and the timeout/socketTimeout API docs so the change is easy to locate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 240c4738-ab53-4b59-bda8-8494d37984cf

📥 Commits

Reviewing files that changed from the base of the PR and between 55de3ef and 2e9143f.

📒 Files selected for processing (5)
  • docs/runtime/networking/fetch.mdx
  • packages/bun-types/globals.d.ts
  • src/http/h2_client/ClientSession.rs
  • src/runtime/webcore/fetch.rs
  • test/js/web/fetch/fetch-timeout-options.test.ts

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Both outside-diff findings addressed in 6da956d.

h2 rearm_timeout and disable_timeout: this one follows directly from the max() argument I made last round, and I missed it. effective_idle_timeout_seconds() returns 0 for "disarm", not "zero seconds", so max(0, 60) = 60 meant a {timeout: false} stream was killed at a sibling's 60s — exactly the early-kill the aggregation is supposed to rule out. Treating 0 as unbounded (disarm the socket) restores "never kill a stream at a shorter sibling's deadline." The price is that a short per-request socketTimeout is not enforced while a {timeout: false} sibling shares the session; that's the same tradeoff already documented on the function, and it's the safer failure mode.

On whether this regresses from main: the original code armed at the process default if any client wasn't disabled, so a {timeout: false} stream already died at 300s with a non-disabled sibling. My previous max() could make that earlier (at the sibling's shorter per-request value). The fix makes it later (never), which is a change from main but in the direction {timeout: false} asked for.

timeout: N raises socketTimeout: documented in both the fetch.mdx Note and the timeout JSDoc. A caller who wants both bounds writes { timeout: 900_000, socketTimeout: 30_000 }; { timeout: 900_000 } alone relaxes the idle backstop to match, which is the #16682 fix.

Jarred-Sumner added a commit that referenced this pull request Jul 8, 2026
)

`fetch(url, { timeout: 60 * 60 * 1000 })` (or a long
`AbortSignal.timeout`) still aborted after ~5 minutes: Bun arms a socket
idle timer on every HTTP client socket, defaulting to 300 seconds
(`BUN_CONFIG_HTTP_IDLE_TIMEOUT`), and a numeric `timeout` was only ever
inspected for "is it 0/false" to decide whether to disable that timer.
Any positive value fell through to the global default, so slow responses
(LLM providers, long polls) died at 5 minutes no matter what deadline
the caller asked for.

That behavior was wrong because the *default* idle timer silently
preempted an *explicit* user deadline: the option existed, was accepted,
and then had no effect on when the request was killed. Callers who
wanted more than 5 minutes had to disable Bun's timeout entirely and
re-implement their own.

The fix stays inside the existing `timeout` option: when it is a finite
number > 0, the millisecond value is converted to seconds and threaded
as a per-request `idle_timeout_seconds` through `FetchOptions` ->
`AsyncHTTP` options -> `HTTPClient`, and
`HTTPClient::effective_idle_timeout_seconds()` (used by both the h1
`set_timeout` path and the h2 session's `rearm_timeout`, which arms the
longest deadline among the streams sharing the connection) prefers it
over the global default. `timeout: 0`/`false` still means "no timeout",
and non-finite/non-positive values keep today's behavior. The uSockets
normalisation (clamp to 239 minutes, round values above 240s up to a
whole minute) is factored into a shared
`normalize_idle_timeout_seconds()` used by both the env default and the
per-request override. This intentionally adds no new option names;
#33338 (granular connect/socket timeouts) is the broader API proposal
and can layer on top of this plumbing.

Fixes #16682

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
Squashed and rebased onto #33647, which landed the per-request
idle_timeout_seconds plumbing (HTTPClient/AsyncHTTP/FetchOptions,
effective_idle_timeout_seconds, normalize_idle_timeout_seconds, and the h2
rearm_timeout aggregation) along with the #16682 fix that a numeric timeout
extends the socket-idle deadline.

This PR keeps what is net-new on top of that:

  timeout         whole-request wall-clock deadline (EventLoopTimer)
  connectTimeout  DNS + TCP + TLS
  socketTimeout   socket inactivity in either direction

plus the supporting pieces: Signals.connected and mark_connected() across
h1/h2/h3 so connectTimeout knows when dialling ended; the two embedded
EventLoopTimers in FetchTasklet with dispatch, arm, cancel and on-fire handling;
CommonAbortReason::ConnectionTimeout so connectTimeout reports a distinct
message; the cancel_request_body() helper shared by every abort path so a
streaming request body is cancelled with the reason; docs, types, and a 21-test
suite.

Conflict resolution against #33647: took main for every shared hunk
(normalize/effective helpers, h2 rearm_timeout, AsyncHTTP::init normalisation
site). Two adjustments followed:

- timeout_ms_arg now treats Infinity as "no deadline", matching the semantic
  #33647 established and tests for.
- The h2 idle-aggregation test's "short explicit deadline" sub-cases switched
  {timeout: 1000} -> {socketTimeout: 1000}, since timeout now arms a
  per-request wall-clock deadline that is independent of the shared socket
  timer the test is exercising.
@robobun
robobun force-pushed the farm/d548e3d0/fetch-granular-timeouts branch from 6da956d to bef0c74 Compare July 9, 2026 18:26
Comment thread src/http/h2_client/ClientSession.rs Outdated
Comment thread test/js/web/fetch/fetch-timeout-options.test.ts Outdated
…err assertions

The rearm_timeout doc comment was describing the pre-rebase implementation
(disarm entirely when any client is unbounded), which the rebase discarded in
favour of main's floor-at-global-default behaviour. Since this PR no longer
touches the function body, it should not touch the doc comment either; reverted
to main's version so the function drops out of the diff.

The two .not.toContain("TimeoutError") assertions were the same dead-assertion
pattern as the .not.toContain("panic") removed in 42e2ae4: the preceding
.toEqual({stdout, exitCode}) already fails first on the regression they guard
against, so the stderr check is never reached.
@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI on the rebased sha (3baeef9, build 71122): 281 passed, x64-asan 20/20 green, none of this PR's test files in any annotation.

The one failure is proxy-stress-concurrent.test.ts on :darwin: 14 x64 — 1 request out of 1200 failing in two stress sub-cases. The only thing this diff adds to the proxy path unconditionally is a mark_connected() atomic store, which nothing reads when connectTimeout isn't set; the test passes 31/31 locally with this build. Load-sensitive flake on one shard.

Re-roll already spent; not pushing again.

Jarred-Sumner pushed a commit that referenced this pull request Jul 30, 2026
…er block (#36145)

A server that trickles one response-header byte at a time, each interval
shorter than the request's idle timeout, can keep a `fetch()` alive
indefinitely. The HTTP client re-arms its socket idle timer inside the
`short_read!` path of `handle_on_data_headers`, so every dripped byte
resets the clock. A fully silent stall in the same phase is already
bounded by the same timer (armed at `on_open`); only the drip defeats
it.

## Reproduction

```js
import net from "net";
const FULL = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok";
const server = net.createServer(sock => {
  sock.on("data", () => {});
  let i = 0;
  const iv = setInterval(() => {
    if (sock.destroyed) return clearInterval(iv);
    if (i < 10) sock.write(FULL[i++]);
    else { clearInterval(iv); sock.end(FULL.slice(i)); }
  }, 2000);
});
await new Promise(r => server.listen(0, "127.0.0.1", r));
await fetch(`http://127.0.0.1:${server.address().port}/`, { timeout: 5000 });
// 1.4.0-canary: resolves 200 after ~22s. With this change: TimeoutError at ~5-9s.
```

The same shape with no bytes written (silent stall) already rejects with
`TimeoutError: The operation timed out.` on the existing build, so
`AbortSignal.timeout()` is the only way to bound the drip case today.

## Change

- Drop the `set_timeout` call from `short_read!` in
`handle_on_data_headers`. The timer stays as armed by `on_open` /
`on_writable`, so it is an absolute deadline for the header block
(undici `headersTimeout` semantics).
- Gate the proxy-tunnel `on_data` re-arm on `response_stage == Body |
BodyChunk`, mirroring the non-proxy dispatch, so the same deadline holds
for HTTPS through a CONNECT proxy.
- Re-arm once right after `handle_response_metadata` succeeds so the
body phase starts with a fresh idle window rather than whatever was left
of the header deadline (folded from #36146). Body reads continue to
re-arm per chunk (undici `bodyTimeout` semantics).
- Update the `IDLE_TIMEOUT_SECONDS` doc comment to describe the new
header-phase behaviour.

The default deadline is unchanged (300 s /
`BUN_CONFIG_HTTP_IDLE_TIMEOUT` / per-request `timeout`), and `{timeout:
false}` still disables it.

## Verification

New test in `test/js/web/fetch/fetch.test.ts`: a raw `net.Server` drips
10 header bytes at 2 s each against `{timeout: 5000}` and must reject
with `TimeoutError`, then drips a 5-byte body after a burst header block
and must resolve with 200.

```
$ USE_SYSTEM_BUN=1 bun test test/js/web/fetch/fetch.test.ts -t "absolute deadline"
(fail)  header drip resolves {status: 200, body: "hello"} after 22011 ms

$ bun bd test test/js/web/fetch/fetch.test.ts -t "absolute deadline"
(pass)  [18250 ms]
```

`bun-install-stalled-tls.test.ts`, `fetch-keepalive.test.ts`, the
adjacent "explicit numeric \`timeout\` extends the socket idle deadline"
test, and `proxy.test.ts` / `proxy-stress-lifecycle.test.ts` /
`proxy-stress-matrix.test.ts` (496 proxy tests total, including the
trickled-tunnel-bytes cases) all pass on the debug build.

## Scope

HTTP/1 only. Related: #33338 adds `connectTimeout` / `socketTimeout` /
whole-request `timeout` as per-request options with no change to the
header-phase re-arm; this change is independent and composes with it. A
`headersTimeout` option distinct from the body-phase idle value can
follow once the per-request plumbing in #33338 lands.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/web/fetch/fetch.test.ts

<!-- robobun:evidence:end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Respect timeout passed to fetch

2 participants