Skip to content

Commit 0395679

Browse files
JoshMockUzlopak
andauthored
feat: Support for capping the number of origins in Agent (#4365)
Co-authored-by: Aras Abbasi <aras.abbasi@googlemail.com>
1 parent 830bff0 commit 0395679

7 files changed

Lines changed: 85 additions & 5 deletions

File tree

docs/docs/api/Agent.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Returns: `Agent`
1919
Extends: [`PoolOptions`](/docs/docs/api/Pool.md#parameter-pooloptions)
2020

2121
* **factory** `(origin: URL, opts: Object) => Dispatcher` - Default: `(origin, opts) => new Pool(origin, opts)`
22+
* **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.
2223

2324
## Instance Properties
2425

lib/core/errors.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,22 @@ class SecureProxyConnectionError extends UndiciError {
359359
[kSecureProxyConnectionError] = true
360360
}
361361

362+
const kMaxOriginsReachedError = Symbol.for('undici.error.UND_ERR_MAX_ORIGINS_REACHED')
363+
class MaxOriginsReachedError extends UndiciError {
364+
constructor (message) {
365+
super(message)
366+
this.name = 'MaxOriginsReachedError'
367+
this.message = message || 'Maximum allowed origins reached'
368+
this.code = 'UND_ERR_MAX_ORIGINS_REACHED'
369+
}
370+
371+
static [Symbol.hasInstance] (instance) {
372+
return instance && instance[kMaxOriginsReachedError] === true
373+
}
374+
375+
[kMaxOriginsReachedError] = true
376+
}
377+
362378
module.exports = {
363379
AbortError,
364380
HTTPParserError,
@@ -381,5 +397,6 @@ module.exports = {
381397
ResponseExceededMaxSizeError,
382398
RequestRetryError,
383399
ResponseError,
384-
SecureProxyConnectionError
400+
SecureProxyConnectionError,
401+
MaxOriginsReachedError
385402
}

lib/dispatcher/agent.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict'
22

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

1718
function defaultFactory (origin, opts) {
1819
return opts && opts.connections === 1
@@ -21,7 +22,7 @@ function defaultFactory (origin, opts) {
2122
}
2223

2324
class Agent extends DispatcherBase {
24-
constructor ({ factory = defaultFactory, connect, ...options } = {}) {
25+
constructor ({ factory = defaultFactory, maxOrigins = Infinity, connect, ...options } = {}) {
2526
if (typeof factory !== 'function') {
2627
throw new InvalidArgumentError('factory must be a function.')
2728
}
@@ -30,15 +31,20 @@ class Agent extends DispatcherBase {
3031
throw new InvalidArgumentError('connect must be a function or an object')
3132
}
3233

34+
if (typeof maxOrigins !== 'number' || Number.isNaN(maxOrigins) || maxOrigins <= 0) {
35+
throw new InvalidArgumentError('maxOrigins must be a number greater than 0')
36+
}
37+
3338
super()
3439

3540
if (connect && typeof connect !== 'function') {
3641
connect = { ...connect }
3742
}
3843

39-
this[kOptions] = { ...util.deepClone(options), connect }
44+
this[kOptions] = { ...util.deepClone(options), maxOrigins, connect }
4045
this[kFactory] = factory
4146
this[kClients] = new Map()
47+
this[kOrigins] = new Set()
4248

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

82+
if (this[kOrigins].size >= this[kOptions].maxOrigins && !this[kOrigins].has(key)) {
83+
throw new MaxOriginsReachedError()
84+
}
85+
7686
const result = this[kClients].get(key)
7787
let dispatcher = result && result.dispatcher
7888
if (!dispatcher) {
@@ -84,6 +94,7 @@ class Agent extends DispatcherBase {
8494
this[kClients].delete(key)
8595
result.dispatcher.close()
8696
}
97+
this[kOrigins].delete(key)
8798
}
8899
}
89100
dispatcher = this[kFactory](opts.origin, this[kOptions])
@@ -105,6 +116,7 @@ class Agent extends DispatcherBase {
105116
})
106117

107118
this[kClients].set(key, { count: 0, dispatcher })
119+
this[kOrigins].add(key)
108120
}
109121

110122
return dispatcher.dispatch(opts, handler)

test/node-test/agent.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
const { describe, test, after } = require('node:test')
44
const assert = require('node:assert/strict')
5+
const { once } = require('node:events')
56
const http = require('node:http')
67
const { PassThrough } = require('node:stream')
78
const { kRunning } = require('../../lib/core/symbols')
@@ -40,6 +41,42 @@ test('Agent', t => {
4041
p.doesNotThrow(() => new Agent())
4142
})
4243

44+
test('Agent enforces maxOrigins', async (t) => {
45+
const p = tspl(t, { plan: 1 })
46+
47+
const dispatcher = new Agent({
48+
maxOrigins: 1,
49+
keepAliveMaxTimeout: 100,
50+
keepAliveTimeout: 100
51+
})
52+
t.after(() => dispatcher.close())
53+
54+
const handler = (_req, res) => {
55+
setTimeout(() => res.end('ok'), 50)
56+
}
57+
58+
const server1 = http.createServer({ joinDuplicateHeaders: true }, handler)
59+
server1.listen(0)
60+
await once(server1, 'listening')
61+
t.after(closeServerAsPromise(server1))
62+
63+
const server2 = http.createServer({ joinDuplicateHeaders: true }, handler)
64+
server2.listen(0)
65+
await once(server2, 'listening')
66+
t.after(closeServerAsPromise(server2))
67+
68+
try {
69+
await Promise.all([
70+
request(`http://localhost:${server1.address().port}`, { dispatcher }),
71+
request(`http://localhost:${server2.address().port}`, { dispatcher })
72+
])
73+
} catch (err) {
74+
p.ok(err instanceof errors.MaxOriginsReachedError)
75+
}
76+
77+
await p.completed
78+
})
79+
4380
test('agent should call callback after closing internal pools', async (t) => {
4481
const p = tspl(t, { plan: 2 })
4582

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

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

669708
test('dispatch validations', async t => {

test/types/errors.test-d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ expectAssignable<errors.SecureProxyConnectionError>(new errors.SecureProxyConnec
110110
expectAssignable<'SecureProxyConnectionError'>(new errors.SecureProxyConnectionError().name)
111111
expectAssignable<'UND_ERR_PRX_TLS'>(new errors.SecureProxyConnectionError().code)
112112

113+
expectAssignable<errors.UndiciError>(new errors.MaxOriginsReachedError())
114+
expectAssignable<errors.MaxOriginsReachedError>(new errors.MaxOriginsReachedError())
115+
expectAssignable<'MaxOriginsReachedError'>(new errors.MaxOriginsReachedError().name)
116+
expectAssignable<'UND_ERR_MAX_ORIGINS_REACHED'>(new errors.MaxOriginsReachedError().code)
117+
113118
{
114119
// @ts-ignore
115120
function f (): errors.HeadersTimeoutError | errors.ConnectTimeoutError { }

types/agent.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ declare namespace Agent {
2424
factory?(origin: string | URL, opts: Object): Dispatcher;
2525

2626
interceptors?: { Agent?: readonly Dispatcher.DispatchInterceptor[] } & Pool.Options['interceptors']
27+
maxOrigins?: number
2728
}
2829

2930
export interface DispatchOptions extends Dispatcher.DispatchOptions {

types/errors.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,9 @@ declare namespace Errors {
153153
name: 'SecureProxyConnectionError'
154154
code: 'UND_ERR_PRX_TLS'
155155
}
156+
157+
class MaxOriginsReachedError extends UndiciError {
158+
name: 'MaxOriginsReachedError'
159+
code: 'UND_ERR_MAX_ORIGINS_REACHED'
160+
}
156161
}

0 commit comments

Comments
 (0)