Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 73 additions & 3 deletions docs/runtime/networking/fetch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -166,14 +166,84 @@ When using streams with S3:

### Fetching a URL with a timeout

To fetch a URL with a timeout, use `AbortSignal.timeout`:
The simplest deadline is `timeout`, a number of milliseconds: the whole request
must finish within it, or it rejects.

```ts
const response = await fetch("http://example.com", {
signal: AbortSignal.timeout(1000),
// Fails if the request isn't completely done within 5 seconds.
await fetch("http://example.com", { timeout: 5_000 });
```

One deadline for everything is a blunt instrument, though. A large download
legitimately takes minutes, but nothing should spend minutes opening a socket.
Two more options bound the individual phases.

```ts
const response = await fetch("http://example.com/large-file", {
// Give up if the connection isn't established within 5 seconds.
connectTimeout: 5_000,
// ...but let the download take as long as it needs, as long as bytes keep
// arriving at least every 2 minutes.
socketTimeout: 120_000,
});
```

| Option | Covers | Default |
| ---------------- | ---------------------------------------------------------------------- | ----------- |
| `timeout` | The whole request, from `fetch()` until the body finishes. | No deadline |
| `connectTimeout` | DNS resolution, the TCP handshake, and the TLS handshake for `https:`. | No deadline |
| `socketTimeout` | Any stretch where no bytes move in either direction, in any phase. | 5 minutes |

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

`socketTimeout` and `timeout` answer different questions, and you often want
both:

- `socketTimeout` fires when the connection goes quiet. It is re-armed on every
byte in either direction, so a response that trickles in steadily never trips
it.
- `timeout` fires regardless of activity. It is the one that catches a server
that is technically alive but far too slow.

```ts
// Bail if the server goes quiet for 30s, AND never spend more than 2 minutes.
await fetch("http://example.com/", { socketTimeout: 30_000, timeout: 120_000 });
```

`timeout: false` (or `0`) disables _every_ timeout for the request, which is what
you want for long-polling and server-sent events:

```ts
await fetch("http://example.com/events", { timeout: false });
```

<Note>

`connectTimeout` applies to the initial connection, not to one reopened while
following a redirect. It and `timeout` are both measured from the moment
`fetch()` is called, so time spent queued behind the [simultaneous connection
limit](#simultaneous-connection-limit) counts against them.

`socketTimeout` is rounded up to whole seconds (and to whole minutes above 240
seconds) because that is the resolution of the underlying socket timer, which may
also fire up to one tick early, and values above 239 minutes are clamped to that
ceiling. Treat it as a coarse backstop rather than a precise deadline; `timeout`
and `connectTimeout` have millisecond precision. Its default can be changed
process-wide with the `BUN_CONFIG_HTTP_IDLE_TIMEOUT` environment variable, in
seconds.

A numeric `timeout` also raises the effective `socketTimeout` to match unless
`socketTimeout` is set explicitly, so the socket-idle default can never preempt
the deadline you wrote. `{ timeout: 900_000, socketTimeout: 30_000 }` keeps both
bounds; `{ timeout: 900_000 }` alone relaxes the idle backstop to 15 minutes.
Setting `socketTimeout` explicitly outranks `timeout: false`, so
`{ timeout: false, socketTimeout: 30_000 }` keeps the socket timer you asked for.

</Note>
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#### Canceling a request

To cancel a request, use an `AbortController`:
Expand Down
92 changes: 92 additions & 0 deletions packages/bun-types/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1927,6 +1927,98 @@ interface BunFetchRequestInit extends RequestInit {
*/
tls?: BunFetchRequestInitTLS;

/**
* Milliseconds the whole request may take, measured from the moment `fetch()`
* is called until the response body has finished. A hard wall-clock deadline:
* it fires even while bytes are actively arriving, so a server that trickles
* data forever is still cut off. Rejects with a
* `TimeoutError: The operation timed out.`
*
* The equivalent of `Client.Timeout` in Go, `timeout` in reqwest and
* `libcurl`, `callTimeout` in OkHttp, and `timeoutIntervalForResource` in
* `URLSession`.
*
* Defaults to `true`, which means "use the other timeout defaults with no
* whole-request deadline": {@link socketTimeout} is still on, but there is no
* overall wall-clock limit unless you pass a number here.
*
* A number also raises the effective {@link socketTimeout} to at least that
* value unless `socketTimeout` is set explicitly, so the socket-idle default
* can never fire ahead of the deadline you wrote.
*
* `false` (or `0`) disables *every* timeout for this request, including
* {@link socketTimeout}.
*
* Not part of the Fetch API specification.
*
* @default true
*
* @example
* ```js
* // The whole request must finish within 5 seconds.
* const response = await fetch("https://example.com/", { timeout: 5_000 });
* ```
*
* @example
* ```js
* // Never time out: useful for long-polling or server-sent events.
* const response = await fetch("https://example.com/events", { timeout: false });
* ```
*/
timeout?: boolean | number;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* Milliseconds to spend establishing the connection (DNS resolution, the TCP
* handshake, and the TLS handshake for `https:`) before the request rejects
* with a `TimeoutError: The connection timed out.` The distinct message lets a
* retry policy tell "the network is down" from "the server is slow".
*
* Applies to the initial connection, not to one reopened while following a
* redirect. Measured from the moment `fetch()` is called, so time spent queued
* behind `BUN_CONFIG_MAX_HTTP_REQUESTS` in-flight requests counts against it.
*
* `0` or `false` means no connect deadline.
*
* Not part of the Fetch API specification.
*
* @default false
*
* @example
* ```js
* // Fail fast on a dead network, but allow a slow response body.
* await fetch("https://example.com/large", {
* connectTimeout: 5_000,
* socketTimeout: 120_000,
* });
* ```
*/
connectTimeout?: number | false;

/**
* Milliseconds the socket may sit with no bytes moving in either direction
* before the request rejects with a `TimeoutError: The operation timed out.`
* The timer is re-armed on every read and every write, so a response that
* trickles in steadily never trips it. Use {@link timeout} when you need a
* deadline that fires regardless of activity.
*
* This is `socketTimeoutMillis` in Ktor and `socket` in `got`. It is the
* equivalent of `read`/`bodyTimeout` elsewhere, except that it also covers a
* stalled upload.
*
* Rounded up to whole seconds (and to whole minutes above 240 seconds)
* because that is the resolution of the underlying socket timer, which may
* also fire up to one tick early. It is a coarse backstop, not a precise
* deadline.
*
* `0` or `false` means no socket deadline. Setting it explicitly outranks
* `timeout: false`.
*
* Not part of the Fetch API specification.
*
* @default 300_000 (overridable process-wide with `BUN_CONFIG_HTTP_IDLE_TIMEOUT`, in seconds)
*/
socketTimeout?: number | false;

/**
* Log the raw HTTP request and response to stdout, as a debugging aid.
* This API may be removed in a future version of Bun without notice.
Expand Down
2 changes: 2 additions & 0 deletions src/event_loop/EventLoopTimer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ pub enum Tag {
DevServerSweepSourceMaps,
DevServerMemoryVisualizerTick,
AbortSignalTimeout,
FetchConnectTimeout,
FetchTotalTimeout,
DateHeaderTimer,
BunTest,
EventLoopDelayMonitor,
Expand Down
4 changes: 4 additions & 0 deletions src/http/HTTPContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,10 @@ impl<const SSL: bool> HTTPContext<SSL> {
// the centralised [`proxy_tunnel::raw_as_mut`] backref upgrade.
crate::proxy_tunnel::raw_as_mut(raw).adopt::<SSL>(client, sock);
client.on_open::<SSL>(sock)?;
// With `SSL` this branch never reaches `first_call` (the tunnel
// is established and `on_open` only calls it for plain TCP), so
// mark the connect phase done here. Redundant when `!SSL`.
client.mark_connected();
client.on_writable::<true, SSL>(sock);
} else {
client.on_open::<SSL>(sock)?;
Expand Down
9 changes: 9 additions & 0 deletions src/http/Signals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ pub struct Signals {
pub aborted: Option<NonNull<AtomicBool>>,
pub cert_errors: Option<NonNull<AtomicBool>>,
pub upgraded: Option<NonNull<AtomicBool>>,
/// Set once the transport is established (h1 socket open, h2 session adopted,
/// h3 handshake done). Read from the JS thread by the `connectTimeout`
/// deadline to tell "still dialing" from "connected".
pub connected: Option<NonNull<AtomicBool>>,
pub body_receive_mode: Option<NonNull<AtomicU8>>,
}

Expand Down Expand Up @@ -59,6 +63,7 @@ impl Signals {
Field::Aborted => self.aborted,
Field::CertErrors => self.cert_errors,
Field::Upgraded => self.upgraded,
Field::Connected => self.connected,
}?;
Some(bun_ptr::BackRef::from(ptr))
}
Expand Down Expand Up @@ -89,6 +94,7 @@ pub struct Store {
pub aborted: AtomicBool,
pub cert_errors: AtomicBool,
pub upgraded: AtomicBool,
pub connected: AtomicBool,
pub body_receive_mode: AtomicU8,
}

Expand All @@ -100,6 +106,7 @@ impl Default for Store {
aborted: AtomicBool::new(false),
cert_errors: AtomicBool::new(false),
upgraded: AtomicBool::new(false),
connected: AtomicBool::new(false),
body_receive_mode: AtomicU8::new(BodyReceiveMode::AutoPause as u8),
}
}
Expand All @@ -113,6 +120,7 @@ impl Store {
aborted: Some(NonNull::from(&self.aborted)),
cert_errors: Some(NonNull::from(&self.cert_errors)),
upgraded: Some(NonNull::from(&self.upgraded)),
connected: Some(NonNull::from(&self.connected)),
body_receive_mode: None,
}
}
Expand Down Expand Up @@ -156,4 +164,5 @@ pub enum Field {
Aborted,
CertErrors,
Upgraded,
Connected,
}
5 changes: 5 additions & 0 deletions src/http/h2_client/ClientSession.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,9 @@ impl ClientSession {

pub fn adopt(&mut self, client: &mut HTTPClient) {
client.h2_register_abort_tracker(self.socket);
// The session's socket is already established, so this request is past
// the connect phase even while it waits for SETTINGS in `pending_attach`.
client.mark_connected();
// Park instead of attaching when (a) we're inside onData's deliver
// loop — attach() mustn't mutate `streams` under iteration — or (b)
// the server's first SETTINGS hasn't arrived yet, so the real
Expand Down Expand Up @@ -332,6 +335,7 @@ impl ClientSession {
/// is routed via the session socket so `abortByHttpId` can find it.
pub fn enqueue(&mut self, client: &mut HTTPClient<'_>) {
client.h2_register_abort_tracker(self.socket);
client.mark_connected();
self.pending_attach.push(client.as_erased_ptr().as_ptr());
self.rearm_timeout();
}
Expand Down Expand Up @@ -397,6 +401,7 @@ impl ClientSession {
/// DATA, and flush.
pub fn attach(&mut self, client: &mut HTTPClient) {
debug_assert!(self.has_headroom());
client.mark_connected();

let send_window = i32::try_from(self.remote_initial_window_size.min(wire::MAX_WINDOW_SIZE))
.expect("int cast");
Expand Down
11 changes: 11 additions & 0 deletions src/http/h3_client/ClientSession.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,22 @@ impl ClientSession {
self.ref_();

if self.handshake_done {
client.mark_connected();
// handshake_done implies qsocket is Some and valid.
self.qsocket_mut().unwrap().make_stream();
}
}

/// The QUIC handshake landed: every request parked on this connection is
/// past the connect phase, so `connectTimeout` must stop applying to them.
pub fn mark_pending_connected(&mut self) {
for &stream_ptr in self.pending.iter() {
if let Some(client) = stream_mut(stream_ptr).client {
client_mut(client).mark_connected();
}
}
}

pub fn stream_body_by_http_id(&mut self, async_http_id: u32, ended: bool) {
for &stream_ptr in self.pending.iter() {
let stream = stream_mut(stream_ptr);
Expand Down
1 change: 1 addition & 0 deletions src/http/h3_client/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ extern "C" fn on_hsk_done(qs: *mut quic::Socket, ok: c_int) {
return;
}
session.handshake_done = true;
session.mark_pending_connected();
for _ in 0..session.pending.len() {
qs.make_stream();
}
Expand Down
13 changes: 13 additions & 0 deletions src/http/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1963,6 +1963,10 @@ impl<'a> HTTPClient<'a> {
}

pub fn first_call<const IS_SSL: bool>(&mut self, socket: HttpSocket<IS_SSL>) {
// Reached from `on_open` for plain TCP and from `on_handshake` for TLS,
// so `connectTimeout` covers DNS + TCP + the TLS handshake.
self.mark_connected();

if FeatureFlags::IS_FETCH_PRECONNECT_SUPPORTED {
if self.flags.is_preconnect_only {
self.on_preconnect::<IS_SSL>(socket);
Expand Down Expand Up @@ -4115,6 +4119,15 @@ impl<'a> HTTPClient<'a> {
socket.set_timeout(self.effective_idle_timeout_seconds());
}

/// Transport usable (TCP connected, TLS handshaken, or pooled): where
/// `connectTimeout` stops applying. Must be reached on *every* path that can
/// send a request; write-once, so only the first connection is bounded.
#[inline]
pub fn mark_connected(&self) {
self.signals
.store(signals::Field::Connected, true, Ordering::Release);
}

fn maybe_pause_receive<const IS_SSL: bool>(&mut self, socket: HttpSocket<IS_SSL>) {
if self.state.flags.receive_paused
|| self.proxy_tunnel.is_some()
Expand Down
1 change: 1 addition & 0 deletions src/http_types/FetchRedirect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ pub enum CommonAbortReason {
Timeout = 1,
UserAbort = 2,
ConnectionClosed = 3,
ConnectionTimeout = 4,
Comment thread
robobun marked this conversation as resolved.
}
7 changes: 6 additions & 1 deletion src/jsc/AbortSignal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,12 @@ impl AbortSignal {
return Some(AbortReason::Common(match reason {
1 => CommonAbortReason::Timeout,
2 => CommonAbortReason::UserAbort,
_ => CommonAbortReason::ConnectionClosed,
3 => CommonAbortReason::ConnectionClosed,
4 => CommonAbortReason::ConnectionTimeout,
_ => {
debug_assert!(false, "unknown CommonAbortReason discriminant {reason}");
CommonAbortReason::ConnectionClosed
}
}));
}
if js_reason.is_empty() {
Expand Down
3 changes: 3 additions & 0 deletions src/jsc/bindings/ErrorCode.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1782,6 +1782,9 @@ JSC::JSValue WebCore::toJS(JSC::JSGlobalObject* globalObject, CommonAbortReason
case CommonAbortReason::ConnectionClosed: {
return createDOMException(globalObject, ExceptionCode::AbortError, "The connection was closed."_s);
}
case CommonAbortReason::ConnectionTimeout: {
return createDOMException(globalObject, ExceptionCode::TimeoutError, "The connection timed out."_s);
}
default: {
break;
}
Expand Down
1 change: 1 addition & 0 deletions src/jsc/bindings/webcore/AbortSignal.h
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ enum class CommonAbortReason : uint8_t {
Timeout,
UserAbort,
ConnectionClosed,
ConnectionTimeout,
};

JSC::JSValue toJS(JSC::JSGlobalObject*, CommonAbortReason);
Expand Down
13 changes: 13 additions & 0 deletions src/runtime/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,19 @@ pub unsafe fn __bun_fire_timer(t: *mut EventLoopTimer, now: *const ElTimespec, v
timer_arm!(Subprocess<'_>, event_loop_timer, |c, _now, _vm| (*c)
.timeout_callback())
}
EventLoopTimerTag::FetchConnectTimeout => {
// SAFETY: §Dispatch — tag set together with the container in
// `FetchTasklet::get`; `t` is the tasklet's `connect_timeout_timer`.
let container = unsafe { FetchTasklet::from_connect_timeout_timer_ptr(t) };
// SAFETY: per fn contract.
unsafe { (*container).on_connect_timeout() };
}
EventLoopTimerTag::FetchTotalTimeout => {
// SAFETY: §Dispatch — `t` is the tasklet's `total_timeout_timer`.
let container = unsafe { FetchTasklet::from_total_timeout_timer_ptr(t) };
// SAFETY: per fn contract.
unsafe { (*container).on_total_timeout() };
}
EventLoopTimerTag::DevServerSweepSourceMaps => {
// `sweep_weak_refs` takes the raw `*EventLoopTimer` and recovers
// the store inside.
Expand Down
Loading
Loading