Skip to content

fetch: let an explicit timeout extend the socket idle deadline - #33647

Merged
Jarred-Sumner merged 7 commits into
mainfrom
claude/fetch-timeout-honors-idle
Jul 8, 2026
Merged

fetch: let an explicit timeout extend the socket idle deadline#33647
Jarred-Sumner merged 7 commits into
mainfrom
claude/fetch-timeout-honors-idle

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

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

fetch(url, { timeout: ms }) previously only distinguished "disabled"
(0/false) from "enabled": any positive value still left the socket on the
global 300s idle timer (BUN_CONFIG_HTTP_IDLE_TIMEOUT), so a request that
legitimately goes quiet for longer than 5 minutes aborted even when the
caller asked for a longer deadline.

Thread the millisecond value (when finite and > 0) through FetchOptions ->
AsyncHTTP options -> HTTPClient.idle_timeout_seconds, and have the h1
set_timeout and h2 rearm_timeout paths use the per-request value when
present (for a shared h2 session, the longest among attached streams).
timeout: 0/false still disables the timer entirely, and non-finite or
non-positive numbers keep today's behavior. The uSockets normalisation
(clamp to 239 min, round >240s up to a whole minute) moves into a shared
normalize_idle_timeout_seconds() used by both the env default and the
per-request override.

Fixes #16682
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fa17239e-6f60-4da3-80aa-962dea237d83

📥 Commits

Reviewing files that changed from the base of the PR and between 5d1c4fe and 9972962.

📒 Files selected for processing (2)
  • src/runtime/webcore/fetch.rs
  • test/js/web/fetch/fetch.test.ts

Walkthrough

This PR adds a per-request idle timeout option to fetch. A timeout?: number | boolean property is added to BunFetchRequestInit, converted into seconds and threaded through FetchOptions, FetchTasklet, AsyncHTTP::Options, and HTTPClient, with a shared normalization helper used by both the fetch path and the global BUN_CONFIG_HTTP_IDLE_TIMEOUT default. The HTTP/2 client now computes shared socket timeout from per-client effective values, and regression tests cover both fetch and HTTP/2 behavior.

Changes

Per-request idle timeout support

Layer / File(s) Summary
Public API type
packages/bun-types/globals.d.ts
Adds `timeout?: number
Idle-timeout normalization helper
src/http/lib.rs, src/http/HTTPThread.rs
Adds normalize_idle_timeout_seconds to clamp and round raw seconds to uSockets timer bounds; HTTPThread::on_start now calls this helper instead of inlining the clamping math for the global default.
HTTPClient effective timeout selection
src/http/lib.rs
Adds idle_timeout_seconds: Option<c_uint> and effective_idle_timeout_seconds() to HTTPClient, and updates set_timeout to select between disabled, per-request override, and global default values.
AsyncHTTP options wiring
src/http/AsyncHTTP.rs
Adds idle_timeout_seconds to Options, defaults it to None on client construction, and normalizes and propagates the value into HTTPClient during AsyncHTTP::init.
Fetch option parsing and wiring
src/runtime/webcore/fetch.rs, src/runtime/webcore/fetch/FetchTasklet.rs
Parses the timeout fetch option into idle_timeout_seconds, adds the field to FetchOptions with a None default, and passes it through FetchTasklet::get() into AsyncHTTP::Options.
HTTP/2 shared session timer
src/http/h2_client/ClientSession.rs
Rewrites rearm_timeout to compute the shared socket timeout as the max effective idle timeout across streams and pending clients, handling unbounded clients and applying the global idle timeout as a lower bound.
Regression tests
test/js/web/fetch/fetch.test.ts, test/js/web/fetch/fetch-http2-client.test.ts
Adds tests verifying timeout interacts correctly with the global idle-timeout default for plain fetch and coalesced HTTP/2 sessions.

Possibly related PRs

  • oven-sh/bun#30376: Also changes HTTP/2 client socket idle-timeout arming and BUN_CONFIG_HTTP_IDLE_TIMEOUT handling, overlapping with the shared timeout logic here.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: explicit fetch timeouts now extend the socket idle deadline.
Description check ✅ Passed The description explains the fix and how it works, and it mentions verification through added tests, so it is mostly complete.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator
Updated 8:17 PM PT - Jul 7th, 2026

@robobun, your commit 9972962 has 2 failures in Build #70083 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33647

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

bun-33647 --bun

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fetch: add connectTimeout, socketTimeout, and a whole-request timeout #33338 - Also fixes Respect timeout passed to fetch #16682 by threading a per-request idle timeout through the fetch/HTTPClient stack; is a superset that additionally adds connectTimeout and a whole-request timeout

🤖 Generated with Claude Code

Comment thread src/runtime/webcore/fetch.rs Outdated
Comment thread src/http/h2_client/ClientSession.rs
Comment thread src/http/lib.rs
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

✅ All review follow-ups landed through 9972962. fetch.test.ts and fetch-http2-client.test.ts pass on every CI lane; remaining red on build 70083 is known Windows flake (bake/dev-and-prod HMR timing, napi.test.ts gcUntil) unrelated to this change. Ready for a maintainer.

… stream shares it

rearm_timeout computes the shared socket's idle deadline as the max of
every attached client's effective_idle_timeout_seconds(). A
disable_timeout client contributes 0 there, so a sibling's short
explicit override (e.g. {timeout:1000}) armed the socket at 1s and
on_long_timeout -> session.on_close failed every stream on the session,
including the one whose caller passed {timeout:false}. Before
per-request overrides the same mix armed at the global default, so the
no-timeout stream had that as a floor.

Track whether any attached client has disable_timeout and, when one
coexists with siblings that want a timer, floor the max at the global
idle_timeout_seconds() so the no-timeout stream keeps the same lower
bound it had before. When every client opted out the max is still 0 and
the timer stays disarmed.

Test covers both halves of the h2 aggregate: a long explicit override
extends the session past a tiny global default, and a {timeout:false}
stream survives a sibling's 1s explicit override.
Comment thread src/http/h2_client/ClientSession.rs Outdated
…:false} stream is present; declare fetch() timeout option in BunFetchRequestInit

When BUN_CONFIG_HTTP_IDLE_TIMEOUT=0, idle_timeout_seconds() is 0 and
want.max(0) is a no-op, so the floor added in 9782070 gave no
protection: a {timeout:false} h2 stream was still armed at (and killed
by) a coalesced sibling's short explicit override. Pre-per-request-
override the same mix armed at idle_timeout_seconds()=0 -> set_timeout(0)
= disarmed; restore that by disarming explicitly when any_disabled and
the global default is 0. Covered by a third scenario in the h2 aggregate
test (global=0, {timeout:false} + {timeout:1000} both survive a 10s
hold).

Also declare timeout?: number | boolean on BunFetchRequestInit so the
now-meaningful numeric semantics are discoverable.
Comment thread src/http/h2_client/ClientSession.rs Outdated
…onds()==0, not just disable_timeout

A plain fetch() (no timeout option) under BUN_CONFIG_HTTP_IDLE_TIMEOUT=0
has disable_timeout=false and idle_timeout_seconds=None, so its
effective_idle_timeout_seconds() is 0 ("no timeout") but the previous
any_disabled gate keyed only on the flag and missed it. A coalesced
sibling with a short explicit {timeout:N} then armed the shared socket
and on_close failed both, the same class already fixed for explicit
{timeout:false} in 9782070/dd30609.

Key the gate on the computed value instead so any client whose effective
deadline is "none" is protected regardless of how it got there. The
global=0 test scenario now includes a plain fetch alongside the explicit
{timeout:false} to cover both.

@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

Caution

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

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

514-550: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the aggregation into a small helper for readability.

rearm_timeout now mixes traversal (streams + pending_attach), a stateful closure, and the "unbounded floor" post-processing in one function. A small helper like fn effective_session_timeout(&self) -> c_uint returning (want, any_unbounded) (or the final clamped value) would separate "compute" from "apply" and make the floor logic easier to unit-test in isolation.

🤖 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 514 - 550, The timeout
re-arming logic in rearm_timeout is doing both aggregation over
streams/pending_attach and the final global-floor adjustment in one place, which
hurts readability and testability. Extract the computation into a small helper
such as effective_session_timeout (or a similar private method) that returns the
final clamped timeout or the intermediate state needed for clamping, then keep
rearm_timeout focused on calling self.socket.set_timeout with that result.
Preserve the current behavior for stream_ref, pending_client_mut, and
crate::idle_timeout_seconds while moving the “any_unbounded”/floor logic into
the helper.
🤖 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 `@packages/bun-types/globals.d.ts`:
- Around line 2088-2092: The fetch timeout behavior and the `timeout` docs are
inconsistent: `fetch(url, { timeout: NaN })` and `Infinity` currently disable
the idle timer because `fetch` parsing in `src/runtime/webcore/fetch.rs` treats
`timeout.to_int32() == 0` as disabled, while `packages/bun-types/globals.d.ts`
says non-finite values use the default. Update the `fetch` timeout parser to
treat non-finite values as the default deadline, or adjust the
`RequestInit.timeout` docs in `globals.d.ts` to match the actual
`fetch`/`RequestInit` behavior.

In `@test/js/web/fetch/fetch-http2-client.test.ts`:
- Around line 1968-1976: The test leaves parent-side hold timers pending after
early child exit, which can keep the process alive and leak callbacks across
tests. Update the relevant fetch HTTP/2 test cases around makeH2Server,
server.close(), and the 10s setTimeout usage to track each pending timer and
clear them during cleanup. Wrap the test flow in try/finally so the timer
cleanup and server shutdown always run, even when assertions fail.

In `@test/js/web/fetch/fetch.test.ts`:
- Around line 2887-2939: Move the idle-timeout regression out of fetch.test.ts
into a new standalone fetch regression file under test/js/web/fetch/ following
the fetch-*.test.ts pattern. Keep the existing scenario around the explicit
numeric timeout behavior, the Bun.spawn subprocess, and the
BUN_CONFIG_HTTP_IDLE_TIMEOUT-dependent setup, but relocate the test so the
monolithic fetch suite stays smaller and this config-heavy multi-second case is
isolated. Use the same test body and helpers like Bun.serve, Bun.spawn, and the
timeout assertions, just place them in a dedicated scenario-specific test file.

---

Outside diff comments:
In `@src/http/h2_client/ClientSession.rs`:
- Around line 514-550: The timeout re-arming logic in rearm_timeout is doing
both aggregation over streams/pending_attach and the final global-floor
adjustment in one place, which hurts readability and testability. Extract the
computation into a small helper such as effective_session_timeout (or a similar
private method) that returns the final clamped timeout or the intermediate state
needed for clamping, then keep rearm_timeout focused on calling
self.socket.set_timeout with that result. Preserve the current behavior for
stream_ref, pending_client_mut, and crate::idle_timeout_seconds while moving the
“any_unbounded”/floor logic into the helper.
🪄 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: a1d7fff1-b9b9-40df-980e-5666de85cfa6

📥 Commits

Reviewing files that changed from the base of the PR and between 3f5d816 and d764a60.

📒 Files selected for processing (9)
  • packages/bun-types/globals.d.ts
  • src/http/AsyncHTTP.rs
  • src/http/HTTPThread.rs
  • src/http/h2_client/ClientSession.rs
  • src/http/lib.rs
  • src/runtime/webcore/fetch.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • test/js/web/fetch/fetch-http2-client.test.ts
  • test/js/web/fetch/fetch.test.ts

Comment thread packages/bun-types/globals.d.ts Outdated
Comment thread test/js/web/fetch/fetch-http2-client.test.ts
Comment thread test/js/web/fetch/fetch.test.ts
… hold timers in h2 idle-deadline test cleanup

The parser's to_int32()==0 path means NaN/Infinity disable the idle
timer (ToInt32 of non-finite is 0), so document that rather than
claiming non-finite uses the default.

Track the per-request hold timers in the h2 aggregate test and clear
them in finally so an early child exit does not leave 10s callbacks
pending past server.close().
Comment thread packages/bun-types/globals.d.ts
JSValue::to_int32() is JSC's coerceJSValueDoubleTruncatingT, which
saturates +/-Infinity to i32::MAX/MIN (not spec ToInt32's 0), so
{timeout: Infinity} previously fell through to the global default
instead of meaning "never time out". Gate the disable decision on
!ms.is_finite() as well so non-finite values uniformly disable, making
the BunFetchRequestInit.timeout JSDoc accurate and giving Infinity the
intuitive semantics. Covered by a new withInfinity case in the h1
idle-deadline test.
@Jarred-Sumner
Jarred-Sumner merged commit 3353737 into main Jul 8, 2026
78 of 80 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/fetch-timeout-honors-idle branch July 8, 2026 08:24
robobun added a commit that referenced this pull request Jul 9, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Respect timeout passed to fetch

4 participants