dynamic-throttled-queue 2.3.0-rc.e00ce0b → 3.0.0-rc.1d87d99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,143 +14,109 @@ Originally forked from [shaunpersad/throttled-queue](https://github.com/shaunper
14
14
  pnpm add dynamic-throttled-queue
15
15
  ```
16
16
 
17
- ## Usage
17
+ Requires Node.js 24 or later. The package is ESM only.
18
+
19
+ ## Quick start
18
20
 
19
21
  ```ts
20
22
  import { createThrottledQueue } from "dynamic-throttled-queue";
21
23
 
24
+ // At most 5 starts per second, spaced 200 ms apart.
22
25
  const throttle = createThrottledQueue({ min_rpi: 5, interval: 1000 });
23
26
 
24
- throttle(() => {
25
- // perform some type of activity in here.
26
- });
27
- ```
28
-
29
- Callbacks can return `false` to signal a failure (used for dynamic rate adjustment and retry). Async callbacks (returning a Promise) are also supported — rejections and `false` resolutions count as failures. By default, every failure reduces the adaptive rate; use `rateOutcomeClassifier` when only selected failures should do so. Use `retryClassifier` to separately decide whether a failure is eligible for retry.
30
-
31
- Each callback receives an execution context containing the queue-owned `AbortSignal`. Existing zero-argument callbacks remain supported. Use the signal to cooperatively cancel in-flight work:
32
-
33
- ```ts
34
27
  throttle(async ({ signal }) => {
35
- const response = await fetch("/api/data", { signal });
36
- if (!response.ok) return false;
28
+ const res = await fetch("https://api.example.com/data", { signal });
29
+ return res.ok; // `false` counts as a failure
37
30
  });
38
31
  ```
39
32
 
40
- Set `concurrency` to bound callbacks that are still awaiting asynchronous completion. This limit is independent of the request-start rate; omitting it preserves the existing unlimited in-flight behavior.
41
-
42
- Set `maxQueueSize` to bound accepted work. Capacity is reserved from enqueue through terminal success or failure, including pending callbacks, active callbacks, and retries. A full queue makes `enqueue()` throw synchronously in v2; unlimited capacity remains the default. In v3, a full queue will return `false` instead.
43
-
44
- ## Options
45
-
46
- | Param | Type | Default | Description |
47
- | ----- | ---- | ------- | ----------- |
48
- | `min_rpi` | `number` | *required* | Minimum requests per interval |
49
- | `max_rpi` | `number` | `min_rpi` | Maximum requests per interval |
50
- | `interval` | `number` | *required* | Milliseconds between each batch of requests |
51
- | `evenly_spaced` | `boolean` | `true` | Distribute requests evenly throughout the interval |
52
- | `errors_per_interval` | `number` | `5` | Positive integer error threshold per interval before adjusting rate |
53
- | `back_off` | `boolean` | `false` | Back off for 1 full interval when error threshold is hit |
54
- | `retry` | `number` | `0` | Non-negative integer number of times to retry failed callbacks |
55
- | `retryBackoff` | `RetryBackoff` | — | Per-retry fixed, linear, or exponential delay policy; omit for immediate retries |
56
- | `concurrency` | `number` | — | Maximum callbacks awaiting asynchronous completion; omit for no limit |
57
- | `maxQueueSize` | `number` | — | Maximum accepted callbacks not yet terminal, including pending, active, and retried work; omit for no limit |
58
- | `compact_threshold` | `number` | `512` | Non-negative integer minimum dead slots before internal queue compaction triggers; `0` compacts at the earliest eligible point |
59
- | `rateStrategy` | `RateStrategy` | `linear` | Pure policy that requests the next rate and an optional backoff after each observation window |
60
- | `rateOutcomeClassifier` | `RateOutcomeClassifier` | — | Decides whether a failed callback outcome contributes to adaptive-rate error counting |
61
- | `adjustmentTiming` | `"interval" \| "settled"` | `"interval"` | Defines whether rate decisions use outcomes settled each interval or complete started-attempt sets |
62
- | `retryClassifier` | `RetryClassifier` | — | Decides whether a failed callback outcome is eligible for another attempt |
63
- | `onRateChange` | `(rate: number) => void` | — | Called when the current rate changes |
64
-
65
- `retry`, `errors_per_interval`, `compact_threshold`, and `maxQueueSize` reject fractional and non-finite values. `errors_per_interval` must be at least `1`; `retry` and `compact_threshold` may be `0`. `retryBackoff.baseDelay` and optional `maxDelay` are finite non-negative millisecond values (fractional values are accepted); optional `jitter` is finite from `0` through `1`.
33
+ `createThrottledQueue` returns a queue. Call it with a callback to queue fire-and-forget work, or call `queue.submit(callback)` when you need the callback's result.
66
34
 
67
- `retryBackoff` is `{ strategy: "fixed" | "linear" | "exponential"; baseDelay: number; maxDelay?: number; jitter?: number; random?: () => number }`. The first retry has index `1`: fixed uses `baseDelay`, linear uses `baseDelay × retryIndex`, and exponential uses `baseDelay × 2^(retryIndex - 1)`. The calculated delay is capped at `maxDelay`, when supplied, then optional symmetric percentage jitter is applied and capped again. `random` makes jitter deterministic for tests; a thrown, non-finite, or out-of-range result safely falls back to no jitter. Retry backoff begins when the failed attempt settles and is independent of adaptive-rate `back_off`.
35
+ ## Queuing work
68
36
 
69
- ### Adaptive-rate timing
37
+ | | `throttle(callback)` | `throttle.submit(callback)` |
38
+ | - | -------------------- | --------------------------- |
39
+ | Returns | nothing | a `TaskHandle<T>`: `{ result, cancel }` |
40
+ | Failure | returning `false`, throwing, or rejecting | throwing or rejecting only; `false` is a normal result |
41
+ | Final failure | dropped; only `getState().failed` sees it | rejects `result` |
42
+ | Cancel one item | no | `cancel(reason?)` |
43
+ | Signal passed to the callback | the queue's signal, aborted only by `abort()` | the item's own signal, aborted by `cancel()` or `abort()` |
70
44
 
71
- `adjustmentTiming: "interval"` is the compatibility default: each configured interval observes the callback outcomes that have settled so far. A slow callback can therefore settle after its start interval and affect a later rate decision.
45
+ Both forms retry, count toward the adaptive rate, and respect `concurrency` and `maxQueueSize` the same way. Use `submit()` when you need a value back or need to know that an item finally failed.
72
46
 
73
- With `adjustmentTiming: "settled"`, an observation window contains every callback attempt that starts during one full configured interval. When collection closes, no further callbacks start until every attempt in that window settles; the queue then makes exactly one rate decision from the complete window. The next collection interval begins immediately after that decision, unless adaptive-rate `back_off` delays scheduling. Empty settled intervals do not produce a decision. A never-settling callback blocks further settled windows until it settles, or until terminal `abort()`.
47
+ Return or await async work. The queue only sees what the callback returns: an `async` callback is tracked until it settles, but a promise the callback starts and neither awaits nor returns cannot fail, retry, hold a `concurrency` slot, or delay `waitForIdle()`.
74
48
 
75
- ## Handle API
49
+ Each callback receives an `ExecutionContext` with an `AbortSignal` and the one-based `attempt` number (retries count up from 1). Zero-argument callbacks work too. Pass the signal to cancellable APIs such as `fetch` so that `abort()` and `cancel()` can stop in-flight work.
76
50
 
77
- `createThrottledQueue` returns a function with additional properties:
51
+ ## Examples
78
52
 
79
- | Property | Type | Description |
80
- | -------- | ---- | ----------- |
81
- | `pause()` | `() => void` | Temporarily prevent new callback starts while retaining accepted work |
82
- | `resume()` | `() => void` | Resume a paused queue with a fresh pacing and observation window |
83
- | `stop()` | `() => void` | Stop processing the queue immediately |
84
- | `abort()` | `() => void` | Terminally discard queued work and signal active callbacks |
85
- | `waitForIdle()` | `() => Promise<void>` | Resolves when all pending, active, and delayed-retry work has completed |
86
- | `getState()` | `() => QueueState` | Returns a frozen point-in-time snapshot of queue state and counters |
87
- | `pending` | `number` (readonly) | Number of callbacks waiting in the queue, including delayed retries |
53
+ ### Rate-limit API calls
88
54
 
89
55
  ```ts
90
- const throttle = createThrottledQueue({ min_rpi: 5, interval: 1000 });
56
+ import { createThrottledQueue } from "dynamic-throttled-queue";
91
57
 
92
- throttle(() => fetch("/api/data"));
93
- console.log(throttle.pending); // number of queued callbacks
58
+ const throttle = createThrottledQueue({ min_rpi: 1, interval: 1000 });
94
59
 
95
- throttle.stop(); // halt processing
60
+ for (let i = 0; i < 100; i++) {
61
+ throttle(({ signal }) =>
62
+ fetch(`https://api.example.com/items/${i}`, { signal })
63
+ .then(res => res.json())
64
+ .then(console.log)
65
+ );
66
+ }
96
67
  ```
97
68
 
98
- ## Lifecycle
99
-
100
- | State | Pending work and enqueue | Active work | Scheduling and lifecycle operations |
101
- | ----- | ------------------------ | ----------- | ----------------------------------- |
102
- | Running | Pending work can start; new callbacks are accepted. | Continues normally. | `pause()` retains work and stops new starts. `stop()` retains work and clears timers. `abort()` is terminal. A rate strategy failure moves the queue to Failed. |
103
- | Paused | Pending work, newly enqueued callbacks, and retries are retained but do not start. | Continues and settles normally. | `resume()` restarts pacing at the current rate and begins a fresh adaptive-rate observation window. Delayed retries freeze their remaining delay and continue only after resume. `pause()` is idempotent. `stop()` clears the paused state. |
104
- | Stopped | Pending work is retained. A later enqueue restarts scheduling. | Continues and settles normally. | Delayed retries freeze their remaining delay; the later enqueue resumes them. `pause()` and `resume()` are no-ops. `stop()` is idempotent. |
105
- | Aborted | Pending work is discarded and future enqueues throw. | Receives the shared abort signal and may finish cooperatively. | `pause()`, `resume()`, `stop()`, and `abort()` do not restart scheduling; `abort()` is idempotent. |
106
- | Failed | Pending work is discarded and future enqueues throw the strategy error. | Continues and settles without retries or accounting. The shared signal is not aborted. | Terminal. `pause()`, `resume()`, `stop()`, and `abort()` are no-ops. |
69
+ ### Burst instead of spacing
107
70
 
108
- While paused, callback outcomes do not contribute to adaptive-rate adjustment. A failed active callback may still create a configured retry, but that retry remains pending until `resume()`.
109
-
110
- In settled timing, `pause()` discards the in-progress observation window rather than making a partial or late rate decision. `stop()` retains its non-restarting behavior, and `abort()` remains terminal: neither produces post-stop or post-abort settled-window accounting.
111
-
112
- `stop()` clears scheduler timers and retains callbacks that have not started. It cannot cancel an active asynchronous callback; if that callback later succeeds, returns `false`, or rejects, its settlement does not restart scheduling. A retry created by a failed active callback is retained with the pending work. Enqueueing another callback resumes the queue and any frozen delayed retries.
113
-
114
- `abort()` is terminal and idempotent. It clears scheduler timers, discards pending callbacks, and aborts the one shared signal supplied to active callbacks. Future enqueue attempts throw. Cancellation is cooperative: callbacks that ignore the signal can continue running, but their later success or failure does not retry work or affect adaptive-rate accounting.
115
-
116
- A queue fails when `rateStrategy` throws or returns a malformed decision. The queue clears its timers, discards pending callbacks and delayed retries, and reports `state: "failed"`. The original error is still rethrown where the decision ran: an uncaught exception from the adaptive-rate timer, or an unhandled rejection when a slow asynchronous callback completes a settled-timing window. Pending `waitForIdle()` promises reject with that error immediately, even while callbacks are still active; later `waitForIdle()` calls reject with it and later enqueues throw it. Unlike `abort()`, failure does not wait for active callbacks to settle. Create a new queue to resume work.
117
-
118
- `getState()` returns a frozen `QueueState` snapshot. Each call is independent; mutations to the returned object do not affect the queue. Fields: `rate` (current rate), `pending` (queued + delayed retries), `active` (callbacks executing), `state` (`"running"` | `"paused"` | `"stopped"` | `"aborted"` | `"failed"`), and monotonic lifetime counters `started`, `succeeded`, `failed`, `retried`, `rateIncreases`, `rateDecreases`. `started`/`succeeded`/`failed` count callback attempts: a retry is a new attempt; a failure is counted even when a later retry succeeds. `retried` increments only when another attempt is actually scheduled. Rate-direction counters match applied rate changes and `onRateChange` notifications exactly. Counters do not change for settlements after `abort()` or a strategy failure.
119
-
120
- `waitForIdle()` returns a `Promise<void>` that resolves once all pending callbacks, active executions, and delayed retries have reached a terminal outcome. If the queue is already idle the promise resolves immediately. Each call is one-shot: a later enqueue does not affect a promise that has already resolved. Multiple simultaneous callers all resolve at the same idle transition without retaining waiter state. A callback failure that triggers a retry never exposes a transient idle transition between the failed attempt and the retry. `stop()` retains pending work, so existing waiters remain pending until that work completes after a later enqueue resumes the queue. `abort()` discards pending work, so waiters resolve as soon as no callback is active: immediately when none is running, otherwise when the last active callback settles. Post-abort settlements do not create new retry work. A paused queue resolves waiters only when both pending and active work are zero. A failed queue rejects every waiter with the strategy error at once, without waiting for active callbacks.
121
-
122
- ## Examples
123
-
124
- ### Basic
71
+ `evenly_spaced: false` starts up to `min_rpi` callbacks together at the start of each interval.
125
72
 
126
73
  ```ts
127
- import { createThrottledQueue } from "dynamic-throttled-queue";
128
-
129
- const throttle = createThrottledQueue({ min_rpi: 1, interval: 1000 });
74
+ const throttle = createThrottledQueue({ min_rpi: 10, interval: 1000, evenly_spaced: false });
130
75
 
131
76
  for (let i = 0; i < 100; i++) {
132
- throttle(() => {
133
- fetch("https://api.example.com/data").then(console.log);
134
- });
77
+ throttle(() => fetch("https://api.example.com/data")); // up to 10 at once per second
135
78
  }
136
79
  ```
137
80
 
138
- ### Batch mode (not evenly spaced)
81
+ ### Get results back
139
82
 
140
83
  ```ts
141
- const throttle = createThrottledQueue({ min_rpi: 10, interval: 1000, evenly_spaced: false });
84
+ const queue = createThrottledQueue({ min_rpi: 5, interval: 1000 });
85
+
86
+ const users = await Promise.all(
87
+ [ 1, 2, 3 ].map(id =>
88
+ queue.submit(async ({ signal, attempt }) => {
89
+ const res = await fetch(`https://api.example.com/users/${id}`, { signal });
90
+ if (!res.ok) throw new Error(`HTTP ${res.status} on attempt ${attempt}`);
91
+ return res.json() as Promise<{ name: string; }>;
92
+ }).result
93
+ )
94
+ );
95
+ ```
142
96
 
143
- for (let i = 0; i < 100; i++) {
144
- throttle(() => {
145
- // Fires up to 10 at once per second
146
- fetch("https://api.example.com/data").then(console.log);
147
- });
97
+ ### Cancel an item
98
+
99
+ ```ts
100
+ const task = queue.submit(({ signal }) => fetch("https://api.example.com/slow", { signal }));
101
+
102
+ const timer = setTimeout(() => task.cancel(), 5000);
103
+ try {
104
+ const res = await task.result;
105
+ console.log(res.status);
106
+ }
107
+ catch (error) {
108
+ console.log("canceled or failed", error);
109
+ }
110
+ finally {
111
+ clearTimeout(timer);
148
112
  }
149
113
  ```
150
114
 
151
- ### Dynamic rate adjustment
115
+ `result` is an ordinary promise. If you cancel or abort without awaiting it, attach a handler (for example `task.result.catch(() => {})`) so the rejection is not reported as unhandled.
116
+
117
+ ### Adapt the rate to errors
152
118
 
153
- When `max_rpi` > `min_rpi`, the queue dynamically adjusts throughput based on errors. Starts at the midpoint and scales up (0 errors) or down (>= threshold errors) each interval.
119
+ When `max_rpi` is greater than `min_rpi`, the queue adjusts throughput based on failures. It starts at the midpoint of the range, then each interval scales up after a window with no errors and down after a window that reaches `errors_per_interval` errors.
154
120
 
155
121
  ```ts
156
122
  const throttle = createThrottledQueue({
@@ -158,32 +124,53 @@ const throttle = createThrottledQueue({
158
124
  max_rpi: 10,
159
125
  interval: 1000,
160
126
  errors_per_interval: 3,
161
- onRateChange: (rate) => console.log(`Rate: ${rate}/interval`),
127
+ back_off: true, // also pause for one interval when the threshold is hit
128
+ onRateChange: rate => console.log(`Rate: ${rate}/interval`),
162
129
  });
163
130
 
164
131
  for (let i = 0; i < 100; i++) {
165
132
  throttle(async () => {
166
133
  const res = await fetch("https://api.example.com/data");
167
- if (!res.ok) return false; // signals an error
134
+ return res.ok;
168
135
  });
169
136
  }
170
137
  ```
171
138
 
172
- ### Rate outcome classification
139
+ ### Retry failures
173
140
 
174
- `rateOutcomeClassifier` receives only failed callback outcomes and returns whether each one should reduce the adaptive rate. It does not change retry eligibility: `false`, thrown errors, and rejected promises still retry under the existing `retry` option.
141
+ ```ts
142
+ const throttle = createThrottledQueue({
143
+ min_rpi: 5,
144
+ interval: 1000,
145
+ retry: 3, // up to 3 more attempts per item
146
+ retryBackoff: {
147
+ strategy: "exponential", // 250 ms, 500 ms, 1000 ms
148
+ baseDelay: 250,
149
+ maxDelay: 10_000,
150
+ jitter: 0.2,
151
+ },
152
+ });
153
+
154
+ throttle(async () => {
155
+ const res = await fetch("https://api.example.com/data");
156
+ return res.ok; // `false` is retried
157
+ });
158
+ ```
159
+
160
+ Without `retryBackoff`, retries rejoin the queue immediately.
161
+
162
+ ### Choose which failures retry or slow the queue
163
+
164
+ `retryClassifier` decides whether a failure is retried; `rateOutcomeClassifier` decides whether it reduces the adaptive rate. Each receives a `FailureOutcome`: `{ kind: "thrown", error }`, `{ kind: "rejected", error }`, or, for fire-and-forget callbacks only, `{ kind: "returned-false" }`. A failure can be retryable, rate-reducing, both, or neither.
175
165
 
176
166
  ```ts
177
- import {
178
- createThrottledQueue,
179
- type RateFailureOutcome,
180
- } from "dynamic-throttled-queue";
167
+ import { createThrottledQueue, type FailureOutcome } from "dynamic-throttled-queue";
181
168
 
182
- function affectsCapacity(outcome: RateFailureOutcome) {
169
+ function affectsCapacity(outcome: FailureOutcome) {
183
170
  if (outcome.kind === "returned-false") return false;
184
171
 
185
172
  // Treat HTTP 429 and 5xx as capacity signals, while ignoring other failures.
186
- const status = (outcome.error as { status?: number }).status;
173
+ const status = (outcome.error as { status?: number; }).status;
187
174
  return status === 429 || (status !== undefined && status >= 500 && status < 600);
188
175
  }
189
176
 
@@ -191,32 +178,59 @@ const throttle = createThrottledQueue({
191
178
  min_rpi: 1,
192
179
  max_rpi: 10,
193
180
  interval: 1000,
181
+ retry: 2,
182
+ retryClassifier: (outcome, attempt) => outcome.kind === "rejected" && attempt < 3,
194
183
  rateOutcomeClassifier: affectsCapacity,
195
184
  });
196
185
  ```
197
186
 
198
- The normalized outcomes are `{ kind: "returned-false" }`, `{ kind: "thrown", error }`, and `{ kind: "rejected", error }`. Omitting the classifier preserves the default behavior: every failure reduces the adaptive rate. If the classifier throws, the original failure safely counts as rate-reducing and no separate classifier error is surfaced.
187
+ ### Honour `Retry-After` on HTTP 429
199
188
 
200
- ### Retry classification
189
+ `cooldownFor(delay)` stops the queue from starting anything new for `delay` milliseconds. Combine it with the classifiers to retry rate-limited calls and slow down, without retrying client errors.
201
190
 
202
- `retryClassifier` receives the same normalized failure outcome and a one-based attempt number, including the initial callback start. It must return literal `true` for the failure to be retried; any other value makes it permanent. The configured `retry` value remains the hard cap on additional attempts, so the classifier is not called after that budget is exhausted. If it throws, the queue preserves legacy behavior and retries subject to the remaining budget.
191
+ ```ts
192
+ import { createThrottledQueue, type FailureOutcome } from "dynamic-throttled-queue";
193
+
194
+ class HttpError extends Error {
195
+ status: number;
196
+ constructor(status: number) {
197
+ super(`HTTP ${status}`);
198
+ this.status = status;
199
+ }
200
+ }
203
201
 
204
- Retry eligibility is independent from adaptive-rate accounting. A failure can be retryable, rate-reducing, both, or neither:
202
+ const isCapacityError = (outcome: FailureOutcome) =>
203
+ "error" in outcome && outcome.error instanceof HttpError && (outcome.error.status === 429 || outcome.error.status >= 500);
205
204
 
206
- ```ts
207
- const throttle = createThrottledQueue({
205
+ const queue = createThrottledQueue({
208
206
  min_rpi: 1,
207
+ max_rpi: 20,
209
208
  interval: 1000,
210
- retry: 2,
211
- retryClassifier: (outcome, attempt) =>
212
- outcome.kind === "rejected" && attempt < 3,
213
- rateOutcomeClassifier: outcome => outcome.kind === "rejected",
209
+ retry: 3,
210
+ retryBackoff: { strategy: "exponential", baseDelay: 500, maxDelay: 10_000, jitter: 0.2 },
211
+ retryClassifier: isCapacityError,
212
+ rateOutcomeClassifier: isCapacityError,
213
+ maxCooldown: 60_000,
214
214
  });
215
+
216
+ function getJson(url: string): Promise<unknown> {
217
+ return queue.submit(async ({ signal }) => {
218
+ const res = await fetch(url, { signal });
219
+ if (res.status === 429) {
220
+ const seconds = Number(res.headers.get("Retry-After"));
221
+ if (Number.isFinite(seconds) && seconds > 0) queue.cooldownFor(seconds * 1000);
222
+ }
223
+ if (!res.ok) throw new HttpError(res.status);
224
+ return res.json();
225
+ }).result;
226
+ }
215
227
  ```
216
228
 
217
- ### Rate strategies
229
+ The queue does not read HTTP responses: convert the hint to milliseconds yourself, clamping past dates to `0`.
218
230
 
219
- The default `linear` strategy preserves the adaptive behavior above: it changes the rate by one request per interval. Use `aimd` when a capacity signal should reduce throughput more quickly while recovery remains gradual. AIMD increases by a fixed amount after a clean eligible observation window and reduces the rate by a multiplier when the error threshold is reached.
231
+ ### Swap the rate strategy
232
+
233
+ The default `linear` strategy changes the rate by one request per interval. Use `aimd` when a capacity signal should cut throughput quickly while recovery stays gradual.
220
234
 
221
235
  ```ts
222
236
  import { aimd, createThrottledQueue } from "dynamic-throttled-queue";
@@ -229,8 +243,6 @@ const throttle = createThrottledQueue({
229
243
  });
230
244
  ```
231
245
 
232
- `aimd()` defaults to `{ increaseBy: 1, decreaseFactor: 0.5 }`. `increaseBy` must be a positive integer; `decreaseFactor` must be greater than `0` and less than `1`. AIMD uses `Math.floor(currentRate * decreaseFactor)` when reducing the rate, then the queue applies its configured `min_rpi` and `max_rpi` bounds. Like `linear`, it holds steady for partial-error, empty, and immediately-post-backoff observation windows.
233
-
234
246
  You can also import and pass `linear` explicitly, or provide a custom pure strategy:
235
247
 
236
248
  ```ts
@@ -261,58 +273,188 @@ const throttle = createThrottledQueue({
261
273
  createThrottledQueue({ min_rpi: 1, max_rpi: 10, interval: 1000, rateStrategy: linear });
262
274
  ```
263
275
 
264
- A `RateStrategy` receives a frozen observation with `currentRate`, `minRate`, `maxRate`, `errorCount`, `errorThreshold`, `hasPendingWork`, and `wasBackedOff`; it returns `{ nextRate, shouldBackOff }`. Every queue starts at the midpoint of its configured range. The queue clamps finite integer `nextRate` values to its bounds. `shouldBackOff` requests a pause, which the queue performs only when `back_off` is `true`.
265
-
266
- Strategies must return a finite integer `nextRate` and a boolean `shouldBackOff`. A malformed decision or a strategy exception fails that queue permanently: see [Lifecycle](#lifecycle) for what the failed state discards, rejects and rethrows. Create a new queue instance to resume work.
267
-
268
- ### Backoff
276
+ ### Bound in-flight work and queue size
269
277
 
270
278
  ```ts
271
279
  const throttle = createThrottledQueue({
272
280
  min_rpi: 10,
273
281
  interval: 1000,
274
- back_off: true,
275
- errors_per_interval: 2,
282
+ concurrency: 3, // at most 3 callbacks awaiting completion
283
+ maxQueueSize: 1000, // at most 1000 accepted items not yet finished
276
284
  });
285
+
286
+ try {
287
+ throttle(() => fetch("https://api.example.com/data"));
288
+ }
289
+ catch {
290
+ // The queue is full: shed the work or try later.
291
+ }
277
292
  ```
278
293
 
279
- ### Retry
294
+ `concurrency` is independent of the start rate. `maxQueueSize` reserves capacity from the moment work is queued until it finally succeeds or fails, including active callbacks and retries. A full queue makes `throttle()` and `submit()` throw synchronously.
295
+
296
+ ### Pause, drain and shut down
280
297
 
281
298
  ```ts
282
- const throttle = createThrottledQueue({
283
- min_rpi: 5,
284
- interval: 1000,
285
- retry: 3, // retry failed callbacks up to 3 times
286
- });
299
+ throttle.pause(); // nothing new starts; queued work is kept
300
+ throttle.resume();
287
301
 
288
- throttle(async () => {
289
- const res = await fetch("https://api.example.com/data");
290
- if (!res.ok) return false; // will be retried
291
- });
302
+ await throttle.waitForIdle(); // every queued, active and retrying item has finished
303
+
304
+ throttle.abort(); // terminal: discard queued work and abort the signal
292
305
  ```
293
306
 
294
- ### Retry backoff
307
+ ### Monitor the queue
295
308
 
296
309
  ```ts
297
- const throttle = createThrottledQueue({
298
- min_rpi: 5,
299
- interval: 1000,
300
- retry: 3,
301
- retryBackoff: {
302
- strategy: "exponential",
303
- baseDelay: 250,
304
- maxDelay: 10_000,
305
- jitter: 0.2,
306
- },
307
- });
310
+ const { rate, pending, active, state, failed, retried } = throttle.getState();
311
+ console.log({ rate, pending, active, state, failed, retried });
308
312
  ```
309
313
 
314
+ ## Options
315
+
316
+ ### Rate
317
+
318
+ | Option | Type | Default | Description |
319
+ | ------ | ---- | ------- | ----------- |
320
+ | `min_rpi` | `number` | *required* | Minimum requests per interval |
321
+ | `max_rpi` | `number` | `min_rpi` | Maximum requests per interval |
322
+ | `interval` | `number` | *required* | Milliseconds between each batch of requests |
323
+ | `evenly_spaced` | `boolean` | `true` | Distribute requests evenly throughout the interval |
324
+ | `errors_per_interval` | `number` | `5` | Positive integer error threshold per interval before adjusting rate |
325
+ | `back_off` | `boolean` | `false` | Back off for 1 full interval when error threshold is hit |
326
+ | `rateStrategy` | `RateStrategy` | `linear` | Pure policy that requests the next rate and an optional backoff after each observation window |
327
+ | `rateOutcomeClassifier` | `RateOutcomeClassifier` | — | Decides whether a failed callback outcome contributes to adaptive-rate error counting |
328
+ | `adjustmentTiming` | `"interval" \| "settled"` | `"interval"` | Whether rate decisions use outcomes settled each interval or complete started-attempt sets; see [Adaptive-rate timing](#adaptive-rate-timing) |
329
+ | `onRateChange` | `(rate: number) => void` | — | Called when the current rate changes |
330
+
331
+ ### Retry
332
+
333
+ | Option | Type | Default | Description |
334
+ | ------ | ---- | ------- | ----------- |
335
+ | `retry` | `number` | `0` | Non-negative integer number of times to retry failed callbacks |
336
+ | `retryBackoff` | `RetryBackoff` | — | Per-retry fixed, linear, or exponential delay policy; omit for immediate retries |
337
+ | `retryClassifier` | `RetryClassifier` | — | Decides whether a failed callback outcome is eligible for another attempt |
338
+
339
+ ### Capacity and cooldown
340
+
341
+ | Option | Type | Default | Description |
342
+ | ------ | ---- | ------- | ----------- |
343
+ | `concurrency` | `number` | — | Maximum callbacks awaiting asynchronous completion; omit for no limit |
344
+ | `maxQueueSize` | `number` | — | Maximum accepted callbacks not yet terminal, including pending, active, and retried work; omit for no limit |
345
+ | `maxCooldown` | `number` | `2147483647` | Longest cooldown, in milliseconds, that `cooldownFor()` applies; longer requests are clamped. Must be positive and at most `2147483647`, the largest delay timers honor |
346
+ | `compact_threshold` | `number` | `512` | Non-negative integer minimum dead slots before internal queue compaction triggers; `0` compacts at the earliest eligible point |
347
+
348
+ ### Validation
349
+
350
+ `createThrottledQueue` throws when an option is out of range. `min_rpi` must be a positive integer and `max_rpi` an integer no smaller than it; `interval` must be a positive number. `retry`, `errors_per_interval`, `compact_threshold`, `concurrency`, and `maxQueueSize` reject fractional and non-finite values. `errors_per_interval` and `concurrency` must be at least `1`; `retry`, `compact_threshold`, and `maxQueueSize` may be `0`. `retryBackoff.strategy` must be `fixed`, `linear`, or `exponential`. `retryBackoff.baseDelay` and optional `maxDelay` are finite non-negative millisecond values (fractional values are accepted); optional `jitter` is finite from `0` through `1`.
351
+
352
+ ### `retryBackoff`
353
+
354
+ `retryBackoff` is `{ strategy: "fixed" | "linear" | "exponential"; baseDelay: number; maxDelay?: number; jitter?: number; random?: () => number }`. The first retry has index `1`: fixed uses `baseDelay`, linear uses `baseDelay × retryIndex`, and exponential uses `baseDelay × 2^(retryIndex - 1)`. The calculated delay is capped at `maxDelay`, when supplied, then optional symmetric percentage jitter is applied and capped again. `random` makes jitter deterministic for tests; a thrown, non-finite, or out-of-range result safely falls back to no jitter. Retry backoff begins when the failed attempt settles and is independent of adaptive-rate `back_off`.
355
+
310
356
  Delayed retries remain pending, return to the normal queue tail when due, and then obey normal scheduler pacing. Equal due times preserve the order their retry delays were scheduled. `abort()` discards delayed retries along with other pending work.
311
357
 
312
- ## Migration from v1
358
+ ## Queue API
359
+
360
+ `createThrottledQueue(options)` returns a `ThrottleHandle`: a function that queues fire-and-forget work, with these methods.
361
+
362
+ | Method | Type | Description |
363
+ | ------ | ---- | ----------- |
364
+ | `(callback)` | `(callback: ThrottleCallback) => void` | Queue fire-and-forget work; see [Queuing work](#queuing-work) |
365
+ | `submit(callback)` | `<T>(callback: TaskCallback<T>) => TaskHandle<T>` | Queue one item and return its `{ result, cancel }` handle; see [Task handles](#task-handles) |
366
+ | `pause()` | `() => void` | Temporarily prevent new callback starts while retaining accepted work |
367
+ | `resume()` | `() => void` | Resume a paused queue with a fresh pacing and observation window |
368
+ | `stop()` | `() => void` | Stop scheduling until more work is queued; queued work is kept |
369
+ | `abort()` | `() => void` | Terminally discard queued work and signal active callbacks |
370
+ | `cooldownFor(delay)` | `(delay: number) => void` | Start nothing new for `delay` ms, for example after a server's `Retry-After`; see [Server-directed cooldown](#server-directed-cooldown) |
371
+ | `waitForIdle()` | `() => Promise<void>` | Resolves when all pending, active, and delayed-retry work has completed |
372
+ | `getState()` | `() => QueueState` | Returns a frozen point-in-time snapshot of queue state and counters |
373
+
374
+ The package also exports `aimd` and `linear`, and these types: `ThrottleOptions`, `ThrottleHandle`, `ThrottleFn`, `ThrottleCallback`, `TaskCallback`, `TaskHandle`, `ExecutionContext`, `QueueState`, `QueueLifecycleState`, `FailureOutcome`, `RateOutcomeClassifier`, `RetryClassifier`, `RetryBackoff`, `RateStrategy`, `RateStrategyObservation`, `RateStrategyDecision`, `AimdOptions`, and `AdjustmentTiming`.
375
+
376
+ ## Behaviour in detail
377
+
378
+ ### Task handles
379
+
380
+ `submit()` accepts a callback like the fire-and-forget form, but returns a handle for that one item, covering every attempt it makes.
381
+
382
+ - **Results.** Every value the callback returns or resolves, including `false`, is the result. Only a throw or a rejection is a failure, and it goes through `retry`, `retryClassifier`, and `rateOutcomeClassifier` the same as any other failure. `result` fulfills once, with the final successful value, or rejects once, with the final attempt's error after retries end.
383
+ - **Cancellation.** `cancel(reason?)` rejects `result` immediately with `reason`, or with an `AbortError` when no reason is given. A canceled item that has not started (including one waiting on a delayed retry) is removed: it never starts, frees its `maxQueueSize` slot, and does not use a start slot. A canceled active item has its own signal aborted, and only that signal; other work and the queue signal are unaffected. It keeps its concurrency slot until the callback returns, and `waitForIdle()` waits for it. Its outcome never retries and never counts as a success, a failure, or a rate-reducing error. Calling `cancel()` again, or after `result` has settled, does nothing.
384
+ - **Queue termination.** `abort()` rejects every unsettled item at once with the queue signal's `AbortError` and aborts each item's signal. A strategy failure (see [Lifecycle](#lifecycle)) does the same with the strategy error. Values that arrive later are discarded.
385
+ - **Retained work.** `pause()` and `stop()` keep items and leave their results pending.
386
+ - **Admission.** `submit()` throws synchronously and returns no handle when the queue would reject fire-and-forget work: the queue is full, aborted, or failed.
387
+
388
+ ### Lifecycle
389
+
390
+ | State | Pending work and new work | Active work | Scheduling and lifecycle operations |
391
+ | ----- | ------------------------- | ----------- | ----------------------------------- |
392
+ | Running | Pending work can start; new callbacks are accepted. | Continues normally. | `pause()` retains work and stops new starts. `stop()` retains work and clears timers. `abort()` is terminal. A rate strategy failure moves the queue to Failed. |
393
+ | Paused | Pending work, newly queued callbacks, and retries are retained but do not start. | Continues and settles normally. | `resume()` restarts pacing at the current rate and begins a fresh adaptive-rate observation window. Delayed retries freeze their remaining delay and continue only after resume. `pause()` is idempotent. `stop()` clears the paused state. |
394
+ | Stopped | Pending work is retained. Queuing new work restarts scheduling. | Continues and settles normally. | Delayed retries freeze their remaining delay; the restart resumes them. `pause()` and `resume()` are no-ops. `stop()` is idempotent. |
395
+ | Aborted | Pending work is discarded and new work throws. | Receives the shared abort signal and may finish cooperatively. | `pause()`, `resume()`, `stop()`, and `abort()` do not restart scheduling; `abort()` is idempotent. |
396
+ | Failed | Pending work is discarded and new work throws the strategy error. | Continues and settles without retries or accounting. The shared signal is not aborted. | Terminal. `pause()`, `resume()`, `stop()`, and `abort()` are no-ops. |
397
+
398
+ While paused, callback outcomes do not contribute to adaptive-rate adjustment. A failed active callback may still create a configured retry, but that retry remains pending until `resume()`.
399
+
400
+ In settled timing, `pause()` discards the in-progress observation window rather than making a partial or late rate decision. `stop()` retains its non-restarting behavior, and `abort()` remains terminal: neither produces post-stop or post-abort settled-window accounting.
401
+
402
+ `stop()` clears scheduler timers and retains callbacks that have not started. It cannot cancel an active asynchronous callback; if that callback later succeeds, returns `false`, or rejects, its settlement does not restart scheduling. A retry created by a failed active callback is retained with the pending work. Queuing another callback resumes the queue and any frozen delayed retries.
403
+
404
+ `abort()` is terminal and idempotent. It clears scheduler timers, discards pending callbacks, and aborts the one shared signal supplied to active callbacks. Queuing more work throws. Cancellation is cooperative: callbacks that ignore the signal can continue running, but their later success or failure does not retry work or affect adaptive-rate accounting.
405
+
406
+ A queue fails when `rateStrategy` throws or returns a malformed decision. The queue clears its timers, discards pending callbacks and delayed retries, and reports `state: "failed"`. The original error is still rethrown where the decision ran: an uncaught exception from the adaptive-rate timer, or an unhandled rejection when a slow asynchronous callback completes a settled-timing window. Pending `waitForIdle()` promises reject with that error immediately, even while callbacks are still active; later `waitForIdle()` calls reject with it and queuing more work throws it. Unlike `abort()`, failure does not wait for active callbacks to settle. Create a new queue to resume work.
407
+
408
+ ### `getState()`
409
+
410
+ `getState()` returns a frozen `QueueState` snapshot. Each call is independent; mutations to the returned object do not affect the queue. Fields: `rate` (current rate), `pending` (queued + delayed retries), `active` (callbacks executing), `state` (`"running"` | `"paused"` | `"stopped"` | `"aborted"` | `"failed"`), `cooldownRemaining` (milliseconds, rounded up, until an active cooldown ends; `0` when none is active), and monotonic lifetime counters `started`, `succeeded`, `failed`, `retried`, `canceled`, `rateIncreases`, `rateDecreases`, `cooldowns`, `cooldownTotal`. `started`/`succeeded`/`failed` count callback attempts: a retry is a new attempt; a failure is counted even when a later retry succeeds. `retried` increments only when another attempt is actually scheduled. `canceled` counts submitted items whose result `cancel()` rejected; a canceled active attempt counts as neither succeeded nor failed. Rate-direction counters match applied rate changes and `onRateChange` notifications exactly. `cooldowns` counts `cooldownFor()` calls that moved the deadline later, and `cooldownTotal` is the milliseconds they added. Counters do not change for settlements after `abort()` or a strategy failure.
411
+
412
+ ### `waitForIdle()`
413
+
414
+ `waitForIdle()` returns a `Promise<void>` that resolves once all pending callbacks, active executions, and delayed retries have reached a terminal outcome. If the queue is already idle the promise resolves immediately. Each call is one-shot: queuing more work does not affect a promise that has already resolved. Multiple simultaneous callers all resolve at the same idle transition without retaining waiter state. A callback failure that triggers a retry never exposes a transient idle transition between the failed attempt and the retry. `stop()` retains pending work, so existing waiters remain pending until that work completes after newly queued work resumes the queue. `abort()` discards pending work, so waiters resolve as soon as no callback is active: immediately when none is running, otherwise when the last active callback settles. Post-abort settlements do not create new retry work. A paused queue resolves waiters only when both pending and active work are zero. A failed queue rejects every waiter with the strategy error at once, without waiting for active callbacks.
415
+
416
+ ### Adaptive-rate timing
417
+
418
+ `adjustmentTiming: "interval"` is the default: each configured interval observes the callback outcomes that have settled so far. A slow callback can therefore settle after its start interval and affect a later rate decision.
419
+
420
+ With `adjustmentTiming: "settled"`, an observation window contains every callback attempt that starts during one full configured interval. When collection closes, no further callbacks start until every attempt in that window settles; the queue then makes exactly one rate decision from the complete window. The next collection interval begins immediately after that decision, unless adaptive-rate `back_off` delays scheduling. Empty settled intervals do not produce a decision. A never-settling callback blocks further settled windows until it settles, or until terminal `abort()`.
421
+
422
+ ### Rate strategies
423
+
424
+ AIMD increases by a fixed amount after a clean eligible observation window and reduces the rate by a multiplier when the error threshold is reached. `aimd()` defaults to `{ increaseBy: 1, decreaseFactor: 0.5 }`. `increaseBy` must be a positive integer; `decreaseFactor` must be greater than `0` and less than `1`. AIMD uses `Math.floor(currentRate * decreaseFactor)` when reducing the rate, then the queue applies its configured `min_rpi` and `max_rpi` bounds. Like `linear`, it holds steady for partial-error, empty, and immediately-post-backoff observation windows.
425
+
426
+ A `RateStrategy` receives a frozen observation with `currentRate`, `minRate`, `maxRate`, `errorCount`, `errorThreshold`, `hasPendingWork`, and `wasBackedOff`; it returns `{ nextRate, shouldBackOff }`. Every queue starts at the midpoint of its configured range. The queue clamps finite integer `nextRate` values to its bounds. `shouldBackOff` requests a pause, which the queue performs only when `back_off` is `true`.
427
+
428
+ Strategies must return a finite integer `nextRate` and a boolean `shouldBackOff`. A malformed decision or a strategy exception fails that queue permanently: see [Lifecycle](#lifecycle) for what the failed state discards, rejects and rethrows. Create a new queue instance to resume work.
429
+
430
+ ### Failure classification
431
+
432
+ `rateOutcomeClassifier` receives only failed callback outcomes and returns whether each one should reduce the adaptive rate. It does not change retry eligibility. Omitting it preserves the default behavior: every failure reduces the adaptive rate. If the classifier throws, the original failure safely counts as rate-reducing and no separate classifier error is surfaced.
433
+
434
+ `retryClassifier` receives the same `FailureOutcome` and a one-based attempt number, including the initial callback start. Submitted items never produce `returned-false`, because `false` is a normal `submit()` result. The classifier must return literal `true` for the failure to be retried; any other value makes it permanent. The configured `retry` value remains the hard cap on additional attempts, so the classifier is not called after that budget is exhausted. If it throws, the queue retries subject to the remaining budget.
435
+
436
+ ### Server-directed cooldown
437
+
438
+ `cooldownFor(delay)` stops the queue from starting anything new for `delay` milliseconds. See [Honour `Retry-After` on HTTP 429](#honour-retry-after-on-http-429) for a full example.
439
+
440
+ - **Scheduling.** Pending work, newly queued work, and retries are kept and do not start. Active callbacks keep running and settle normally. When the cooldown ends, starts resume without new work being queued; the first one is due one spacing later, as after any fresh start. Delayed retries keep counting down and join the queue when due.
441
+ - **Overlaps.** Each call asks for "nothing before now + `delay`". A later deadline replaces the current one; an earlier one is ignored. Two 60-second hints that arrive together give one 60-second cooldown, not two.
442
+ - **Validation.** `NaN`, negative, and infinite delays throw a `RangeError` in every lifecycle state, including after `abort()` or a strategy failure. Delays above `maxCooldown` are clamped to it. `0` does nothing.
443
+ - **Lifecycle.** `pause()` and cooldown are separate: a cooldown keeps elapsing while paused, its end does not resume a paused queue, and `resume()` does not end it. `stop()` clears the cooldown timer but keeps the deadline, so the work that restarts the queue still waits for it. `abort()` and a strategy failure clear the cooldown; later valid calls do nothing.
444
+ - **Adaptive rate.** No rate decision is made while cooling down, so quiet intervals do not raise the rate. Failures that settle during the cooldown still count toward the first decision after it. In interval timing, a fresh interval begins when the cooldown ends. In settled timing, a cooldown closes the open collection interval early; once it ends and every callback started in that window has settled, the queue decides that window and opens the next. A cooldown replaces any adaptive-rate backoff already in progress. Outcomes that settle while paused stay ignored, as without a cooldown.
445
+ - **Separate decisions.** A cooldown does not reduce the rate or retry anything. Use `rateOutcomeClassifier` and `retryClassifier` for those; the three can be combined freely.
446
+
447
+ ## Migration
448
+
449
+ ### From v2
450
+
451
+ - The `pending` property is removed. Read `getState().pending` instead.
452
+ - The `RateFailureOutcome` type is renamed to `FailureOutcome`.
453
+
454
+ ### From v1
313
455
 
314
- - `errors_per_second` removed — use `errors_per_interval` (counts errors per full interval window, not per second).
315
- - `debug` option removed — use `onRateChange` callback for observability.
456
+ - `errors_per_second` removed. Use `errors_per_interval`, which counts errors per full interval window, not per second.
457
+ - `debug` option removed. Use the `onRateChange` callback for observability.
316
458
 
317
459
  ## Development
318
460
 
@@ -320,7 +462,7 @@ Delayed retries remain pending, return to the normal queue tail when due, and th
320
462
  pnpm install
321
463
  pnpm build # build with zshy (ESM only)
322
464
  pnpm test # run vitest
323
- pnpm lint # type-check
465
+ pnpm lint # type-check and ESLint
324
466
  ```
325
467
 
326
468
  ## License