Skip to content
18 changes: 10 additions & 8 deletions docs/runtime/networking/fetch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ import { fetch } from "bun";
fetch.preconnect("https://bun.com");
```

Calling `fetch` immediately after `fetch.preconnect` does not make your request faster. Preconnecting only helps when there's a gap between knowing the host and sending the request.
Calling `fetch` immediately after `fetch.preconnect` does not make your request faster. Preconnecting only helps when there's a gap between knowing the host and sending the request. The connection waits in the keep-alive pool, which reuses an idle connection for 4 seconds (see below), so a gap longer than that loses it.

#### Preconnect at startup

Expand All @@ -473,6 +473,8 @@ bun --fetch-preconnect https://bun.com ./my-script.ts

Bun automatically reuses connections to the same host. This is called **connection pooling**, and it can significantly reduce the time spent establishing connections.

A server closes an idle connection on its own schedule, and a request sent at that moment fails with `ECONNRESET`. Bun only retries an idempotent request (`GET`, `HEAD`, `PUT`, `DELETE`, ...) on a new connection, never a `POST`. So Bun stops reusing an idle connection before the server's deadline. When the response has a `Keep-Alive: timeout=N` header, the connection is reused for `N - 2` seconds after the response, and closed instead of pooled when that is zero. Without the header, the connection is reused for 4 seconds. These are the same numbers Node's `fetch` uses.

#### Fetch sessions

A `Bun.FetchSession` holds connection settings and owns a keep-alive pool. `session.fetch` is `fetch` with that session, and it is bound, so it works wherever a `fetch` function is accepted (`new SomeClient({ fetch: session.fetch })`). It has no `preconnect`. `fetch(url, { session })` does the same through the global `fetch`. Bun never shares a connection between two sessions, or between a session and plain `fetch`.
Expand All @@ -488,18 +490,18 @@ const response = await session.fetch("https://example.com");
session.close(); // closes the idle connections
```

| Option | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tls` | The same options as `fetch`'s `tls`. `checkServerIdentity` runs once per connection. |
| `proxy` | The same values as `fetch`'s `proxy`, including `false`. |
| `keepAlive` | `false` closes each connection after its response. `idleTimeout` is in seconds (default 300). `maxIdleSockets` caps the idle connections per kind (plain, TLS, Unix socket). Neither limit applies to HTTP/3 connections. |
| `unix` | A Unix socket path. |
| Option | Description |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tls` | The same options as `fetch`'s `tls`. `checkServerIdentity` runs once per connection. |
| `proxy` | The same values as `fetch`'s `proxy`, including `false`. |
| `keepAlive` | `false` closes each connection after its response. `idleTimeout` is in seconds. `maxIdleSockets` caps the idle connections per kind (plain, TLS, Unix socket). Neither limit applies to HTTP/3 connections. |
| `unix` | A Unix socket path. |

An option given on the request takes precedence over the session's, `proxy: false` included. A request's `tls` replaces the session's `tls` as a whole.

The constructor throws for a `proxy` or a `tls.checkServerIdentity` that it cannot use, such as `proxy: 8080`, a `proxy` object without a `url`, or a `checkServerIdentity` that is not a function. For `proxy`, only `undefined`, `null` and `""` mean no option. On a request, `fetch` ignores such a `proxy` (`true` throws), and the request keeps the proxy of its session or of the environment.

Bun rounds `idleTimeout` up to its socket timer, which moves in 4 second steps up to four minutes and in whole minutes beyond that. A connection is never closed before `idleTimeout`, and it can stay open for up to two steps longer. The longest `idleTimeout` is 238 minutes. A larger value means 238 minutes.
`idleTimeout` is the longest a connection of the session is reused after it sat idle, rounded up to whole seconds. It is exact. It replaces the 4 second default for a server that sends no `Keep-Alive` header, so a value above 4 brings back the race described above for that server, and the server's `Keep-Alive: timeout=N` still lowers it to `N - 2`. Without `idleTimeout`, a hint above 600 seconds counts as 600. An HTTP/2 connection has no `Keep-Alive` header and announces its close with a GOAWAY frame, so it is reused for `idleTimeout` seconds (300 without one). The longest `idleTimeout` is 238 minutes. A larger value means 238 minutes. A connection that is no longer reused is closed by a coarse timer soon after: within 8 seconds up to four minutes of idle time, and within two minutes beyond that.

Without `maxIdleSockets` a session has no limit of its own. The idle connections of all sessions and of plain `fetch` share one bounded pool per kind. When that pool is full, Bun closes the longest-idle connection in it, whichever session it belongs to. A `maxIdleSockets` above the size of the pool has no effect.

Expand Down
18 changes: 11 additions & 7 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9110,14 +9110,18 @@ declare module "bun" {
| boolean
| {
/**
* Seconds an idle connection stays in the pool before it is closed.
* The socket timer is coarse: it moves in 4 second steps up to four
* minutes, and in whole minutes beyond that. Bun rounds the value up
* to the timer, so a connection is never closed before `idleTimeout`,
* and it can stay open for up to two steps longer. The longest is
* 238 minutes (14280), and a larger value means that.
* Seconds an idle connection is reused for, rounded up to a whole
* second. After that it is no longer handed to a request, and a
* coarse timer closes it soon after (within 8 seconds up to four
* minutes of idle time, within two minutes beyond that). The
* longest is 238 minutes (14280), and a larger value means that.
Comment thread
robobun marked this conversation as resolved.
Resolved
*
* @default 300
* Without it, an HTTP/1.1 connection is reused for the server's
* `Keep-Alive: timeout=N` minus 2 seconds (at most 600), or for 4
* seconds when the server sends no such header, and an HTTP/2
* connection for 300 seconds. `idleTimeout` replaces the 4 second
* default and the 300 seconds, and the server's hint still lowers
* it.
*/
idleTimeout?: number | undefined;
/**
Expand Down
2 changes: 1 addition & 1 deletion src/http/AsyncHTTP.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ unsafe fn free_owned_href(href: &'static [u8]) {

/// Read the HTTP-thread monotonic timer in nanoseconds.
#[inline]
fn http_thread_timer_read() -> u64 {
pub(crate) fn http_thread_timer_read() -> u64 {
crate::http_thread().timer.elapsed().as_nanos() as u64
}

Expand Down
100 changes: 88 additions & 12 deletions src/http/HTTPContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,13 @@
pub(crate) h2_session: Option<RefPtr<h2::ClientSession>>,
/// `PoolOptions::id` of the fetch session that opened it.
pub(crate) pool_id: u64,
/// `PoolOptions::idle_timeout_seconds` of the session that parked it.
pub(crate) idle_timeout_seconds: u32,
/// Seconds this socket may be handed out for after it was parked (see
/// [`HTTPContext::reuse_window_seconds`]). Also what the idle close timer
/// is armed from.
pub(crate) reuse_seconds: u32,
/// `http_thread().timer` reading (ns) past which `find_in` closes this
/// socket instead of handing it out.
pub(crate) reuse_deadline_ns: u64,
}

/// `&mut` access to a pooled / found-slot HTTP/2 session, for the field
Expand Down Expand Up @@ -624,8 +629,11 @@
/// tunnel. The pool takes ownership of one strong ref on the tunnel;
/// the caller must NOT deref it afterwards. If pooling fails (pool
/// full, hostname too long, socket bad), the tunnel is dereffed here.
///
/// `keepalive_hint_seconds` is the `timeout=N` of the response's
/// `Keep-Alive` header, when it sent one.
#[allow(clippy::too_many_arguments)]
pub(crate) fn release_socket(

Check warning on line 636 in src/http/HTTPContext.rs

View workflow job for this annotation

GitHub Actions / mordant

`http_context::HTTPContext::<SSL>::release_socket` is generic over `SSL` and is compiled 2 times in this crate, but one part of it, 40 of its 374 statements (as MIR), does not use `SSL`
&mut self,
socket: HTTPSocket<SSL>,
did_have_handshaking_error_while_reject_unauthorized_is_false: bool,
Expand All @@ -640,6 +648,7 @@
h2_session: Option<RefPtr<h2::ClientSession>>,
unix_path: &[u8],
pool: crate::PoolOptions,
keepalive_hint_seconds: Option<u32>,
) {
// log("releaseSocket(0x{f})", .{bun.fmt.hexIntUpper(@intFromPtr(socket.socket))});

Expand All @@ -654,7 +663,11 @@
debug_assert!(!hostname.is_empty());
debug_assert!(transport == Transport::Unix || port > 0);

if hostname.len() <= MAX_KEEPALIVE_HOSTNAME
let reuse_seconds =
Self::reuse_window_seconds(pool, keepalive_hint_seconds, h2_session.is_some());

if reuse_seconds > 0
&& hostname.len() <= MAX_KEEPALIVE_HOSTNAME
&& !(socket.is_closed() || socket.is_shutdown() || socket.get_error() != 0)
&& socket.is_established()
{
Expand Down Expand Up @@ -687,7 +700,9 @@
proxy_auth_hash,
h2_session,
pool_id: pool.id,
idle_timeout_seconds: pool.idle_timeout_seconds,
reuse_seconds,
reuse_deadline_ns: http::http_thread_timer_read()
.saturating_add(u64::from(reuse_seconds) * 1_000_000_000),
Comment thread
robobun marked this conversation as resolved.
Resolved
};
let parked = match transport {
Transport::Tcp => Self::park(self.pending_sockets.get_or_init(), pooled, pool),
Expand Down Expand Up @@ -770,17 +785,57 @@
ActiveSocket::<SSL>::init(slot.addr().as_ptr().cast_const()),
);
socket.flush();
Self::arm_idle_timeout(socket, options.idle_timeout_seconds);
Self::arm_idle_timeout(socket, pooled.reuse_seconds);
slot.write(pooled);
None
}

/// How long a parked socket may sit idle; 0 is the 5 minute default.
fn arm_idle_timeout(socket: HTTPSocket<SSL>, idle_timeout_seconds: u32) {
socket.set_timeout(match idle_timeout_seconds {
0 => 5 * 60,
seconds => seconds,
});
/// Seconds a socket parked now may be handed out for. 0 means do not
/// park it.
///
/// The origin closes an idle connection on its own schedule, and a
/// request written at that moment fails without a retry unless it is
/// idempotent. `Keep-Alive: timeout=N` announces the schedule: the window
/// is `N` minus [`http::KEEPALIVE_HINT_MARGIN_SECONDS`] (undici
/// `keepAliveTimeoutThreshold`). Without a hint it is
/// [`http::DEFAULT_REUSE_SECONDS`] (undici `keepAliveTimeout`). A session
/// `idleTimeout` replaces the default and caps the hint. Only a session
/// without one takes undici's [`http::MAX_KEEPALIVE_HINT_SECONDS`] cap.
///
/// HTTP/2 has no `Keep-Alive` header (RFC 9113 section 8.2.2), and an
/// origin announces the close of an idle connection with GOAWAY, which
/// `on_idle_data` records. So a session is not torn down every 4 s: its
/// window is the session `idleTimeout` or
/// [`http::DEFAULT_H2_REUSE_SECONDS`].
fn reuse_window_seconds(

Check warning on line 810 in src/http/HTTPContext.rs

View workflow job for this annotation

GitHub Actions / mordant

`http_context::HTTPContext::<SSL>::reuse_window_seconds` is generic over `SSL` and is compiled 2 times in this crate, but one part of it, 33 of its 33 statements (as MIR), does not use `SSL`
pool: crate::PoolOptions,
keepalive_hint_seconds: Option<u32>,
is_h2: bool,
) -> u32 {
let session = match pool.idle_timeout_seconds {
0 => None,
seconds => Some(seconds),
};
if is_h2 {
return session.unwrap_or(http::DEFAULT_H2_REUSE_SECONDS);
}
let hint =
keepalive_hint_seconds.map(|n| n.saturating_sub(http::KEEPALIVE_HINT_MARGIN_SECONDS));
match (session, hint) {
(Some(s), Some(h)) => s.min(h),
Comment thread
robobun marked this conversation as resolved.
Resolved
(Some(s), None) => s,
(None, Some(h)) => h.min(http::MAX_KEEPALIVE_HINT_SECONDS),
(None, None) => http::DEFAULT_REUSE_SECONDS,
Comment thread
robobun marked this conversation as resolved.
Resolved
}
}

/// Arm the close timer of a parked socket. The timer wheel is coarse and
/// the value is padded so it never fires before `reuse_seconds`; the
/// handout check in `find_in` is the exact bound.
fn arm_idle_timeout(socket: HTTPSocket<SSL>, reuse_seconds: u32) {
socket.set_timeout(http::normalize_idle_timeout_seconds(u64::from(
reuse_seconds,
)));
}

#[allow(clippy::too_many_arguments)]
Expand Down Expand Up @@ -840,6 +895,7 @@
transport,
pool_id,
} = *key;
let now_ns = http::http_thread_timer_read();
let mut iter = pool.used.iterator::<true, true>();

while let Some(pending_socket_index) = iter.next() {
Expand Down Expand Up @@ -921,6 +977,15 @@
continue;
}

// Past its reuse window: the origin closes it any moment now,
// and a request written onto it cannot be replayed unless it
// is idempotent. Retire it with a FIN and dial a new one.
if now_ns >= socket.reuse_deadline_ns {
bun_core::scoped_log!(HTTPContext, "Keep-Alive reuse window over");
Self::close_socket(http_socket);
continue;
}

// `HTTPThread::drain_events` hands a socket out before the loop
// polls it, so input the origin already wrote is unread in the
// kernel and invisible to the checks above; reuse answers this
Expand Down Expand Up @@ -1452,7 +1517,7 @@
if handshake_success {
if let Some(pooled) = active.pooled_mut() {
// Allow pooled sockets to be reused if the handshake was successful.
HTTPContext::<SSL>::arm_idle_timeout(socket, pooled.idle_timeout_seconds);
HTTPContext::<SSL>::arm_idle_timeout(socket, pooled.reuse_seconds);
return;
}
}
Expand Down Expand Up @@ -1569,6 +1634,17 @@
HTTPContext::<SSL>::mark_socket_as_dead(socket);
h2::ClientSession::on_timeout(session);
}
if tagged.is::<PooledSocket<SSL>>() {
// An idle keep-alive socket we are done with: a FIN, so the
// origin does not observe a reset for a connection nothing was
// wrong with. This was a RST while no timer bounded a graceful
// TLS close started from the timeout handler; uSockets now gives
// the close_notify wait `US_SSL_CLOSE_AFTER_SPILL_TIMEOUT`. The
// FIN leaves this side in TIME_WAIT, as in node and undici.
bun_core::scoped_log!(HTTPContext, "Keep-Alive idle timeout");
HTTPContext::<SSL>::close_socket(socket);
return;
}

HTTPContext::<SSL>::terminate_socket(socket);
}
Expand Down
4 changes: 4 additions & 0 deletions src/http/InternalState.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ pub struct InternalState<'a> {
/// Prefix of `compressed_body` the decoder has already taken.
compressed_body_consumed: usize,
pub(crate) content_length: Option<usize>,
/// `timeout=N` of the response's `Keep-Alive` header: how long the origin
/// keeps the connection open after this response.
pub(crate) keepalive_timeout_seconds: Option<u32>,
pub(crate) total_body_received: usize,
// Self-borrow into `original_request_body.bytes`; `RawSlice` carries the
// outlives-holder invariant (the backing `original_request_body` is a
Expand Down Expand Up @@ -134,6 +137,7 @@ impl Default for InternalState<'_> {
compressed_body: MutableString::init_empty(),
compressed_body_consumed: 0,
content_length: None,
keepalive_timeout_seconds: None,
total_body_received: 0,
request_body: bun_ptr::RawSlice::EMPTY,
original_request_body: HTTPRequestBody::Bytes(b""),
Expand Down
1 change: 1 addition & 0 deletions src/http/h2_client/ClientSession.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1175,6 +1175,7 @@ impl ClientSession {
Some(self_ref),
b"",
self.pool,
None,
);
} else {
NewHTTPContext::<true>::close_socket(self.socket);
Expand Down
Loading
Loading