Skip to content
Closed
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
2 changes: 1 addition & 1 deletion docs/docs/api/Dispatcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -962,7 +962,7 @@ const { Client, interceptors } = require("undici");
const { redirect } = interceptors;

const client = new Client("http://example.com").compose(
redirect({ maxRedirections: 3, throwOnMaxRedirects: true })
redirect({ maxRedirections: 3, throwOnMaxRedirections: true })
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please keep both name around but document only the new one

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks @mcollina. That makes sense to me. Restored throwOnMaxRedirect and its tests in 971cc6e

);
client.request({ path: "/" })
```
Expand Down
5 changes: 1 addition & 4 deletions docs/docs/api/RedirectHandler.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@

A class that handles redirection logic for HTTP requests.

## `new RedirectHandler(dispatch, maxRedirections, opts, handler, redirectionLimitReached)`
## `new RedirectHandler(dispatch, maxRedirections, opts, handler)`

Arguments:

- **dispatch** `function` - The dispatch function to be called after every retry.
- **maxRedirections** `number` - Maximum number of redirections allowed.
- **opts** `object` - Options for handling redirection.
- **handler** `object` - An object containing handlers for different stages of the request lifecycle.
- **redirectionLimitReached** `boolean` (default: `false`) - A flag that the implementer can provide to enable or disable the feature. If set to `false`, it indicates that the caller doesn't want to use the feature and prefers the old behavior.

Returns: `RedirectHandler`

Expand All @@ -20,7 +19,6 @@ Returns: `RedirectHandler`
- **maxRedirections** `number` (required) - Maximum number of redirections allowed.
- **opts** `object` (required) - Options for handling redirection.
- **handler** `object` (required) - Handlers for different stages of the request lifecycle.
- **redirectionLimitReached** `boolean` (default: `false`) - A flag that the implementer can provide to enable or disable the feature. If set to `false`, it indicates that the caller doesn't want to use the feature and prefers the old behavior.

### Properties

Expand All @@ -30,7 +28,6 @@ Returns: `RedirectHandler`
- **maxRedirections** `number` - Maximum number of redirections allowed.
- **handler** `object` - Handlers for different stages of the request lifecycle.
- **history** `Array` - An array representing the history of URLs during redirection.
- **redirectionLimitReached** `boolean` - Indicates whether the redirection limit has been reached.

### Methods

Expand Down
7 changes: 6 additions & 1 deletion lib/handler/redirect-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ class RedirectHandler {
throw new InvalidArgumentError('maxRedirections must be a positive number')
}

if (opts.throwOnMaxRedirections != null && typeof opts.throwOnMaxRedirections !== 'boolean') {
throw new InvalidArgumentError('throwOnMaxRedirections must be a boolean')
}

this.dispatch = dispatch
this.location = null
const { maxRedirections: _, ...cleanOpts } = opts
Expand Down Expand Up @@ -92,7 +96,8 @@ class RedirectHandler {
}

onResponseStart (controller, statusCode, headers, statusMessage) {
if (this.opts.throwOnMaxRedirect && this.history.length >= this.maxRedirections) {
// throwOnMaxRedirect was not documented or validated, but it was respected here, and may have been used by some users. Keep it for now.
if ((this.opts.throwOnMaxRedirections || this.opts.throwOnMaxRedirect) && this.history.length >= this.maxRedirections) {
throw new Error('max redirects')
}

Expand Down
6 changes: 3 additions & 3 deletions lib/interceptor/redirect.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@

const RedirectHandler = require('../handler/redirect-handler')

function createRedirectInterceptor ({ maxRedirections: defaultMaxRedirections } = {}) {
function createRedirectInterceptor ({ maxRedirections: defaultMaxRedirections, throwOnMaxRedirections: defaultThrowOnMaxRedirections } = {}) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This would also need to handle throwOnMaxRedirects.

Copy link
Copy Markdown
Author

@williamhaley williamhaley Aug 1, 2025

Choose a reason for hiding this comment

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

Do you mean throwOnMaxRedirect (no s)? throwOnMaxRedirects was in the documentation, but it wasn't used in the functional code on main so I don't think that value could have worked. throwOnMaxRedirect was used in the functional code though.

I can add throwOnMaxRedirect here since that used to work in the functional code. My rationale for not including it here originally was that throwOnMaxRedirect was never documented so I wasn't sure if it was worth advertising it now in Typescript or better to just consolidate on throwOnMaxRedirections. throwOnMaxRedirections is now in the Dispatcher.md docs as well as the functional code and the naming aligns with maxRedirections.

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.

@williamhaley

Current status, as I understand it:

  1. throwOnMaxRedirects has now been cleaned up in ( docs: update broken links in file "Dispatcher.md" #4924 )
  2. redirectionLimitReached has now been cleaned up in ( doc: remove unused parameter redirectionLimitReached #4933 )
  3. throwOnMaxRedirect appears to be the option introduced by ( feat: implement throwOnMaxRedirect option for RedirectHandler #2563 ), but it still seems under-documented and its behavior/supported surface is not very clear.

With those pieces out of the way, I think this PR may be worth reopening.

return (dispatch) => {
return function Intercept (opts, handler) {
const { maxRedirections = defaultMaxRedirections, ...rest } = opts
const { maxRedirections = defaultMaxRedirections, throwOnMaxRedirections = defaultThrowOnMaxRedirections, ...rest } = opts

if (maxRedirections == null || maxRedirections === 0) {
return dispatch(opts, handler)
}

const dispatchOpts = { ...rest } // Stop sub dispatcher from also redirecting.
const redirectHandler = new RedirectHandler(dispatch, maxRedirections, dispatchOpts, handler)
const redirectHandler = new RedirectHandler(dispatch, maxRedirections, { throwOnMaxRedirections, ...dispatchOpts }, handler)
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Maybe a little confusing that maxRedirections is a positional argument, but throwOnMaxRedirections is part of the opts object. That seemed to be the original intention based on existing code, as far as I could tell, so I kept with that.

return dispatch(dispatchOpts, redirectHandler)
}
}
Expand Down
68 changes: 55 additions & 13 deletions test/interceptors/redirect.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,23 @@ const {
} = undici

for (const factory of [
(server, opts) =>
new undici.Agent(opts).compose(
redirect({ maxRedirections: opts?.maxRedirections })
(server, dispatchOpts) =>
new undici.Agent(dispatchOpts).compose(
redirect({ maxRedirections: dispatchOpts?.maxRedirections, throwOnMaxRedirections: dispatchOpts?.throwOnMaxRedirections })
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I think there could even be another arg, intercepterOpts, passed to the factory() so that it can be used as a distinct arg from the default opts we use when instantiating each Dispatcher (Agent, Pool, Client) with its default args

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

i like this idea, but likely should go in a separate PR 👍

),
(server, opts) =>
new undici.Pool(`http://${server}`, opts).compose(
redirect({ maxRedirections: opts?.maxRedirections })
(server, dispatchOpts) =>
new undici.Pool(`http://${server}`, dispatchOpts).compose(
redirect({ maxRedirections: dispatchOpts?.maxRedirections, throwOnMaxRedirections: dispatchOpts?.throwOnMaxRedirections })
),
(server, opts) =>
new undici.Client(`http://${server}`, opts).compose(
redirect({ maxRedirections: opts?.maxRedirections })
(server, dispatchOpts) =>
new undici.Client(`http://${server}`, dispatchOpts).compose(
redirect({ maxRedirections: dispatchOpts?.maxRedirections, throwOnMaxRedirections: dispatchOpts?.throwOnMaxRedirections })
)
]) {
const request = (t, server, opts, ...args) => {
const dispatcher = factory(server, opts)
const request = (t, server, dispatchOpts, url, requestOpts) => {
const dispatcher = factory(server, dispatchOpts)
after(() => dispatcher.close())
return undici.request(args[0], { ...args[1], dispatcher }, args[2])
Copy link
Copy Markdown
Author

@williamhaley williamhaley Aug 1, 2025

Choose a reason for hiding this comment

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

args[2] seemed to never be used. I named the spread args to try and clarify the intention. Seems like the third positional arg was the opts that are always sent to the factory dispatchers, the fourth positional arg was always the url, and the fifth positional arg was the undici.request() opts.

return undici.request(url, { ...requestOpts, dispatcher })
}

test('should always have a history with the final URL even if no redirections were followed', async t => {
Expand Down Expand Up @@ -445,7 +445,7 @@ for (const factory of [
await t.completed
})

test('should follow a redirect chain up to the allowed number of times for redirectionLimitReached', async t => {
test('should follow a redirect chain up to the allowed number of times for maxRedirections using the undici.request opts and undocumented throwOnMaxRedirect', async t => {
t = tspl(t, { plan: 1 })

const server = await startRedirectingServer()
Expand All @@ -466,6 +466,48 @@ for (const factory of [
await t.completed
})

test('should follow a redirect chain up to the allowed number of times for maxRedirections using the undici.request opts', async t => {
t = tspl(t, { plan: 1 })

const server = await startRedirectingServer()

try {
await request(t, server, undefined, `http://${server}/300`, {
maxRedirections: 2,
throwOnMaxRedirections: true
})
} catch (error) {
if (error.message.startsWith('max redirects')) {
t.ok(true, 'Max redirects handled correctly')
} else {
t.fail(`Unexpected error: ${error.message}`)
}
}

await t.completed
})

test('should follow a redirect chain up to the allowed number of times for maxRedirections using the dispatch opts', async t => {
t = tspl(t, { plan: 1 })

const server = await startRedirectingServer()

try {
await request(t, server, {
maxRedirections: 2,
throwOnMaxRedirections: true
}, `http://${server}/300`, undefined)
} catch (error) {
if (error.message.startsWith('max redirects')) {
t.ok(true, 'Max redirects handled correctly')
} else {
t.fail(`Unexpected error: ${error.message}`)
}
}

await t.completed
})

test('when a Location response header is NOT present', async t => {
t = tspl(t, { plan: 6 * 3 })

Expand Down
91 changes: 84 additions & 7 deletions test/redirect-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ const { Headers: UndiciHeaders } = require('..')
const redirect = undici.interceptors.redirect

for (const factory of [
(server, opts) => new undici.Agent(opts).compose(redirect({ maxRedirections: opts?.maxRedirections })),
(server, opts) => new undici.Pool(`http://${server}`, opts).compose(redirect({ maxRedirections: opts?.maxRedirections })),
(server, opts) => new undici.Client(`http://${server}`, opts).compose(redirect({ maxRedirections: opts?.maxRedirections }))
(server, dispatchOpts) => new undici.Agent(dispatchOpts).compose(redirect({ maxRedirections: dispatchOpts?.maxRedirections, throwOnMaxRedirections: dispatchOpts?.throwOnMaxRedirections })),
(server, dispatchOpts) => new undici.Pool(`http://${server}`, dispatchOpts).compose(redirect({ maxRedirections: dispatchOpts?.maxRedirections, throwOnMaxRedirections: dispatchOpts?.throwOnMaxRedirections })),
(server, dispatchOpts) => new undici.Client(`http://${server}`, dispatchOpts).compose(redirect({ maxRedirections: dispatchOpts?.maxRedirections, throwOnMaxRedirections: dispatchOpts?.throwOnMaxRedirections }))
]) {
const request = (t, server, opts, ...args) => {
const dispatcher = factory(server, opts)
const request = (t, server, dispatchOpts, url, requestOpts) => {
const dispatcher = factory(server, dispatchOpts)
after(() => dispatcher.close())
return undici.request(args[0], { ...args[1], dispatcher }, args[2])
return undici.request(url, { ...requestOpts, dispatcher })
}

test('should always have a history with the final URL even if no redirections were followed', async t => {
Expand Down Expand Up @@ -405,7 +405,7 @@ for (const factory of [
await t.completed
})

test('should follow a redirect chain up to the allowed number of times for redirectionLimitReached', async t => {
test('should follow a redirect chain up to the allowed number of times for maxRedirections using the undici.request opts and undocumented throwOnMaxRedirect', async t => {
t = tspl(t, { plan: 1 })

const server = await startRedirectingServer()
Expand All @@ -426,6 +426,48 @@ for (const factory of [
await t.completed
})

test('should follow a redirect chain up to the allowed number of times for maxRedirections using the undici.request opts', async t => {
t = tspl(t, { plan: 1 })

const server = await startRedirectingServer()

try {
await request(t, server, undefined, `http://${server}/300`, {
maxRedirections: 2,
throwOnMaxRedirections: true
})
} catch (error) {
if (error.message.startsWith('max redirects')) {
t.ok(true, 'Max redirects handled correctly')
} else {
t.fail(`Unexpected error: ${error.message}`)
}
}

await t.completed
})

test('should follow a redirect chain up to the allowed number of times for maxRedirections using the dispatch opts', async t => {
t = tspl(t, { plan: 1 })

const server = await startRedirectingServer()

try {
await request(t, server, {
maxRedirections: 2,
throwOnMaxRedirections: true
}, `http://${server}/300`, undefined)
} catch (error) {
if (error.message.startsWith('max redirects')) {
t.ok(true, 'Max redirects handled correctly')
} else {
t.fail(`Unexpected error: ${error.message}`)
}
}

await t.completed
})

test('when a Location response header is NOT present', async t => {
t = tspl(t, { plan: 6 * 3 })

Expand Down Expand Up @@ -462,6 +504,23 @@ for (const factory of [
await t.completed
})

test('should not allow invalid throwOnMaxRedirections arguments', async t => {
t = tspl(t, { plan: 1 })

try {
await request(t, 'localhost', undefined, 'http://localhost', {
method: 'GET',
maxRedirections: 1,
throwOnMaxRedirections: 'INVALID'
})

t.fail('Did not throw')
} catch (err) {
t.strictEqual(err.message, 'throwOnMaxRedirections must be a boolean')
}
await t.completed
})

test('should not allow invalid maxRedirections arguments default', async t => {
t = tspl(t, { plan: 1 })

Expand All @@ -479,6 +538,24 @@ for (const factory of [
await t.completed
})

test('should not allow invalid throwOnMaxRedirections arguments default', async t => {
t = tspl(t, { plan: 1 })

try {
await request(t, 'localhost', undefined, 'http://localhost', {
method: 'GET',
maxRedirections: 1,
throwOnMaxRedirections: 'INVALID'
})

t.fail('Did not throw')
} catch (err) {
t.strictEqual(err.message, 'throwOnMaxRedirections must be a boolean')
}

await t.completed
})

test('should not follow redirects when using ReadableStream request bodies', async t => {
t = tspl(t, { plan: 3 })

Expand Down
2 changes: 1 addition & 1 deletion test/types/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@ const handler: Dispatcher.DispatchHandler = {}

const redirectHandler = new Undici.RedirectHandler(dispatcher.dispatch, 10, {
path: '/', method: 'GET'
}, handler, false) as RedirectHandler
}, handler)
expectAssignable<RedirectHandler>(redirectHandler)
6 changes: 0 additions & 6 deletions types/dispatcher.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,6 @@ declare namespace Dispatcher {
signal?: AbortSignal | EventEmitter | null;
/** This argument parameter is passed through to `ConnectData` */
opaque?: TOpaque;
/** Default: false */
redirectionLimitReached?: boolean;
/** Default: `null` */
responseHeaders?: 'raw' | null;
}
Expand All @@ -145,8 +143,6 @@ declare namespace Dispatcher {
opaque?: TOpaque;
/** Default: `null` */
signal?: AbortSignal | EventEmitter | null;
/** Default: false */
redirectionLimitReached?: boolean;
/** Default: `null` */
onInfo?: (info: { statusCode: number, headers: Record<string, string | string[]> }) => void;
/** Default: `null` */
Expand All @@ -168,8 +164,6 @@ declare namespace Dispatcher {
protocol?: string;
/** Default: `null` */
signal?: AbortSignal | EventEmitter | null;
/** Default: false */
redirectionLimitReached?: boolean;
/** Default: `null` */
responseHeaders?: 'raw' | null;
}
Expand Down
1 change: 0 additions & 1 deletion types/handlers.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ export declare class RedirectHandler implements Dispatcher.DispatchHandler {
maxRedirections: number,
opts: Dispatcher.DispatchOptions,
handler: Dispatcher.DispatchHandler,
redirectionLimitReached: boolean
)
}

Expand Down
2 changes: 1 addition & 1 deletion types/interceptors.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default Interceptors
declare namespace Interceptors {
export type DumpInterceptorOpts = { maxSize?: number }
export type RetryInterceptorOpts = RetryHandler.RetryOptions
export type RedirectInterceptorOpts = { maxRedirections?: number }
export type RedirectInterceptorOpts = { maxRedirections?: number, throwOnMaxRedirections?: boolean }
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Probably worth defining RedirectHandler.RedirectOptions like exists for the RetryHandler, but that seemed a bit daunting to me based on all the type definitions for retry-handler.d.ts so I just left this as a simple one-off type declaration


export type ResponseErrorInterceptorOpts = { throwOnError: boolean }
export type CacheInterceptorOpts = CacheHandler.CacheOptions
Expand Down
Loading