Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
32 changes: 32 additions & 0 deletions docs/runtime/networking/fetch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -260,11 +260,42 @@ const response = await fetch("http://example.com", {
// Disable connection reuse for this request
keepalive: false,

// How many redirects to follow (default: 20, maximum: 126)
maxRedirects: 5,

// Debug logging level
verbose: true, // or "curl" for more detailed output
});
```

### Redirects

By default, `fetch` follows up to 20 redirects, the limit the [fetch specification](https://fetch.spec.whatwg.org/#http-redirect-fetch) mandates. The 21st redirect rejects with a `TypeError`:

```ts
try {
await fetch("http://example.com/a-very-long-redirect-chain");
} catch (error) {
error instanceof TypeError; // true
error.code; // "TooManyRedirects"
}
```

Use the `maxRedirects` option to raise or lower the limit. It accepts any integer from `0` (reject the first redirect) through `126`; larger values are clamped to `126`.

```ts
// Follow at most 5 redirects
await fetch("http://example.com/", { maxRedirects: 5 });
```

The standard `redirect` option works as the specification describes. `"manual"` returns the redirect response itself, and `"error"` rejects with a `TypeError` whose `code` is `"UnexpectedRedirect"`.

```ts
const response = await fetch("http://example.com/", { redirect: "manual" });
response.status; // 302
response.headers.get("location"); // the redirect target
```
Comment thread
robobun marked this conversation as resolved.
Outdated

### Protocol support

Beyond HTTP(S), Bun's fetch supports several additional protocols:
Expand Down Expand Up @@ -335,6 +366,7 @@ Bun's fetch implementation includes several specific error cases:
- Using the `proxy` and `unix` options together throws an error
- TLS certificate validation failures when `rejectUnauthorized` is true (or undefined)
- S3 operations may throw specific errors related to authentication or permissions
- Redirect failures reject with a `TypeError` carrying a `code` property, such as `"TooManyRedirects"` or `"UnexpectedRedirect"`

### Content-Type handling

Expand Down
10 changes: 7 additions & 3 deletions packages/bun-types/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2070,11 +2070,15 @@ interface BunFetchRequestInit extends RequestInit {

/**
* The maximum number of redirects to follow when `redirect` is `"follow"`.
* If the response chain redirects more than this many times, the request
* rejects with a "too many redirects" error.
* Once the chain redirects more than this many times, the request rejects
* with a `TypeError` whose `code` is `"TooManyRedirects"`.
* Not part of the Fetch API specification.
*
* @default 126
* `0` rejects the first redirect rather than meaning "unlimited". Values
* above the maximum of `126` are clamped to `126`. The default matches the
* 20-redirect limit the Fetch specification mandates.
*
* @default 20
* @example
* ```js
* const response = await fetch("https://example.com/", { maxRedirects: 3 });
Expand Down
10 changes: 5 additions & 5 deletions src/http/AsyncHTTP.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ use bun_picohttp as picohttp;

use crate::headers::{self, Headers};
use crate::{
FetchRedirect, Flags, HTTPClient, HTTPRequestBody, HTTPVerboseLevel, InternalState, Method,
Signals, ThreadlocalAsyncHTTP,
DEFAULT_REDIRECT_COUNT, FetchRedirect, Flags, HTTPClient, HTTPRequestBody, HTTPVerboseLevel,
InternalState, Method, Signals, ThreadlocalAsyncHTTP,
};
use crate::{HTTPClientResult, HTTPClientResultCallback};

Expand Down Expand Up @@ -165,9 +165,7 @@ fn make_client<'a>(
url,
connected_url: URL::default(),
verbose: HTTPVerboseLevel::None,
// Note: DEFAULT_REDIRECT_COUNT (= 127) is crate-private in lib.rs;
// duplicated as a literal here.
remaining_redirect_count: 127,
remaining_redirect_count: DEFAULT_REDIRECT_COUNT,
allow_retry: false,
h2_retries: 0,
redirect_type,
Expand Down Expand Up @@ -484,6 +482,8 @@ impl<'a> AsyncHTTP<'a> {
this.client.flags.disable_decompression = val;
}
if let Some(val) = options.max_redirects {
// +1: the stored budget includes the terminal check; see
// `DEFAULT_REDIRECT_COUNT`. Clamped to 126 so the result fits `i8`.
Comment thread
robobun marked this conversation as resolved.
Outdated
this.client.remaining_redirect_count = (val.min(126) + 1) as i8;
}
if let Some(val) = options.disable_keepalive {
Expand Down
14 changes: 12 additions & 2 deletions src/http/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,15 @@ pub static EXPERIMENTAL_HTTP3_CLIENT_FROM_CLI: AtomicBool = AtomicBool::new(fals

const MAX_REDIRECT_URL_LENGTH: usize = 128 * 1024;

/// Default budget for `HTTPClient::remaining_redirect_count`.
///
/// <https://fetch.spec.whatwg.org/#http-redirect-fetch> step 5: "If request's
/// redirect count is 20, then return a network error." `do_redirect` decrements
/// the budget and then fails on 0, so the stored value is one larger than the
/// number of redirects actually followed: 20 are followed and the 21st redirect
/// response rejects with `TooManyRedirects`.
Comment thread
robobun marked this conversation as resolved.
Outdated
pub(crate) const DEFAULT_REDIRECT_COUNT: i8 = 20 + 1;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/// The static is exported to
/// C++ via `BUN_DEFAULT_MAX_HTTP_HEADER_SIZE`; `AtomicUsize` has the same
/// size/alignment as `usize` so the symbol layout is unchanged.
Expand Down Expand Up @@ -2634,8 +2643,9 @@ impl<'a> HTTPClient<'a> {
self.hostname = None;
}

// TODO: should this check be before decrementing the redirect count?
// the current logic will allow one less redirect than requested
// Decrement-then-check means a budget of N allows N-1 follows, so every
// initializer stores one more than the redirect limit it grants; see
// `DEFAULT_REDIRECT_COUNT`.
Comment thread
robobun marked this conversation as resolved.
Outdated
if self.remaining_redirect_count == 0 {
self.fail(crate::Error::TooManyRedirects);
return;
Expand Down
12 changes: 12 additions & 0 deletions src/jsc/SystemError.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ pub type Maybe<R> = core::result::Result<R, SystemError>;
// is ABI-identical to a non-null `JSGlobalObject*` with write provenance.
unsafe extern "C" {
safe fn SystemError__toErrorInstance(this: &SystemError, global: &JSGlobalObject) -> JSValue;
safe fn SystemError__toTypeErrorInstance(
this: &SystemError,
global: &JSGlobalObject,
) -> JSValue;
safe fn SystemError__toErrorInstanceWithInfoObject(
this: &SystemError,
global: &JSGlobalObject,
Expand All @@ -91,6 +95,14 @@ impl SystemError {
SystemError__toErrorInstance(&self, global)
}

/// Like [`to_error_instance`](Self::to_error_instance), but the instance
/// is a JS `TypeError` with the same `code`/`path`/`errno`/... properties.
/// The fetch spec maps network errors to `TypeError`, which callers
/// feature-detect via `instanceof`. Consumes `self` for the same reason.
Comment thread
robobun marked this conversation as resolved.
Outdated
pub fn to_type_error_instance(self, global: &JSGlobalObject) -> JSValue {
SystemError__toTypeErrorInstance(&self, global)
}

/// Like `to_error_instance` but populates the error's stack trace with async
/// frames from the given promise's await chain. Use when creating an error
/// from native code at the top of the event loop (threadpool callback) to
Expand Down
17 changes: 15 additions & 2 deletions src/jsc/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2353,7 +2353,7 @@ JSC::EncodedJSValue JSGlobalObject__createOutOfMemoryError(JSC::JSGlobalObject*
return JSValue::encode(exception);
}

JSC::EncodedJSValue SystemError__toErrorInstance(const SystemError* arg0, JSC::JSGlobalObject* globalObject)
static JSC::EncodedJSValue systemErrorToErrorInstance(const SystemError* arg0, JSC::JSGlobalObject* globalObject, JSC::ErrorType errorType)
{
SystemError err = *arg0;

Expand All @@ -2367,7 +2367,7 @@ JSC::EncodedJSValue SystemError__toErrorInstance(const SystemError* arg0, JSC::J

auto& names = WebCore::builtinNames(vm);

JSC::JSObject* result = createError(globalObject, ErrorType::Error, message);
JSC::JSObject* result = createError(globalObject, errorType, message);

auto clientData = WebCore::clientData(vm);

Expand Down Expand Up @@ -2426,6 +2426,19 @@ JSC::EncodedJSValue SystemError__toErrorInstance(const SystemError* arg0, JSC::J
return JSC::JSValue::encode(result);
}

JSC::EncodedJSValue SystemError__toErrorInstance(const SystemError* arg0, JSC::JSGlobalObject* globalObject)
{
return systemErrorToErrorInstance(arg0, globalObject, ErrorType::Error);
}

// Same property layout as `SystemError__toErrorInstance` (`code`, `path`,
// `errno`, ...) but the instance is a `TypeError`. The fetch specification
// requires network errors to reject with a `TypeError`.
Comment thread
robobun marked this conversation as resolved.
Outdated
JSC::EncodedJSValue SystemError__toTypeErrorInstance(const SystemError* arg0, JSC::JSGlobalObject* globalObject)
{
return systemErrorToErrorInstance(arg0, globalObject, ErrorType::TypeError);
}

JSC::EncodedJSValue SystemError__toErrorInstanceWithInfoObject(const SystemError* arg0, JSC::JSGlobalObject* globalObject)
{
SystemError err = *arg0;
Expand Down
1 change: 1 addition & 0 deletions src/jsc/bindings/headers.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/runtime/webcore/Body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,11 @@ pub enum Tag {
pub enum ValueError {
AbortReason(CommonAbortReason),
SystemError(SystemError),
/// Like `SystemError` (same `code`/`path`/`errno` properties) but the
/// instance is a JS `TypeError`. The fetch spec maps every "network
/// error" to TypeError; use this over `TypeError` when callers also
/// rely on the error's machine-readable `code`.
Comment thread
robobun marked this conversation as resolved.
Outdated
SystemTypeError(SystemError),
Message(BunString),
/// Surfaces as a JS `TypeError`. The fetch spec maps every "network
/// error" to TypeError, so use this for fetch-layer rejections that
Expand All @@ -579,6 +584,7 @@ impl ValueError {
match self {
// The bun.String fields are dropped by the assignment below.
ValueError::SystemError(_system_error) => {}
ValueError::SystemTypeError(_system_error) => {}
ValueError::Message(message) => message.deref(),
ValueError::TypeError(message) => message.deref(),
ValueError::JSValue(v) => v.deinit(),
Expand Down Expand Up @@ -612,6 +618,9 @@ impl ValueError {
ValueError::SystemError(system_error) => {
core::mem::take(system_error).to_error_instance(global_object)
}
ValueError::SystemTypeError(system_error) => {
core::mem::take(system_error).to_type_error_instance(global_object)
}
ValueError::Message(message) => message.to_error_instance(global_object),
ValueError::TypeError(message) => message.to_type_error_instance(global_object),
// do an early return in this case we don't need to create a new Strong
Expand All @@ -628,6 +637,7 @@ impl ValueError {
// `.clone()` on BunString/SystemError already bumps the refcount (paired
// with their Drop deref); an extra `.ref_()` here would leak +1 per dupe.
ValueError::SystemError(e) => ValueError::SystemError(e.clone()),
ValueError::SystemTypeError(e) => ValueError::SystemTypeError(e.clone()),
ValueError::Message(m) => ValueError::Message(m.clone()),
ValueError::TypeError(m) => ValueError::TypeError(m.clone()),
ValueError::JSValue(js_ref) => {
Expand Down
18 changes: 17 additions & 1 deletion src/runtime/webcore/fetch/FetchTasklet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1292,7 +1292,8 @@ impl FetchTasklet {
let fail = self.result.fail.unwrap();

// Fetch-spec "network error" cases that callers feature-detect via
// `instanceof TypeError`. Keep this list narrow; the catch-all
// `instanceof TypeError`. Keep this list narrow (redirect failures are
// handled below so their `code` property survives); the catch-all
Comment thread
robobun marked this conversation as resolved.
Outdated
// SystemError below is still a plain Error for backwards compat.
if fail == http::Error::RequestBodyNotReusable {
return BodyValueError::TypeError(BunString::static_(
Expand Down Expand Up @@ -1567,6 +1568,21 @@ impl FetchTasklet {
..Default::default()
};

// HTTP-redirect fetch returns a network error for each of these
// (https://fetch.spec.whatwg.org/#http-redirect-fetch), and a network
// error rejects fetch() with a `TypeError`. Same `code`/`path`/message.
Comment thread
robobun marked this conversation as resolved.
Outdated
if matches!(
fail,
http::Error::TooManyRedirects
| http::Error::UnexpectedRedirect
| http::Error::RedirectURLInvalid
| http::Error::InvalidRedirectURL
| http::Error::RedirectURLTooLong
| http::Error::UnsupportedRedirectProtocol
) {
return BodyValueError::SystemTypeError(fetch_error);
}

BodyValueError::SystemError(fetch_error)
}

Expand Down
101 changes: 101 additions & 0 deletions test/js/web/fetch/fetch-redirect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,104 @@ describe("fetch() follows a redirect whose Location scheme is not lowercase", ()
}
});
});

// https://fetch.spec.whatwg.org/#http-redirect-fetch step 5: "If request's
// redirect count is 20, then return a network error." A network error rejects
// the fetch() promise with a TypeError.
describe("fetch() redirect limit", () => {
// `/0 -> /1 -> ... -> /hops` via 302, then a 200. `requests.count` is the
// number of round trips the client actually made.
function redirectChain(hops: number) {
const requests = { count: 0 };
const server = Bun.serve({
port: 0,
fetch(request: Request) {
requests.count++;
const n = Number(new URL(request.url).pathname.slice("/".length));
if (n >= hops) return new Response("done");
return new Response(null, { status: 302, headers: { Location: `/${n + 1}` } });
},
});
return { server, requests, [Symbol.dispose]: () => server.stop(true) };
}

async function rejection(promise: Promise<unknown>): Promise<any> {
return await promise.then(
() => {
throw new Error("expected the fetch promise to reject");
},
(e: unknown) => e,
);
}

it.concurrent("follows exactly 20 redirects by default", async () => {
using chain = redirectChain(20);
const resp = await fetch(`${chain.server.url}0`);
expect(await resp.text()).toBe("done");
expect(resp.status).toBe(200);
expect(chain.requests.count).toBe(21);
});

it.concurrent("rejects the 21st redirect with a TypeError", async () => {
using chain = redirectChain(21);
const err = await rejection(fetch(`${chain.server.url}0`));
expect(err).toBeInstanceOf(TypeError);
expect(err.code).toBe("TooManyRedirects");
expect(err.message).toContain("redirected too many times");
// 20 redirects were followed; the 21st redirect response is the error.
expect(chain.requests.count).toBe(21);
});

it.concurrent("a self-redirect loop makes exactly 21 requests before rejecting", async () => {
let requests = 0;
using server = Bun.serve({
port: 0,
fetch() {
requests++;
return new Response(null, { status: 302, headers: { Location: "/" } });
},
});
const err = await rejection(fetch(server.url));
expect(err).toBeInstanceOf(TypeError);
expect(err.code).toBe("TooManyRedirects");
expect(requests).toBe(21);
});

it.concurrent("exceeding an explicit maxRedirects rejects with a TypeError", async () => {
using chain = redirectChain(3);
const err = await rejection(fetch(`${chain.server.url}0`, { maxRedirects: 2 }));
expect(err).toBeInstanceOf(TypeError);
expect(err.code).toBe("TooManyRedirects");
expect(chain.requests.count).toBe(3);
});

it.concurrent('redirect: "error" rejects with a TypeError', async () => {
using server = Bun.serve({
port: 0,
fetch: () => new Response(null, { status: 302, headers: { Location: "/elsewhere" } }),
});
const err = await rejection(fetch(server.url, { redirect: "error" }));
expect(err).toBeInstanceOf(TypeError);
expect(err.code).toBe("UnexpectedRedirect");
});

it.concurrent("a redirect to a non-HTTP(S) scheme rejects with a TypeError", async () => {
using server = Bun.serve({
port: 0,
fetch: () => new Response(null, { status: 302, headers: { Location: "ftp://example.com/" } }),
});
const err = await rejection(fetch(server.url));
expect(err).toBeInstanceOf(TypeError);
expect(err.code).toBe("UnsupportedRedirectProtocol");
});

it.concurrent("a redirect to an unparseable URL rejects with a TypeError", async () => {
using server = Bun.serve({
port: 0,
fetch: () => new Response(null, { status: 302, headers: { Location: "http://[/" } }),
});
const err = await rejection(fetch(server.url));
expect(err).toBeInstanceOf(TypeError);
expect(err.code).toBe("RedirectURLInvalid");
});
});
Loading