Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
83ea507
Support limiting max global connections in Agent
JoshMock Jul 22, 2025
1266925
Add docs
JoshMock Jul 30, 2025
26ed12f
Restrict max origins instead of max connections
JoshMock Jul 31, 2025
97db2cb
Update docs
JoshMock Jul 31, 2025
1787aba
Update TypeScript type to reflect new option
JoshMock Jul 31, 2025
1052257
Simplify origins option
JoshMock Jul 31, 2025
a6bd457
Undo whitespace fix
JoshMock Jul 31, 2025
79cc8ed
Throw error on max origins instead of queueing requests
JoshMock Aug 1, 2025
a03bb16
Rename origins -> maxOrigins
JoshMock Aug 1, 2025
182a42c
Don't use Client when origins is set to 1
JoshMock Aug 1, 2025
9913e85
Update test to match new conditions
JoshMock Aug 1, 2025
47afff5
Test maxOrigins number validation
JoshMock Aug 1, 2025
f33f38b
Fix doc typo
JoshMock Aug 1, 2025
b09fb0a
Add tests for AgentMaxOriginsReached error type
JoshMock Aug 4, 2025
3d60607
Merge branch 'main' into max-conns
JoshMock Aug 6, 2025
0ed82ae
Close dispatcher instead of destroying right away
JoshMock Aug 12, 2025
9197081
Merge branch 'main' into max-conns
JoshMock Aug 12, 2025
2c68fd1
Ensure Agent emits socket events in a consistent format
JoshMock Aug 13, 2025
d60f5f8
Publish Agent connectionError with correct origin value
JoshMock Aug 14, 2025
8c139b2
Clean up unnecessary change
JoshMock Aug 14, 2025
96ab6d1
Merge branch 'main' into max-conns
JoshMock Aug 21, 2025
b1dc6f7
Rename AgentMaxOriginsReached to AgentMaxOriginsReachedError
JoshMock Aug 22, 2025
5994344
Rename max origins error class
JoshMock Aug 26, 2025
c5117b3
Merge branch 'main' into max-conns
Uzlopak Sep 4, 2025
b98b816
update MaxOriginsReachedError
Uzlopak Sep 4, 2025
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
1 change: 1 addition & 0 deletions docs/docs/api/Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Returns: `Agent`
Extends: [`PoolOptions`](/docs/docs/api/Pool.md#parameter-pooloptions)

* **factory** `(origin: URL, opts: Object) => Dispatcher` - Default: `(origin, opts) => new Pool(origin, opts)`
* **maxOrigins** `number` (optional) - Default: `Infinity` - Limits the total number of origins that can receive requests at a time, throwing an `MaxOriginsReachedError` error when attempting to dispatch when the max is reached. If `Infinity`, no limit is enforced.

## Instance Properties

Expand Down
19 changes: 18 additions & 1 deletion lib/core/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,22 @@ class SecureProxyConnectionError extends UndiciError {
[kSecureProxyConnectionError] = true
}

const kMaxOriginsReachedError = Symbol.for('undici.error.UND_ERR_MAX_ORIGINS_REACHED')
class MaxOriginsReachedError extends UndiciError {
constructor (message) {
super(message)
this.name = 'MaxOriginsReachedError'
this.message = message || 'Maximum allowed origins reached'
this.code = 'UND_ERR_MAX_ORIGINS_REACHED'
}

static [Symbol.hasInstance] (instance) {
return instance && instance[kMaxOriginsReachedError] === true
}

[kMaxOriginsReachedError] = true
}

module.exports = {
AbortError,
HTTPParserError,
Expand All @@ -381,5 +397,6 @@ module.exports = {
ResponseExceededMaxSizeError,
RequestRetryError,
ResponseError,
SecureProxyConnectionError
SecureProxyConnectionError,
MaxOriginsReachedError
}
18 changes: 15 additions & 3 deletions lib/dispatcher/agent.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict'

const { InvalidArgumentError } = require('../core/errors')
const { InvalidArgumentError, MaxOriginsReachedError } = require('../core/errors')
const { kClients, kRunning, kClose, kDestroy, kDispatch, kUrl } = require('../core/symbols')
const DispatcherBase = require('./dispatcher-base')
const Pool = require('./pool')
Expand All @@ -13,6 +13,7 @@ const kOnConnectionError = Symbol('onConnectionError')
const kOnDrain = Symbol('onDrain')
const kFactory = Symbol('factory')
const kOptions = Symbol('options')
const kOrigins = Symbol('origins')

function defaultFactory (origin, opts) {
return opts && opts.connections === 1
Expand All @@ -21,7 +22,7 @@ function defaultFactory (origin, opts) {
}

class Agent extends DispatcherBase {
constructor ({ factory = defaultFactory, connect, ...options } = {}) {
constructor ({ factory = defaultFactory, maxOrigins = Infinity, connect, ...options } = {}) {
if (typeof factory !== 'function') {
throw new InvalidArgumentError('factory must be a function.')
}
Expand All @@ -30,15 +31,20 @@ class Agent extends DispatcherBase {
throw new InvalidArgumentError('connect must be a function or an object')
}

if (typeof maxOrigins !== 'number' || Number.isNaN(maxOrigins) || maxOrigins <= 0) {
throw new InvalidArgumentError('maxOrigins must be a number greater than 0')
}

super()

if (connect && typeof connect !== 'function') {
connect = { ...connect }
}

this[kOptions] = { ...util.deepClone(options), connect }
this[kOptions] = { ...util.deepClone(options), maxOrigins, connect }
this[kFactory] = factory
this[kClients] = new Map()
this[kOrigins] = new Set()

this[kOnDrain] = (origin, targets) => {
this.emit('drain', origin, [this, ...targets])
Expand Down Expand Up @@ -73,6 +79,10 @@ class Agent extends DispatcherBase {
throw new InvalidArgumentError('opts.origin must be a non-empty string or URL.')
}

if (this[kOrigins].size >= this[kOptions].maxOrigins && !this[kOrigins].has(key)) {
throw new MaxOriginsReachedError()
}

const result = this[kClients].get(key)
let dispatcher = result && result.dispatcher
if (!dispatcher) {
Expand All @@ -84,6 +94,7 @@ class Agent extends DispatcherBase {
this[kClients].delete(key)
result.dispatcher.close()
}
this[kOrigins].delete(key)
}
}
dispatcher = this[kFactory](opts.origin, this[kOptions])
Expand All @@ -105,6 +116,7 @@ class Agent extends DispatcherBase {
})

this[kClients].set(key, { count: 0, dispatcher })
this[kOrigins].add(key)
}

return dispatcher.dispatch(opts, handler)
Expand Down
41 changes: 40 additions & 1 deletion test/node-test/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const { describe, test, after } = require('node:test')
const assert = require('node:assert/strict')
const { once } = require('node:events')
const http = require('node:http')
const { PassThrough } = require('node:stream')
const { kRunning } = require('../../lib/core/symbols')
Expand Down Expand Up @@ -40,6 +41,42 @@ test('Agent', t => {
p.doesNotThrow(() => new Agent())
})

test('Agent enforces maxOrigins', async (t) => {
const p = tspl(t, { plan: 1 })

const dispatcher = new Agent({
maxOrigins: 1,
keepAliveMaxTimeout: 100,
keepAliveTimeout: 100
})
t.after(() => dispatcher.close())

const handler = (_req, res) => {
setTimeout(() => res.end('ok'), 50)
}

const server1 = http.createServer({ joinDuplicateHeaders: true }, handler)
server1.listen(0)
await once(server1, 'listening')
t.after(closeServerAsPromise(server1))

const server2 = http.createServer({ joinDuplicateHeaders: true }, handler)
server2.listen(0)
await once(server2, 'listening')
t.after(closeServerAsPromise(server2))

try {
await Promise.all([
request(`http://localhost:${server1.address().port}`, { dispatcher }),
request(`http://localhost:${server2.address().port}`, { dispatcher })
])
} catch (err) {
p.ok(err instanceof errors.MaxOriginsReachedError)
}

await p.completed
})

test('agent should call callback after closing internal pools', async (t) => {
const p = tspl(t, { plan: 2 })

Expand Down Expand Up @@ -662,8 +699,10 @@ test('stream: fails with invalid onInfo', async (t) => {
})

test('constructor validations', t => {
const p = tspl(t, { plan: 1 })
const p = tspl(t, { plan: 3 })
p.throws(() => new Agent({ factory: 'ASD' }), errors.InvalidArgumentError, 'throws on invalid opts argument')
p.throws(() => new Agent({ maxOrigins: -1 }), errors.InvalidArgumentError, 'maxOrigins must be a number greater than 0')
p.throws(() => new Agent({ maxOrigins: 'foo' }), errors.InvalidArgumentError, 'maxOrigins must be a number greater than 0')
})

test('dispatch validations', async t => {
Expand Down
5 changes: 5 additions & 0 deletions test/types/errors.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ expectAssignable<errors.SecureProxyConnectionError>(new errors.SecureProxyConnec
expectAssignable<'SecureProxyConnectionError'>(new errors.SecureProxyConnectionError().name)
expectAssignable<'UND_ERR_PRX_TLS'>(new errors.SecureProxyConnectionError().code)

expectAssignable<errors.UndiciError>(new errors.MaxOriginsReachedError())
expectAssignable<errors.MaxOriginsReachedError>(new errors.MaxOriginsReachedError())
expectAssignable<'MaxOriginsReachedError'>(new errors.MaxOriginsReachedError().name)
expectAssignable<'UND_ERR_MAX_ORIGINS_REACHED'>(new errors.MaxOriginsReachedError().code)

{
// @ts-ignore
function f (): errors.HeadersTimeoutError | errors.ConnectTimeoutError { }
Expand Down
1 change: 1 addition & 0 deletions types/agent.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ declare namespace Agent {
factory?(origin: string | URL, opts: Object): Dispatcher;

interceptors?: { Agent?: readonly Dispatcher.DispatchInterceptor[] } & Pool.Options['interceptors']
maxOrigins?: number
}

export interface DispatchOptions extends Dispatcher.DispatchOptions {
Expand Down
5 changes: 5 additions & 0 deletions types/errors.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,9 @@ declare namespace Errors {
name: 'SecureProxyConnectionError'
code: 'UND_ERR_PRX_TLS'
}

class MaxOriginsReachedError extends UndiciError {
name: 'MaxOriginsReachedError'
code: 'UND_ERR_MAX_ORIGINS_REACHED'
}
}
Loading