dynamic-throttled-queue 3.0.0-rc.1d87d99 → 3.0.0-rc.ea3019c

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/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "dynamic-throttled-queue",
3
- "version": "3.0.0-rc.1d87d99",
3
+ "version": "3.0.0-rc.ea3019c",
4
4
  "type": "module",
5
5
  "description": "Dynamically throttles arbitrary code to execute between a minimum and maximum number of times per interval. Best for making throttled API requests.",
6
6
  "files": [
7
- "dist"
7
+ "dist",
8
+ "skills/configure-throttled-queue"
8
9
  ],
9
10
  "zshy": {
10
11
  "exports": {
@@ -22,7 +23,8 @@
22
23
  "queue",
23
24
  "throttle",
24
25
  "throttled",
25
- "request"
26
+ "request",
27
+ "tanstack-intent"
26
28
  ],
27
29
  "author": "Adrian Elton-Browning <gingacodemonkey@gmail.com>",
28
30
  "license": "MIT",
@@ -33,6 +35,7 @@
33
35
  "@commitlint/cli": "21.2.3",
34
36
  "@commitlint/config-conventional": "20.0.0",
35
37
  "@gingacodemonkey/config": "^0.0.38",
38
+ "@tanstack/intent": "0.5.0",
36
39
  "@types/node": "^24.13.3",
37
40
  "@varlock/bumpy": "^1.18.1",
38
41
  "eslint": "^9.39.5",
@@ -72,6 +75,7 @@
72
75
  "lint:e18e": "pnpm dlx @e18e/cli analyze",
73
76
  "preinstall": "only-allow pnpm",
74
77
  "bump": "bumpy add",
75
- "bump:status": "bumpy status"
78
+ "bump:status": "bumpy status",
79
+ "skills:check": "intent maintainer check"
76
80
  }
77
81
  }
@@ -0,0 +1,340 @@
1
+ ---
2
+ name: configure-throttled-queue
3
+ description: >
4
+ Use when creating, configuring, or debugging a dynamic-throttled-queue
5
+ (createThrottledQueue): pacing calls to a rate-limited API, choosing
6
+ min_rpi/max_rpi/interval/concurrency, adapting the rate to errors or HTTP
7
+ 429s, retrying failed work, honoring Retry-After, getting results back with
8
+ submit() and cancelling them, or pausing, stopping, aborting, and draining
9
+ the queue.
10
+ metadata:
11
+ purpose: >
12
+ Teach agents to configure dynamic-throttled-queue correctly: start-rate
13
+ pacing versus async concurrency, adaptive-rate outcome classification
14
+ versus retry eligibility, server-directed cooldowns, task handles, and the
15
+ pause/resume/stop/abort/failed lifecycle semantics.
16
+ type: core
17
+ library: dynamic-throttled-queue
18
+ library_version: 2.2.0
19
+ sources:
20
+ - src/*.ts
21
+ - src/__tests__/*.test.ts
22
+ - tests/skills/configure-throttled-queue/*
23
+ - README.md
24
+ ---
25
+
26
+ # Configure a dynamic-throttled-queue
27
+
28
+ `createThrottledQueue(options)` returns a function that enqueues callbacks, plus
29
+ control methods. Work through the steps in order; each ends with a check.
30
+
31
+ ## Setup
32
+
33
+ ```ts
34
+ import { createThrottledQueue } from "dynamic-throttled-queue";
35
+
36
+ // At most 5 request starts per second, spread evenly (one every 200 ms).
37
+ const queue = createThrottledQueue({ min_rpi: 5, interval: 1000 });
38
+
39
+ queue(async ({ signal }) => {
40
+ const response = await fetch("https://api.example.com/data", { signal });
41
+ if (!response.ok) return false; // fire-and-forget failure signal
42
+ });
43
+ ```
44
+
45
+ The package is ESM-only and requires Node.js 24 or later.
46
+
47
+ ## 1. Pick the start rate
48
+
49
+ The rate counts **callback starts per `interval` milliseconds**. It says nothing
50
+ about how long a callback runs.
51
+
52
+ | Option | Meaning | Rule |
53
+ | --- | --- | --- |
54
+ | `min_rpi` | Lowest starts per interval | Required positive integer |
55
+ | `max_rpi` | Highest starts per interval | Integer `>= min_rpi`; defaults to `min_rpi` |
56
+ | `interval` | Window length in ms | Required positive number |
57
+ | `evenly_spaced` | `true` (default): one start every `interval / rate` ms. `false`: up to `rate` starts at once per interval | |
58
+
59
+ - A provider limit of "100 requests per minute" is `{ min_rpi: 100, interval: 60_000 }`.
60
+ Use `evenly_spaced: false` only when bursts are allowed.
61
+ - With `max_rpi === min_rpi` the rate is fixed. With a range, the queue starts at
62
+ `Math.ceil((min_rpi + max_rpi) / 2)` and adapts between the bounds (step 3).
63
+ - Set `max_rpi` to the provider's documented ceiling, never above it: the default
64
+ strategy raises the rate after every clean interval that has pending work.
65
+
66
+ Check: `createThrottledQueue` throws synchronously on invalid numbers, and
67
+ `queue.getState().rate` shows the starting rate.
68
+
69
+ ## 2. Bound in-flight work separately
70
+
71
+ `concurrency` caps callbacks still awaiting asynchronous settlement. It is
72
+ independent of the start rate; omitting it allows unlimited in-flight work.
73
+ Use both when the provider limits requests per window **and** simultaneous
74
+ connections:
75
+
76
+ ```ts
77
+ import { createThrottledQueue } from "dynamic-throttled-queue";
78
+
79
+ const queue = createThrottledQueue({
80
+ min_rpi: 10,
81
+ interval: 1000,
82
+ concurrency: 3, // never more than 3 unsettled callbacks
83
+ maxQueueSize: 1000, // bound accepted, not-yet-terminal work
84
+ });
85
+ ```
86
+
87
+ When `maxQueueSize` is reached, `queue(...)` and `submit(...)` **throw
88
+ synchronously** (`Cannot enqueue work: maxQueueSize has been reached`). Capacity covers
89
+ pending, active, and retrying work until each item is terminal.
90
+
91
+ Check: `getState().active` never exceeds `concurrency`.
92
+
93
+ ## 3. Decide which failures adapt the rate
94
+
95
+ A failure is a callback that returns `false`, throws, or rejects. By default
96
+ every failure counts toward `errors_per_interval` (default `5`). When the count
97
+ reaches the threshold, the default `linear` strategy lowers the rate by 1; an
98
+ interval with zero errors and pending work raises it by 1. `back_off: true`
99
+ also skips one full interval after the threshold is hit.
100
+
101
+ Count only capacity signals with `rateOutcomeClassifier`. It receives
102
+ `{ kind: "returned-false" }`, `{ kind: "thrown", error }`, or
103
+ `{ kind: "rejected", error }` and returns whether the failure reduces the rate:
104
+
105
+ ```ts
106
+ import { createThrottledQueue, type FailureOutcome } from "dynamic-throttled-queue";
107
+
108
+ class HttpError extends Error {
109
+ constructor(readonly status: number) {
110
+ super(`HTTP ${status}`);
111
+ }
112
+ }
113
+
114
+ function isCapacitySignal(outcome: FailureOutcome): boolean {
115
+ if (outcome.kind === "returned-false") return false;
116
+ const { error } = outcome;
117
+ return error instanceof HttpError && (error.status === 429 || error.status >= 500);
118
+ }
119
+
120
+ const queue = createThrottledQueue({
121
+ min_rpi: 1,
122
+ max_rpi: 20,
123
+ interval: 1000,
124
+ errors_per_interval: 3,
125
+ back_off: true,
126
+ rateOutcomeClassifier: isCapacitySignal,
127
+ onRateChange: rate => console.log(`rate is now ${rate}/s`),
128
+ });
129
+ ```
130
+
131
+ If the classifier throws, the failure counts as rate-reducing. To change how
132
+ the rate moves (for example AIMD), read
133
+ [rate strategies and timing](references/rate-strategies.md).
134
+
135
+ Check: `getState().rateDecreases` grows only for the failures you classify as
136
+ capacity signals.
137
+
138
+ ## 4. Configure retries independently
139
+
140
+ `retry` is the hard cap on extra attempts (default `0`). `retryClassifier`
141
+ decides eligibility per failure and must return literal `true` to retry.
142
+ `retryBackoff` delays retries; without it they are immediate.
143
+
144
+ ```ts
145
+ import { createThrottledQueue } from "dynamic-throttled-queue";
146
+
147
+ const queue = createThrottledQueue({
148
+ min_rpi: 5,
149
+ interval: 1000,
150
+ retry: 3,
151
+ retryClassifier: (outcome, attempt) => outcome.kind !== "returned-false" && attempt <= 3,
152
+ retryBackoff: { strategy: "exponential", baseDelay: 250, maxDelay: 10_000, jitter: 0.2 },
153
+ });
154
+ ```
155
+
156
+ Retry and rate decisions are separate: a failure can be retryable,
157
+ rate-reducing, both, or neither. Every retry is a new attempt that obeys normal
158
+ pacing. For backoff formulas and validation, read
159
+ [retry details](references/retries.md).
160
+
161
+ Check: `getState().retried` counts scheduled retries; the callback's
162
+ `attempt` (1-based) counts up across retries.
163
+
164
+ ## 5. Honor server cooldowns
165
+
166
+ When a server says when capacity returns (HTTP 429 with `Retry-After`), call
167
+ `cooldownFor(ms)`. The queue starts nothing new until then, keeps pending
168
+ work, and makes no rate decision meanwhile. It does not retry or lower the rate
169
+ on its own; combine it with steps 3 and 4.
170
+
171
+ ```ts
172
+ import { createThrottledQueue } from "dynamic-throttled-queue";
173
+
174
+ const queue = createThrottledQueue({ min_rpi: 1, max_rpi: 10, interval: 1000, retry: 2, maxCooldown: 60_000 });
175
+
176
+ const task = queue.submit(async ({ signal }) => {
177
+ const response = await fetch("https://api.example.com/data", { signal });
178
+ if (response.status === 429) {
179
+ const seconds = Number(response.headers.get("Retry-After"));
180
+ if (Number.isFinite(seconds) && seconds >= 0) queue.cooldownFor(seconds * 1000);
181
+ throw new Error("rate limited");
182
+ }
183
+ return response.json() as Promise<unknown>;
184
+ });
185
+ ```
186
+
187
+ Convert the header yourself and clamp past dates to `0`: `NaN`, negative, and
188
+ infinite delays throw `RangeError`. Delays above `maxCooldown` are clamped. A
189
+ later deadline replaces an earlier one; overlapping hints do not add up.
190
+
191
+ Check: `getState().cooldownRemaining` is non-zero during the cooldown.
192
+
193
+ ## 6. Get results with task handles
194
+
195
+ Use `submit()` when the caller needs the value. Its semantics differ from the
196
+ fire-and-forget form:
197
+
198
+ - Every returned value, **including `false`**, is a successful result. Only a
199
+ throw or rejection fails, retries, and counts for the rate.
200
+ - `result` settles once: the final value, the last attempt's error, or the
201
+ cancel reason.
202
+ - `cancel(reason?)` rejects `result` immediately (an `AbortError` by default).
203
+ A pending item is removed; an active one has only its own signal aborted and
204
+ keeps its concurrency slot until the callback returns. Cancelled work never
205
+ retries and never counts as success, failure, or a rate error.
206
+
207
+ ```ts
208
+ import { createThrottledQueue } from "dynamic-throttled-queue";
209
+
210
+ const queue = createThrottledQueue({ min_rpi: 5, interval: 1000, retry: 2 });
211
+
212
+ const task = queue.submit(async ({ signal, attempt }) => {
213
+ const response = await fetch("https://api.example.com/item", { signal });
214
+ if (!response.ok) throw new Error(`HTTP ${response.status} on attempt ${attempt}`);
215
+ return (await response.json()) as { id: string };
216
+ });
217
+
218
+ setTimeout(() => task.cancel(), 5000);
219
+ const item = await task.result.catch(() => undefined);
220
+ ```
221
+
222
+ Check: every `result` you cancel or abort has a rejection handler.
223
+
224
+ ## 7. Control the lifecycle
225
+
226
+ | Call | Effect | Reversible |
227
+ | --- | --- | --- |
228
+ | `pause()` | No new starts; keeps work; active callbacks finish | `resume()` |
229
+ | `stop()` | Clears timers; keeps work | The next enqueue restarts it |
230
+ | `abort()` | Discards pending work, aborts the shared signal, later enqueues throw | No |
231
+
232
+ A throwing or malformed `rateStrategy` moves the queue to `failed`: terminal,
233
+ pending work discarded, enqueues throw the strategy error, and `waitForIdle()`
234
+ promises reject with it.
235
+ Create a new queue to continue. For per-state rules, read
236
+ [lifecycle details](references/lifecycle.md) before writing shutdown,
237
+ pause, or error-recovery code.
238
+
239
+ Drain and inspect:
240
+
241
+ ```ts
242
+ import { createThrottledQueue } from "dynamic-throttled-queue";
243
+
244
+ const queue = createThrottledQueue({ min_rpi: 5, interval: 1000 });
245
+ for (const id of ["a", "b", "c"]) {
246
+ queue(async ({ signal }) => {
247
+ await fetch(`https://api.example.com/items/${id}`, { signal });
248
+ });
249
+ }
250
+
251
+ await queue.waitForIdle(); // pending, active, and delayed retries all terminal
252
+ const { succeeded, failed, state } = queue.getState();
253
+ console.log({ succeeded, failed, state });
254
+ ```
255
+
256
+ For every `getState()` field and `waitForIdle()` edge case, read
257
+ [state and idle](references/state-and-idle.md).
258
+
259
+ Check: `waitForIdle()` resolves and `getState().pending` and `.active` are `0`.
260
+
261
+ ## Common Mistakes
262
+
263
+ ### CRITICAL: Ignoring the signal and expecting abort or cancel to stop work
264
+
265
+ ```ts
266
+ import { createThrottledQueue } from "dynamic-throttled-queue";
267
+
268
+ const queue = createThrottledQueue({ min_rpi: 5, interval: 1000 });
269
+ // Wrong: abort() cannot stop this request
270
+ queue(async () => { await fetch("https://api.example.com/data"); });
271
+ // Right
272
+ queue(async ({ signal }) => { await fetch("https://api.example.com/data", { signal }); });
273
+ ```
274
+
275
+ Cancellation is cooperative. A callback that ignores `signal` keeps running
276
+ after `abort()` or `cancel()`; only its outcome is discarded. Source: README
277
+ "Lifecycle"; `src/scheduler.ts`.
278
+
279
+ ### CRITICAL: Starting a promise without returning or awaiting it
280
+
281
+ ```ts
282
+ import { createThrottledQueue } from "dynamic-throttled-queue";
283
+
284
+ const queue = createThrottledQueue({ min_rpi: 5, interval: 1000, concurrency: 2, retry: 2 });
285
+ // Wrong: the queue sees a synchronous success; no retry, no concurrency slot
286
+ queue(({ signal }) => { void fetch("https://api.example.com/data", { signal }).catch(() => {}); });
287
+ // Right: return (or await) the work
288
+ queue(({ signal }) => fetch("https://api.example.com/data", { signal }));
289
+ ```
290
+
291
+ The queue only tracks what the callback returns. An unreturned promise cannot
292
+ fail, retry, hold a `concurrency` slot, or delay `waitForIdle()`. Source: README
293
+ "Queuing work".
294
+
295
+ ### HIGH: Using the rate to limit simultaneous requests
296
+
297
+ `min_rpi: 3` allows 3 starts per interval; slow callbacks pile up without
298
+ bound. Add `concurrency` for an in-flight cap. Source: `ThrottleOptions.concurrency`.
299
+
300
+ ### HIGH: Returning `false` from `submit()` to signal failure
301
+
302
+ In `submit()`, `false` is a successful result: no retry, no rate reduction.
303
+ Throw instead. Fire-and-forget callbacks still treat `false` as failure; any
304
+ other returned value is success. Source: `TaskCallback` and `ThrottleCallback`
305
+ in `src/scheduler.ts`.
306
+
307
+ ### HIGH: Expecting `retryClassifier` or cooldowns to change the rate
308
+
309
+ Retry eligibility, rate counting (`rateOutcomeClassifier`), and `cooldownFor`
310
+ are three independent decisions. Configure each one you need. Source: README
311
+ "Failure classification" and "Server-directed cooldown".
312
+
313
+ ### HIGH: Unhandled rejections from cancelled or aborted tasks
314
+
315
+ `cancel()` and `abort()` reject `task.result`. Attach a handler such as
316
+ `task.result.catch(() => {})` if you do not await it.
317
+
318
+ ### MEDIUM: Treating `stop()` as shutdown
319
+
320
+ `stop()` keeps pending work and the next enqueue restarts processing. Use
321
+ `abort()` for terminal shutdown.
322
+
323
+ ### MEDIUM: Catching the `maxQueueSize` overflow as a return value
324
+
325
+ A full queue throws from `queue(...)` and `submit(...)`. Wrap enqueue
326
+ calls in `try`/`catch` when `maxQueueSize` is set.
327
+
328
+ ## Completion
329
+
330
+ The configuration is complete when:
331
+
332
+ 1. The start rate matches the provider limit and `max_rpi` does not exceed it.
333
+ 2. `concurrency` is set when the provider limits simultaneous requests.
334
+ 3. Each failure type is deliberately classified for rate reduction and retry.
335
+ 4. Every async callback passes `signal` to its I/O.
336
+ 5. Every `submit()` result is awaited or has a rejection handler.
337
+ 6. Shutdown uses `abort()` or `waitForIdle()`, not `stop()`.
338
+
339
+ If the queue reports `state: "failed"`, fix the custom `rateStrategy` and
340
+ create a new queue.
@@ -0,0 +1,46 @@
1
+ # Lifecycle details
2
+
3
+ Read this before writing shutdown, pause/resume, or error-recovery code, or
4
+ when a queue stops starting work unexpectedly.
5
+
6
+ | State | Pending work and enqueue | Active work | Lifecycle calls |
7
+ | --- | --- | --- | --- |
8
+ | `running` | Starts normally; enqueues accepted | Runs | `pause()` retains work and stops starts. `stop()` retains work and clears timers. `abort()` is terminal. A strategy failure moves to `failed`. |
9
+ | `paused` | Pending work, new enqueues, and retries are kept but do not start | Continues and settles | `resume()` restarts pacing at the current rate with a fresh observation window. `pause()` is idempotent. `stop()` clears the paused state. |
10
+ | `stopped` | Pending work kept; the next enqueue restarts scheduling | Continues and settles | `pause()` and `resume()` do nothing. `stop()` is idempotent. |
11
+ | `aborted` | Pending work discarded; enqueues throw | Receives the aborted shared signal and may finish cooperatively | Nothing restarts scheduling. `abort()` is idempotent. |
12
+ | `failed` | Pending work and delayed retries discarded; enqueues throw the strategy error | Continues and settles without retries or accounting; the shared signal is **not** aborted | Terminal. All lifecycle calls do nothing. |
13
+
14
+ ## Rules that are easy to miss
15
+
16
+ - While paused, outcomes do not adjust the rate. A failure can still create a
17
+ retry, which waits for `resume()`.
18
+ - `stop()` cannot cancel an active callback, and that callback settling does not
19
+ restart scheduling. Only a new enqueue does, and it also resumes frozen
20
+ delayed retries.
21
+ - `abort()` aborts one shared signal for fire-and-forget callbacks; `submit()`
22
+ callbacks get a per-item signal that `abort()` also aborts. Late outcomes are
23
+ discarded and never retry.
24
+ - On `failed`, the strategy error is rethrown where the decision ran: as an
25
+ uncaught exception from the adaptive-rate timer, or as an unhandled rejection
26
+ when a slow async callback completes a settled-timing window. Pending and
27
+ later `waitForIdle()` calls reject with it. Create a new queue to resume.
28
+
29
+ ## Cooldown interaction
30
+
31
+ - `pause()` and `cooldownFor()` are separate. A cooldown keeps elapsing while
32
+ paused, its end does not resume a paused queue, and `resume()` does not end it.
33
+ - `stop()` clears the cooldown timer but keeps the deadline, so the restarting
34
+ enqueue still waits for it.
35
+ - `abort()` and a strategy failure clear the cooldown; later valid
36
+ `cooldownFor()` calls do nothing, but invalid delays still throw `RangeError`.
37
+ - A cooldown replaces any adaptive-rate backoff in progress. Failures that
38
+ settle during a cooldown count toward the first decision after it.
39
+
40
+ ## Task handles across the lifecycle
41
+
42
+ - `pause()` and `stop()` leave `result` pending.
43
+ - `abort()` rejects every unsettled `result` with the queue signal's
44
+ `AbortError`; a strategy failure rejects them with the strategy error.
45
+ - `submit()` throws synchronously and returns no handle when the queue is full,
46
+ aborted, or failed.
@@ -0,0 +1,68 @@
1
+ # Rate strategies and adjustment timing
2
+
3
+ Read this when the default one-step `linear` adaptation is too slow or too
4
+ fast, when writing a custom `RateStrategy`, or when slow callbacks make rate
5
+ decisions land in the wrong interval.
6
+
7
+ ## Built-in strategies
8
+
9
+ | Strategy | Threshold reached | Clean interval with pending work | Otherwise |
10
+ | --- | --- | --- | --- |
11
+ | `linear` (default) | `currentRate - 1`, request backoff | `currentRate + 1` | hold |
12
+ | `aimd({ increaseBy = 1, decreaseFactor = 0.5 })` | `Math.floor(currentRate * decreaseFactor)`, request backoff | `currentRate + increaseBy` | hold |
13
+
14
+ A backoff request (`shouldBackOff: true`) skips an interval only when the queue
15
+ has `back_off: true`.
16
+
17
+ "Clean" means zero counted errors and the previous window was not a backoff.
18
+ Partial-error, empty, and immediately-post-backoff windows hold the rate. The
19
+ queue clamps every `nextRate` to `[min_rpi, max_rpi]`. `aimd()` throws unless
20
+ `increaseBy` is a positive integer and `0 < decreaseFactor < 1`.
21
+
22
+ ```ts
23
+ import { aimd, createThrottledQueue } from "dynamic-throttled-queue";
24
+
25
+ const queue = createThrottledQueue({
26
+ min_rpi: 1,
27
+ max_rpi: 100,
28
+ interval: 1000,
29
+ rateStrategy: aimd({ increaseBy: 2, decreaseFactor: 0.5 }),
30
+ });
31
+ ```
32
+
33
+ ## Custom strategies
34
+
35
+ A `RateStrategy` is a pure function. It receives a frozen observation
36
+ `{ currentRate, minRate, maxRate, errorCount, errorThreshold, hasPendingWork, wasBackedOff }`
37
+ and returns `{ nextRate, shouldBackOff }`.
38
+
39
+ ```ts
40
+ import { createThrottledQueue, type RateStrategy } from "dynamic-throttled-queue";
41
+
42
+ const decreaseOnly: RateStrategy = ({ currentRate, minRate, errorCount, errorThreshold }) => ({
43
+ nextRate: errorCount >= errorThreshold ? Math.max(minRate, currentRate - 1) : currentRate,
44
+ shouldBackOff: errorCount >= errorThreshold,
45
+ });
46
+
47
+ const queue = createThrottledQueue({ min_rpi: 1, max_rpi: 10, interval: 1000, rateStrategy: decreaseOnly });
48
+ ```
49
+
50
+ - `nextRate` must be a finite integer and `shouldBackOff` a boolean. Anything
51
+ else, or a thrown error, **fails the queue permanently** (see
52
+ [lifecycle](lifecycle.md)). Never return `NaN`, fractions, or `undefined`.
53
+ - `shouldBackOff` only pauses scheduling when the queue has `back_off: true`.
54
+
55
+ ## Adjustment timing
56
+
57
+ - `adjustmentTiming: "interval"` (default): each interval decides from the
58
+ outcomes that have settled so far. A slow callback can settle after its start
59
+ interval and affect a later decision.
60
+ - `adjustmentTiming: "settled"`: a window holds every attempt started during one
61
+ interval. When collection closes, no further callbacks start until every
62
+ attempt in the window settles, then exactly one decision is made. Use it when
63
+ callbacks run longer than `interval` and decisions must reflect complete
64
+ windows. A callback that never settles blocks later windows until it settles
65
+ or the queue is aborted, so give callbacks timeouts.
66
+
67
+ While paused, settled outcomes do not count toward the rate. In settled timing,
68
+ `pause()` discards the open window.
@@ -0,0 +1,52 @@
1
+ # Retry details
2
+
3
+ Read this when choosing a `retryBackoff` policy, when `createThrottledQueue`
4
+ rejects retry options, or when retry counts in `getState()` look wrong.
5
+
6
+ ## Eligibility
7
+
8
+ 1. A failure (`false` in fire-and-forget form, a throw, or a rejection) is
9
+ eligible only while `retry` budget remains. `retry` must be a non-negative
10
+ integer.
11
+ 2. If `retryClassifier` is set, it is called with the normalized outcome and the
12
+ one-based attempt that just failed. Only literal `true` retries; any other
13
+ value makes the failure permanent. It is not called once the budget is spent.
14
+ 3. If the classifier throws, the queue retries within the remaining budget.
15
+
16
+ Cancelled tasks, and outcomes that settle after `abort()` or a strategy
17
+ failure, never retry.
18
+
19
+ ## Backoff
20
+
21
+ `retryBackoff` is
22
+ `{ strategy: "fixed" | "linear" | "exponential"; baseDelay: number; maxDelay?: number; jitter?: number; random?: () => number }`.
23
+
24
+ For retry index `n` (first retry is `1`):
25
+
26
+ | Strategy | Delay before cap |
27
+ | --- | --- |
28
+ | `fixed` | `baseDelay` |
29
+ | `linear` | `baseDelay × n` |
30
+ | `exponential` | `baseDelay × 2^(n - 1)` |
31
+
32
+ The delay is capped at `maxDelay`, symmetric percentage `jitter` (`0`–`1`) is
33
+ applied, then it is capped again. `baseDelay` and `maxDelay` must be finite and
34
+ non-negative; an unknown `strategy` throws. Pass `random` to make jitter
35
+ deterministic in tests; a throwing or out-of-range `random` falls back to no
36
+ jitter.
37
+
38
+ Backoff starts when the failed attempt settles. It is independent of the
39
+ adaptive-rate `back_off` option.
40
+
41
+ ## Scheduling
42
+
43
+ Delayed retries count as pending work and occupy `maxQueueSize` capacity. When
44
+ due, they rejoin the tail of the queue and obey normal pacing; equal due times
45
+ keep their scheduling order. While paused or stopped, their remaining delay
46
+ freezes. `abort()` discards them.
47
+
48
+ ## Counters
49
+
50
+ `started`, `succeeded`, and `failed` count attempts: a failure is counted even
51
+ when a later retry succeeds. `retried` increments only when another attempt is
52
+ actually scheduled.
@@ -0,0 +1,38 @@
1
+ # State and idle
2
+
3
+ Read this when reporting queue metrics, asserting on queue state in tests, or
4
+ waiting for work to finish.
5
+
6
+ ## `getState()`
7
+
8
+ Returns a frozen snapshot; mutating it does not affect the queue.
9
+
10
+ | Field | Meaning |
11
+ | --- | --- |
12
+ | `rate` | Current starts per interval |
13
+ | `pending` | Queued items plus delayed retries |
14
+ | `active` | Callbacks currently executing |
15
+ | `state` | `"running"`, `"paused"`, `"stopped"`, `"aborted"`, or `"failed"` |
16
+ | `cooldownRemaining` | Ms until the cooldown ends, rounded up; `0` when none |
17
+ | `started`, `succeeded`, `failed` | Attempts; a retry is a new attempt |
18
+ | `retried` | Retries actually scheduled |
19
+ | `canceled` | Submitted items whose `result` `cancel()` rejected |
20
+ | `rateIncreases`, `rateDecreases` | Applied rate changes; match `onRateChange` calls exactly |
21
+ | `cooldowns`, `cooldownTotal` | `cooldownFor()` calls that moved the deadline later, and the ms they added |
22
+
23
+ Counters are monotonic and stop changing after `abort()` or a strategy failure.
24
+ A cancelled active attempt counts as neither succeeded nor failed.
25
+
26
+ ## `waitForIdle()`
27
+
28
+ Resolves once pending work, active callbacks, and delayed retries are all
29
+ terminal; immediately if already idle.
30
+
31
+ - One-shot: a later enqueue does not affect a resolved promise. Concurrent
32
+ callers resolve together.
33
+ - A failure that schedules a retry never produces a transient idle.
34
+ - After `stop()`, waiters stay pending until a later enqueue lets the work
35
+ finish. Do not use `stop()` + `waitForIdle()` as shutdown.
36
+ - After `abort()`, waiters resolve as soon as no callback is active.
37
+ - While paused, waiters resolve only when pending and active are both `0`.
38
+ - On `failed`, every waiter rejects with the strategy error at once.