Skip to content

Commit 63b0974

Browse files
jasnelladuh95
authored andcommitted
util: implement util.throttle
Often closely related to util.debounce use cases, this adds an implementation of throttle(fn) .. returns a function that limits the number of concurrent invocations of fn. Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode PR-URL: #65899 Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent a3bbb5a commit 63b0974

6 files changed

Lines changed: 940 additions & 0 deletions

File tree

‎doc/api/errors.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3180,6 +3180,13 @@ This error represents a failed test. Additional information about the failure
31803180
is available via the `cause` property. The `failureType` property specifies
31813181
what the test was doing when the failure occurred.
31823182

3183+
<a id="ERR_THROTTLED"></a>
3184+
3185+
### `ERR_THROTTLED`
3186+
3187+
A call was dropped because a throttled function could not invoke it immediately
3188+
or its pending queue was full.
3189+
31833190
<a id="ERR_TLS_ALPN_CALLBACK_INVALID_RESULT"></a>
31843191

31853192
### `ERR_TLS_ALPN_CALLBACK_INVALID_RESULT`

‎doc/api/util.md‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,115 @@ process.stdin.on('data', (data) => {
481481
onInactivity();
482482
```
483483

484+
## `util.throttle(fn, limit, interval[, options])`
485+
486+
<!-- YAML
487+
added: REPLACEME
488+
-->
489+
490+
* `fn` {Function} The function to throttle.
491+
* `limit` {integer} The maximum number of times to invoke `fn` during an
492+
interval. Must be greater than `0`.
493+
* `interval` {integer} The length of each interval in milliseconds.
494+
* `options` {Object}
495+
* `concurrency` {number} The maximum number of invocations of `fn` whose
496+
return values may be unsettled at once. Must be a positive integer or
497+
`Infinity`. **Default:** `Infinity`.
498+
* `maxPending` {number} The maximum number of calls that may be queued when
499+
`overflow` is `'queue'`. Must be a non-negative integer or `Infinity`.
500+
**Default:** `Infinity`.
501+
* `overflow` {string} Determines how calls exceeding the limit are handled.
502+
**Default:** `'queue'`.
503+
* `'queue'`: Queue calls in the order received.
504+
* `'drop'`: Reject calls immediately without queueing them.
505+
* `signal` {AbortSignal} An `AbortSignal` that cancels pending calls and
506+
prevents future calls when aborted.
507+
* `strict` {boolean} When `true`, ensures that `limit` is not exceeded during
508+
any rolling interval. **Default:** `false`.
509+
* Returns: {Function} The throttled function.
510+
511+
Creates a function that limits how often `fn` is invoked. By default, calls that
512+
exceed the limit are queued in the order received rather than discarded. The
513+
throttled function returns a {Promise} for the value returned by `fn`. If `fn`
514+
throws or returns a rejected promise, the returned promise is rejected with the
515+
same reason.
516+
517+
An invocation starts only when both rate and concurrency capacity are
518+
available. Rate capacity is consumed when `fn` starts, not when a call enters
519+
the queue. Concurrency capacity is released when the value returned by `fn`
520+
settles. Non-promise values settle during the next microtask.
521+
522+
When `options.overflow` is `'drop'`, calls made without available rate or
523+
concurrency capacity are rejected immediately. When `options.overflow` is
524+
`'queue'` and `options.maxPending` calls are already queued, additional calls
525+
are also rejected immediately. `maxPending` has no effect when `overflow` is
526+
`'drop'`.
527+
528+
In both cases, rejected calls return a promise rejected with an
529+
`ERR_THROTTLED` error. The rejected promise is marked as handled, so ignoring it
530+
does not emit an `'unhandledRejection'` event. Awaiting or explicitly handling
531+
the promise still observes the rejection. Rejected calls do not consume rate
532+
or concurrency capacity, enter the queue, or schedule a timeout.
533+
534+
By default, the interval begins when the first call in a new window invokes
535+
`fn`. Up to `limit` calls can invoke `fn` during that window. Queued calls are
536+
processed in groups of up to `limit` as each subsequent window begins. This
537+
windowed behavior can result in calls occurring close together at a window
538+
boundary.
539+
540+
When `options.strict` is `true`, invocation times are tracked individually.
541+
This ensures that no more than `limit` calls begin during any rolling interval,
542+
at the cost of additional bookkeeping.
543+
544+
If `options.signal` is aborted, pending and future calls reject with an
545+
`AbortError`, with the signal's reason set as the error's `cause`, and `fn` is
546+
not invoked by those calls. If the signal is already aborted, `throttle()`
547+
throws an `AbortError`.
548+
549+
The returned function has the following properties:
550+
551+
* `cancel([reason])` cancels all queued calls and resets the current throttle
552+
window. The queued promises reject with an `AbortError`. If provided,
553+
`reason` is set as the error's `cause`. Does not cancel invocations that have
554+
already started.
555+
* `hasImmediateCapacity()` returns `true` if a call made at that moment could
556+
invoke `fn` without being queued or rejected. The check does not reserve
557+
capacity, and the throttled function always checks again when called. It
558+
returns `false` while calls are queued to preserve their order. Callers can
559+
avoid creating a timeout by only calling the throttled function when this
560+
method returns `true`.
561+
* `pending` {Promise|null} is the promise returned by the most recently queued
562+
call, or `null` if no invocation is queued.
563+
* `pendingCount` {integer} is the number of calls awaiting invocation.
564+
* `activeCount` {integer} is the number of invocations whose return values have
565+
not settled.
566+
* `ref()` makes the pending and future timeout keep the Node.js event loop
567+
active. Returns the throttled function.
568+
* `unref()` allows the event loop to exit while a timeout is pending. This also
569+
applies to future timeouts. Returns the throttled function.
570+
571+
Calls that have already invoked `fn` are not affected by `cancel()` or by an
572+
aborted signal. When invoked, `fn` has the throttled function as its `this`
573+
value. The throttled function preserves the `name` and `length` of `fn`.
574+
575+
```mjs
576+
import { throttle } from 'node:util';
577+
578+
const request = throttle(async (id) => {
579+
const response = await fetch(`https://example.com/items/${id}`);
580+
return response.json();
581+
}, 2, 1_000);
582+
583+
// At most two requests begin during each one-second interval. All other calls
584+
// remain queued and retain their original arguments.
585+
const results = await Promise.all([
586+
request(1),
587+
request(2),
588+
request(3),
589+
request(4),
590+
]);
591+
```
592+
484593
## `util.diff(actual, expected)`
485594

486595
<!-- YAML

‎lib/internal/errors.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1849,6 +1849,7 @@ E('ERR_TEST_FAILURE', function(error, failureType) {
18491849
this.cause = error;
18501850
return msg;
18511851
}, Error);
1852+
E('ERR_THROTTLED', 'The call was dropped by the throttled function', Error);
18521853
E('ERR_TLS_ALPN_CALLBACK_INVALID_RESULT', (value, protocols) => {
18531854
return `ALPN callback returned a value (${
18541855
value

0 commit comments

Comments
 (0)