adaptive-concurrency 0.12.0 → 0.12.1

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/dist/Limiter.d.ts CHANGED
@@ -73,7 +73,8 @@ export interface AllotmentUnavailableStrategy<ContextT> {
73
73
  */
74
74
  onLimitChanged?(oldLimit: number, newLimit: number): MaybePromise<void>;
75
75
  }
76
- export interface LimiterOptions<ContextT> {
76
+ type AnyTimerHandle = NodeJS.Timeout | number;
77
+ export interface LimiterOptions<ContextT, TimerHandle extends AnyTimerHandle = AnyTimerHandle> {
77
78
  limit?: AdaptiveLimit;
78
79
  /**
79
80
  * Clock function returning the current time in fractional milliseconds
@@ -104,6 +105,44 @@ export interface LimiterOptions<ContextT> {
104
105
  * heterogeneous workloads. When omitted, no operation name is provided.
105
106
  */
106
107
  operationNameFor?: (context: ContextT) => string | undefined;
108
+ /**
109
+ * Configuration for the recovery probe used to nudge the limit back above
110
+ * 0 when the underlying strategy has fallen there and there are no
111
+ * inflight requests left to produce samples.
112
+ *
113
+ * The limiter relies on the strategy itself to declare a base interval
114
+ * (via {@link AdaptiveLimit.probeFromZeroInterval}) which is grown
115
+ * exponentially across consecutive failed probes; the options here cap
116
+ * and jitter that interval. Strategies that do not implement the probe
117
+ * methods (e.g. {@link FixedLimit}, {@link VegasLimit}) get no recovery
118
+ * probing regardless of these options.
119
+ */
120
+ recoveryProbe?: {
121
+ /**
122
+ * Soft ceiling on a single probe's unjittered wait time, in
123
+ * milliseconds. The strategy-derived base interval is clamped to this
124
+ * value, then symmetric jitter is applied — so realized waits stay
125
+ * symmetric around `min(base, maxMs)` rather than being biased downward
126
+ * near the cap. Realized waits can therefore exceed `maxMs` by up to
127
+ * `jitter` (e.g. 20% with the default jitter). Default: 30_000.
128
+ */
129
+ maxMs?: number;
130
+ /**
131
+ * Symmetric jitter fraction applied to each probe interval (e.g. 0.2
132
+ * means `interval * uniform(0.8, 1.2)`). Must be in [0, 0.5).
133
+ * Default: 0.2.
134
+ */
135
+ jitter?: number;
136
+ };
137
+ /**
138
+ * Timer functions used to schedule recovery probes. Defaults to the
139
+ * environment's global `setTimeout` / `clearTimeout`. Tests can supply a
140
+ * fake to drive the probe state machine deterministically.
141
+ */
142
+ timer?: {
143
+ setTimeout(fn: () => void, ms: number): TimerHandle;
144
+ clearTimeout(handle: TimerHandle): void;
145
+ };
107
146
  }
108
147
  /**
109
148
  * Concurrency limiter with pluggable strategies for gating decisions and
@@ -111,7 +150,7 @@ export interface LimiterOptions<ContextT> {
111
150
  *
112
151
  * @typeParam ContextT Request context type (e.g. partition key).
113
152
  */
114
- export declare class Limiter<Context = void> {
153
+ export declare class Limiter<Context = void, TimerHandle extends AnyTimerHandle = AnyTimerHandle> implements Disposable {
115
154
  private _inflight;
116
155
  private _limit;
117
156
  private readonly clock;
@@ -120,7 +159,38 @@ export declare class Limiter<Context = void> {
120
159
  private readonly rejectionStrategy;
121
160
  private readonly bypassResolver;
122
161
  private readonly operationNameFor;
123
- private readonly acquireBypassedAllotment;
162
+ private readonly recoveryProbeMaxMs;
163
+ private readonly recoveryProbeJitter;
164
+ private readonly timer;
165
+ /**
166
+ * Number of consecutive recovery-probe *fires* since the last confirming
167
+ * success sample. Read by {@link maybeArmRecoveryProbe} to compute the
168
+ * exponentially-growing wait between probes:
169
+ *
170
+ * `interval(n) = limitAlgorithm.probeFromZeroInterval(n)`
171
+ *
172
+ * Incremented inside the probe-timer callback (right after
173
+ * `applyProbeFromZero` runs), and reset only on a successful (non-drop,
174
+ * non-ignored) sample in `releaseAndRecordSuccess`.
175
+ *
176
+ * Note: this is "consecutive probe fires without a confirming success",
177
+ * **not** "consecutive failed probes". A probe firing successfully raises
178
+ * the limit to 1, but until traffic actually flows through and produces
179
+ * a non-drop sample, we have no evidence the system is healthy. Resetting
180
+ * on the limit-rise alone would cause the failure ladder to collapse to
181
+ * the base interval forever in a flapping-downstream scenario (probe →
182
+ * raise → drop → 0 → probe → ...), defeating the purpose of the
183
+ * exponential backoff.
184
+ *
185
+ * The downside is a benign quirk: after a probe successfully raises the
186
+ * limit, if no traffic arrives for a long time and then the limit later
187
+ * drops to 0 again, the next probe waits at `interval(N+1)` instead of
188
+ * `interval(0)`. This errs on the side of caution and is acceptable.
189
+ */
190
+ private probeFailures;
191
+ private probeTimerHandle;
192
+ private disposed;
193
+ private readonly unsubscribeFromLimit;
124
194
  private readonly successCounter;
125
195
  private readonly droppedCounter;
126
196
  private readonly ignoredCounter;
@@ -131,12 +201,67 @@ export declare class Limiter<Context = void> {
131
201
  private readonly acquireTimeOnSuccessDistribution;
132
202
  private readonly acquireTimeOnUnavailableDistribution;
133
203
  static makeDefaultLimit(): AdaptiveLimit;
134
- constructor(options?: LimiterOptions<Context>);
204
+ constructor(options?: LimiterOptions<Context, TimerHandle>);
135
205
  acquire(options?: AcquireOptions<Context>): AcquireResult;
136
206
  private tryAcquireCore;
137
207
  private createAllotment;
208
+ private safeReadClockWithFallback;
209
+ /**
210
+ * Builds a per-acquire allotment for a bypassed request. Mirrors the
211
+ * one-shot `releaseStarted` semantics of {@link createAllotment} so that
212
+ * bypassed and non-bypassed allotments behave consistently when callers
213
+ * accidentally invoke a release method more than once.
214
+ *
215
+ * Bypassed acquires deliberately do not affect `_inflight` or the limit
216
+ * algorithm — `addSample` is never called — but they still emit the
217
+ * standard CALL_NAME counter, tagged with `operationNameFor` so per-
218
+ * operation dashboards include bypassed traffic.
219
+ */
220
+ private createBypassedAllotment;
138
221
  getLimit(): number;
139
222
  getInflight(): number;
223
+ /**
224
+ * Cancels any pending recovery-probe timer and unsubscribes from the
225
+ * underlying limit algorithm. Useful for clean shutdown in long-lived
226
+ * processes, especially when the underlying `AdaptiveLimit` outlives
227
+ * (or is shared across) limiters. Safe to call multiple times.
228
+ *
229
+ * Also exposed via `Symbol.dispose`, so a `Limiter` can be used with the
230
+ * `using` declaration:
231
+ * ```ts
232
+ * using limiter = new Limiter({ ... });
233
+ * ```
234
+ */
235
+ dispose(): void;
236
+ [Symbol.dispose](): void;
237
+ /**
238
+ * True when the limit has collapsed to 0 and there are no inflight
239
+ * requests left to produce samples. In this state, no future
240
+ * `addSample` call will ever arrive on its own, so the limiter has no
241
+ * way to climb back without an external nudge — i.e. a recovery probe.
242
+ */
243
+ private isStarvedForSamples;
244
+ private shouldArmRecoveryProbe;
245
+ /**
246
+ * Schedules a recovery probe if (and only if) the limiter is starved for
247
+ * samples (see {@link isStarvedForSamples}), the strategy supports
248
+ * probing, and no probe is already armed.
249
+ *
250
+ * Never throws. Errors from the limit algorithm's
251
+ * {@link AdaptiveLimit.probeFromZeroInterval}, the timer, or
252
+ * {@link AdaptiveLimit.applyProbeFromZero} are swallowed so callers in
253
+ * the release path / subscribe callback / constructor don't have to
254
+ * wrap each invocation. A failed probe arming simply means recovery
255
+ * may take longer; the next limit transition to 0 will re-attempt
256
+ * through the normal hooks.
257
+ */
258
+ private maybeArmRecoveryProbe;
259
+ /**
260
+ * Cancels the currently-armed recovery probe, if any. Idempotent and
261
+ * never throws (errors from the timer's `clearTimeout` are swallowed so
262
+ * callers don't have to wrap each invocation).
263
+ */
264
+ private cancelProbe;
140
265
  }
141
266
  export type RunCallbackArgs<ContextT> = {
142
267
  context: ContextT | undefined;
@@ -161,4 +286,5 @@ export interface LimitedFunction<ContextT> {
161
286
  * - Callback receives `{ context, signal }` from acquire options.
162
287
  */
163
288
  export declare function withLimiter<ContextT>(limiter: Limiter<ContextT>): LimitedFunction<ContextT>;
289
+ export {};
164
290
  //# sourceMappingURL=Limiter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Limiter.d.ts","sourceRoot":"","sources":["../src/Limiter.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"Limiter.d.ts","sourceRoot":"","sources":["../src/Limiter.ts"],"names":[],"mappings":"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"}
package/dist/Limiter.js CHANGED
@@ -21,7 +21,38 @@ export class Limiter {
21
21
  rejectionStrategy;
22
22
  bypassResolver;
23
23
  operationNameFor;
24
- acquireBypassedAllotment;
24
+ recoveryProbeMaxMs;
25
+ recoveryProbeJitter;
26
+ timer;
27
+ /**
28
+ * Number of consecutive recovery-probe *fires* since the last confirming
29
+ * success sample. Read by {@link maybeArmRecoveryProbe} to compute the
30
+ * exponentially-growing wait between probes:
31
+ *
32
+ * `interval(n) = limitAlgorithm.probeFromZeroInterval(n)`
33
+ *
34
+ * Incremented inside the probe-timer callback (right after
35
+ * `applyProbeFromZero` runs), and reset only on a successful (non-drop,
36
+ * non-ignored) sample in `releaseAndRecordSuccess`.
37
+ *
38
+ * Note: this is "consecutive probe fires without a confirming success",
39
+ * **not** "consecutive failed probes". A probe firing successfully raises
40
+ * the limit to 1, but until traffic actually flows through and produces
41
+ * a non-drop sample, we have no evidence the system is healthy. Resetting
42
+ * on the limit-rise alone would cause the failure ladder to collapse to
43
+ * the base interval forever in a flapping-downstream scenario (probe →
44
+ * raise → drop → 0 → probe → ...), defeating the purpose of the
45
+ * exponential backoff.
46
+ *
47
+ * The downside is a benign quirk: after a probe successfully raises the
48
+ * limit, if no traffic arrives for a long time and then the limit later
49
+ * drops to 0 again, the next probe waits at `interval(N+1)` instead of
50
+ * `interval(0)`. This errs on the side of caution and is acceptable.
51
+ */
52
+ probeFailures = 0;
53
+ probeTimerHandle;
54
+ disposed = false;
55
+ unsubscribeFromLimit;
25
56
  successCounter;
26
57
  droppedCounter;
27
58
  ignoredCounter;
@@ -43,12 +74,57 @@ export class Limiter {
43
74
  this.acquireStrategy =
44
75
  options.acquireStrategy ?? new SemaphoreStrategy(this._limit);
45
76
  this.rejectionStrategy = options.allotmentUnavailableStrategy;
46
- this.limitAlgorithm.subscribe((newLimit) => {
77
+ this.recoveryProbeMaxMs = options.recoveryProbe?.maxMs ?? 30_000;
78
+ this.recoveryProbeJitter = options.recoveryProbe?.jitter ?? 0.2;
79
+ if (!Number.isFinite(this.recoveryProbeMaxMs) ||
80
+ this.recoveryProbeMaxMs <= 0) {
81
+ // Reject NaN / ±Infinity along with non-positive values so a misconfigured
82
+ // probe interval can't silently degrade into a near-busy-loop (Node coerces
83
+ // NaN to ~1ms in `setTimeout(fn, NaN)`) or a non-finite wait.
84
+ throw new RangeError("recoveryProbe.maxMs must be a finite number > 0");
85
+ }
86
+ if (!Number.isFinite(this.recoveryProbeJitter) ||
87
+ this.recoveryProbeJitter < 0 ||
88
+ this.recoveryProbeJitter >= 0.5) {
89
+ throw new RangeError("recoveryProbe.jitter must be a finite number in [0, 0.5)");
90
+ }
91
+ this.timer = options.timer ?? {
92
+ setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
93
+ clearTimeout: (handle) => globalThis.clearTimeout(handle),
94
+ };
95
+ this.unsubscribeFromLimit = this.limitAlgorithm.subscribe((newLimit) => {
47
96
  const oldLimit = this._limit;
48
- this._limit = newLimit;
49
- this.limitGauge.record(newLimit);
97
+ // Notify the acquire strategy FIRST. If it throws atomically (i.e.
98
+ // refuses without mutating its own state), we want `_limit`, the
99
+ // gauge, the rejection strategy, and the recovery probe to all stay
100
+ // pinned to the old value — there's no partial-commit to clean up.
101
+ //
102
+ // If the strategy throws after partially mutating its state, the
103
+ // limiter and strategy are inconsistent either way; we still don't
104
+ // commit `_limit` here, since we'd just be guessing at the strategy's
105
+ // post-throw state. The error propagates to whatever drove the limit
106
+ // change (caller-visible from `setLimit`, swallowed by the release-
107
+ // path `try { addSample }` wrapper from `releaseAndRecord*`).
50
108
  this.acquireStrategy.onLimitChanged?.(oldLimit, newLimit);
51
- void this.rejectionStrategy?.onLimitChanged?.(oldLimit, newLimit);
109
+ this._limit = newLimit;
110
+ try {
111
+ this.limitGauge.record(newLimit);
112
+ }
113
+ catch {
114
+ // Best-effort metric recording; a misbehaving registry must not
115
+ // prevent recovery probe arming below.
116
+ }
117
+ // `onLimitChanged` may return a rejecting Promise; using `void` would
118
+ // surface it as an unhandled rejection (process-terminating in modern
119
+ // Node by default). Wrap in `fireAndForget` so any rejection is
120
+ // swallowed consistently with the release-path try/catch idiom.
121
+ void fireAndForget(() => this.rejectionStrategy?.onLimitChanged?.(oldLimit, newLimit));
122
+ if (newLimit === 0) {
123
+ this.maybeArmRecoveryProbe();
124
+ }
125
+ else {
126
+ this.cancelProbe();
127
+ }
52
128
  });
53
129
  const registry = options.metricRegistry ?? NoopMetricRegistry;
54
130
  const limiterName = options.name ?? `unnamed-${++idCounter}`;
@@ -72,27 +148,25 @@ export class Limiter {
72
148
  this.acquireBypassedCounter = registry.counter(MetricIds.ACQUIRE_ATTEMPT_NAME, { id: limiterName, status: "bypassed" });
73
149
  this.acquireTimeOnSuccessDistribution = registry.distribution(MetricIds.ACQUIRE_TIME_NAME, { id: limiterName, status: "success" });
74
150
  this.acquireTimeOnUnavailableDistribution = registry.distribution(MetricIds.ACQUIRE_TIME_NAME, { id: limiterName, status: "unavailable" });
75
- this.acquireBypassedAllotment = {
76
- releaseAndRecordSuccess: async () => {
77
- this.successCounter.add(1);
78
- },
79
- releaseAndIgnore: async () => {
80
- this.ignoredCounter.add(1);
81
- },
82
- releaseAndRecordDropped: async () => {
83
- this.droppedCounter.add(1);
84
- },
85
- };
86
151
  // Emit metric for initial limit.
87
152
  this.limitGauge.record(this._limit);
153
+ // If the underlying strategy starts at 0 (e.g.
154
+ // `new AIMDLimit({ initialLimit: 0, minLimit: 0 })` or a deserialized
155
+ // snapshot), the limiter is born starved-for-samples. The subscription
156
+ // and release-path hooks only fire on transitions, so without this we'd
157
+ // be stuck at 0 forever — no acquires can land while limit is 0, and no
158
+ // samples can arrive without acquires.
159
+ this.maybeArmRecoveryProbe();
88
160
  }
89
161
  async acquire(options) {
90
162
  if (options?.signal?.aborted)
91
163
  return undefined;
92
164
  const ctx = (options?.context ?? undefined);
93
165
  if (this.bypassResolver?.(ctx)) {
94
- this.acquireBypassedCounter.add(1);
95
- return this.acquireBypassedAllotment;
166
+ void fireAndForget(() => {
167
+ this.acquireBypassedCounter.add(1);
168
+ });
169
+ return this.createBypassedAllotment(ctx);
96
170
  }
97
171
  const acquireStart = this.clock();
98
172
  const state = {
@@ -117,17 +191,35 @@ export class Limiter {
117
191
  }
118
192
  return this.tryAcquireCore(retryCtx);
119
193
  }, options?.signal);
120
- const distribution = result
121
- ? this.acquireTimeOnSuccessDistribution
122
- : this.acquireTimeOnUnavailableDistribution;
123
- distribution.addSample(this.clock() - acquireStart);
194
+ try {
195
+ const distribution = result
196
+ ? this.acquireTimeOnSuccessDistribution
197
+ : this.acquireTimeOnUnavailableDistribution;
198
+ distribution.addSample(this.clock() - acquireStart);
199
+ }
200
+ catch {
201
+ // Best-effort metric update: do not fail the acquire path after
202
+ // capacity has already been acquired via the rejection strategy.
203
+ }
204
+ if (result && options?.signal?.aborted) {
205
+ // The rejection strategy may have acquired via retry just before the
206
+ // signal aborted. Return the caller's capacity to the limiter.
207
+ await result.releaseAndIgnore();
208
+ return undefined;
209
+ }
124
210
  return result;
125
211
  }
126
- this.acquireSucceededCounter.add(1);
127
- const allotment = this.createAllotment(ctx);
128
- // Record the acquire time as a success, since we did actually succeed even
129
- // if we abort below.
130
- this.acquireTimeOnSuccessDistribution.addSample(this.clock() - acquireStart);
212
+ const allotment = await this.createAllotment(ctx);
213
+ try {
214
+ this.acquireSucceededCounter.add(1);
215
+ // Record the acquire time as a success, since we did actually succeed even
216
+ // if we abort below.
217
+ this.acquireTimeOnSuccessDistribution.addSample(this.clock() - acquireStart);
218
+ }
219
+ catch {
220
+ // Best-effort metric update: do not fail the acquire path after
221
+ // capacity has already been reserved and an allotment created.
222
+ }
131
223
  if (options?.signal?.aborted) {
132
224
  // here, we did acquire, so we need to try to cleanup.
133
225
  await allotment.releaseAndIgnore();
@@ -144,13 +236,37 @@ export class Limiter {
144
236
  this.acquireFailedCounter.add(1);
145
237
  return undefined;
146
238
  }
147
- this.acquireSucceededCounter.add(1);
148
- return this.createAllotment(ctx);
239
+ // increment counter only after we've successfully acquired the allotment
240
+ const allotment = await this.createAllotment(ctx);
241
+ try {
242
+ this.acquireSucceededCounter.add(1);
243
+ }
244
+ catch {
245
+ // Best-effort metric update: do not fail the acquire path after
246
+ // capacity has already been reserved and an allotment created.
247
+ }
248
+ return allotment;
149
249
  }
150
- createAllotment(ctx) {
151
- const startTime = this.clock();
250
+ async createAllotment(ctx) {
251
+ // Capture quantities derived from user-supplied callbacks BEFORE
252
+ // touching `_inflight`. If `clock()` or `operationNameFor()` throws,
253
+ // the strategy has already reserved a slot in `tryAcquireAllotment`
254
+ // and we must roll that reservation back — otherwise the permit is
255
+ // permanently leaked and concurrent admission silently degrades.
256
+ let startTime;
257
+ let operationName;
258
+ try {
259
+ startTime = this.clock();
260
+ operationName = this.operationNameFor?.(ctx);
261
+ }
262
+ catch (err) {
263
+ // Best-effort release of the strategy reservation. We're about to
264
+ // throw to the caller of `acquire()`, who will not get an allotment
265
+ // to release manually.
266
+ await fireAndForget(() => this.acquireStrategy.onAllotmentReleased(ctx));
267
+ throw err;
268
+ }
152
269
  const currentInflight = ++this._inflight;
153
- const operationName = this.operationNameFor?.(ctx);
154
270
  const incrementTags = operationName
155
271
  ? { [MetricIds.OPERATION_NAME_TAG]: operationName }
156
272
  : {};
@@ -164,10 +280,24 @@ export class Limiter {
164
280
  if (releaseStarted)
165
281
  return;
166
282
  releaseStarted = true;
167
- const endTime = this.clock();
283
+ // Every interaction with user-supplied code (metrics, clock, the
284
+ // limit algorithm, the strategies) is wrapped in try/catch so that
285
+ // a single misbehaving collaborator can't leave the strategy with
286
+ // a leaked permit or stall queued waiters in a blocking rejection
287
+ // strategy. This release method must never throw or reject (per
288
+ // `LimitAllotment` contract).
289
+ const endTime = this.safeReadClockWithFallback(startTime);
168
290
  const rtt = endTime - startTime;
169
291
  this._inflight--;
170
- this.successCounter.add(1, incrementTags);
292
+ void fireAndForget(() => {
293
+ this.successCounter.add(1, incrementTags);
294
+ });
295
+ // A successful sample is our signal that requests are flowing
296
+ // again: clear any pending probe and reset the failure ladder.
297
+ // (See the docstring on `probeFailures` for why neither is reset
298
+ // on the limit-rise from `applyProbeFromZero` itself.)
299
+ this.probeFailures = 0;
300
+ this.cancelProbe();
171
301
  // If one onAllotmentReleased call fails, hard to know what to do here.
172
302
  // We're in some kind of inconsistent state, but we probably have to
173
303
  // soldier on.
@@ -183,13 +313,23 @@ export class Limiter {
183
313
  await this.rejectionStrategy?.onAllotmentReleased();
184
314
  }
185
315
  catch { }
316
+ // Needed because some limit implementations could keep the limit at 0
317
+ // even after a successful sample (e.g., a GradientLimit where the
318
+ // successful sample's RTT was slow), so we need to re-arm the probe in
319
+ // that case (and won't get a limit subscription callback).
320
+ this.maybeArmRecoveryProbe();
186
321
  },
187
322
  releaseAndIgnore: async () => {
188
323
  if (releaseStarted)
189
324
  return;
190
325
  releaseStarted = true;
191
326
  this._inflight--;
192
- this.ignoredCounter.add(1, incrementTags);
327
+ void fireAndForget(() => {
328
+ this.ignoredCounter.add(1, incrementTags);
329
+ });
330
+ // Decrementing inflight above, possibly to 0, means we might need to
331
+ // arm the probe now.
332
+ this.maybeArmRecoveryProbe();
193
333
  // If one onAllotmentReleased call fails, hard to know what to do here.
194
334
  // We're in some kind of inconsistent state, but we probably have to
195
335
  // soldier on.
@@ -206,10 +346,14 @@ export class Limiter {
206
346
  if (releaseStarted)
207
347
  return;
208
348
  releaseStarted = true;
209
- const endTime = this.clock();
349
+ const endTime = this.safeReadClockWithFallback(startTime);
210
350
  const rtt = endTime - startTime;
211
351
  this._inflight--;
212
- this.droppedCounter.add(1, incrementTags);
352
+ void fireAndForget(() => {
353
+ this.droppedCounter.add(1, incrementTags);
354
+ });
355
+ // Needed after the inflight decrement like in releaseAndIgnore.
356
+ this.maybeArmRecoveryProbe();
213
357
  // If one onAllotmentReleased call fails, hard to know what to do here.
214
358
  // We're in some kind of inconsistent state, but we probably have to
215
359
  // soldier on.
@@ -228,12 +372,191 @@ export class Limiter {
228
372
  },
229
373
  };
230
374
  }
375
+ safeReadClockWithFallback(fallback) {
376
+ try {
377
+ return this.clock();
378
+ }
379
+ catch {
380
+ return fallback;
381
+ }
382
+ }
383
+ /**
384
+ * Builds a per-acquire allotment for a bypassed request. Mirrors the
385
+ * one-shot `releaseStarted` semantics of {@link createAllotment} so that
386
+ * bypassed and non-bypassed allotments behave consistently when callers
387
+ * accidentally invoke a release method more than once.
388
+ *
389
+ * Bypassed acquires deliberately do not affect `_inflight` or the limit
390
+ * algorithm — `addSample` is never called — but they still emit the
391
+ * standard CALL_NAME counter, tagged with `operationNameFor` so per-
392
+ * operation dashboards include bypassed traffic.
393
+ */
394
+ createBypassedAllotment(ctx) {
395
+ let operationName;
396
+ try {
397
+ operationName = this.operationNameFor?.(ctx);
398
+ }
399
+ catch {
400
+ // A misbehaving `operationNameFor` shouldn't break the bypass path;
401
+ // just emit untagged metrics in that case.
402
+ }
403
+ const incrementTags = operationName
404
+ ? { [MetricIds.OPERATION_NAME_TAG]: operationName }
405
+ : {};
406
+ let releaseStarted = false;
407
+ return {
408
+ releaseAndRecordSuccess: async () => {
409
+ if (releaseStarted)
410
+ return;
411
+ releaseStarted = true;
412
+ void fireAndForget(() => {
413
+ this.successCounter.add(1, incrementTags);
414
+ });
415
+ },
416
+ releaseAndIgnore: async () => {
417
+ if (releaseStarted)
418
+ return;
419
+ releaseStarted = true;
420
+ void fireAndForget(() => {
421
+ this.ignoredCounter.add(1, incrementTags);
422
+ });
423
+ },
424
+ releaseAndRecordDropped: async () => {
425
+ if (releaseStarted)
426
+ return;
427
+ releaseStarted = true;
428
+ void fireAndForget(() => {
429
+ this.droppedCounter.add(1, incrementTags);
430
+ });
431
+ },
432
+ };
433
+ }
231
434
  getLimit() {
232
435
  return this._limit;
233
436
  }
234
437
  getInflight() {
235
438
  return this._inflight;
236
439
  }
440
+ /**
441
+ * Cancels any pending recovery-probe timer and unsubscribes from the
442
+ * underlying limit algorithm. Useful for clean shutdown in long-lived
443
+ * processes, especially when the underlying `AdaptiveLimit` outlives
444
+ * (or is shared across) limiters. Safe to call multiple times.
445
+ *
446
+ * Also exposed via `Symbol.dispose`, so a `Limiter` can be used with the
447
+ * `using` declaration:
448
+ * ```ts
449
+ * using limiter = new Limiter({ ... });
450
+ * ```
451
+ */
452
+ dispose() {
453
+ if (this.disposed)
454
+ return;
455
+ this.disposed = true;
456
+ this.cancelProbe();
457
+ this.unsubscribeFromLimit();
458
+ }
459
+ [Symbol.dispose]() {
460
+ this.dispose();
461
+ }
462
+ /**
463
+ * True when the limit has collapsed to 0 and there are no inflight
464
+ * requests left to produce samples. In this state, no future
465
+ * `addSample` call will ever arrive on its own, so the limiter has no
466
+ * way to climb back without an external nudge — i.e. a recovery probe.
467
+ */
468
+ isStarvedForSamples() {
469
+ return this._limit === 0 && this._inflight === 0;
470
+ }
471
+ shouldArmRecoveryProbe() {
472
+ return (!this.disposed &&
473
+ this.probeTimerHandle === undefined &&
474
+ this.isStarvedForSamples() &&
475
+ typeof this.limitAlgorithm.probeFromZeroInterval === "function" &&
476
+ typeof this.limitAlgorithm.applyProbeFromZero === "function");
477
+ }
478
+ /**
479
+ * Schedules a recovery probe if (and only if) the limiter is starved for
480
+ * samples (see {@link isStarvedForSamples}), the strategy supports
481
+ * probing, and no probe is already armed.
482
+ *
483
+ * Never throws. Errors from the limit algorithm's
484
+ * {@link AdaptiveLimit.probeFromZeroInterval}, the timer, or
485
+ * {@link AdaptiveLimit.applyProbeFromZero} are swallowed so callers in
486
+ * the release path / subscribe callback / constructor don't have to
487
+ * wrap each invocation. A failed probe arming simply means recovery
488
+ * may take longer; the next limit transition to 0 will re-attempt
489
+ * through the normal hooks.
490
+ */
491
+ maybeArmRecoveryProbe() {
492
+ try {
493
+ if (!this.shouldArmRecoveryProbe()) {
494
+ return;
495
+ }
496
+ // not-null assertion is safe because of the shouldArmRecoveryProbe()
497
+ // guard, but hard to prove to TS.
498
+ const raw = this.limitAlgorithm.probeFromZeroInterval(this.probeFailures);
499
+ // Cap before the finite check so that an exponential backoff that
500
+ // overflows to Infinity is clamped to recoveryProbeMaxMs instead of
501
+ // silently disabling recovery probing.
502
+ const capped = Math.min(this.recoveryProbeMaxMs, raw);
503
+ if (!Number.isFinite(capped) || capped <= 0)
504
+ return;
505
+ const jitterFactor = 1 + (Math.random() * 2 - 1) * this.recoveryProbeJitter;
506
+ const ms = capped * jitterFactor;
507
+ this.probeTimerHandle = this.timer.setTimeout(() => {
508
+ this.probeTimerHandle = undefined;
509
+ if (this.disposed)
510
+ return;
511
+ if (this._limit !== 0) {
512
+ // For the strategies in this package the limit should be unable to
513
+ // climb back from 0 without a sample, and samples can't arrive while
514
+ // we're starved, so this branch *seems* unreachable. It isn't,
515
+ // though: a custom `AdaptiveLimit` (or `WindowedLimit` wrapping one)
516
+ // could mutate its limit from a timer of its own or via an external
517
+ // `setLimit`-style hook; tests / harnesses can call `addSample`
518
+ // directly; and a maintainer could add a non-completing path that
519
+ // injects samples. None of those should crash the process, so we
520
+ // simply abandon the probe — the next limit transition to 0 will
521
+ // re-arm it through the normal hooks.
522
+ return;
523
+ }
524
+ try {
525
+ // not-null assertion is safe because of the shouldArmRecoveryProbe()
526
+ // guard, but hard to prove to TS.
527
+ this.limitAlgorithm.applyProbeFromZero();
528
+ // Increment *after* the apply (and the synchronous subscription it
529
+ // triggers): see the docstring on `probeFailures`. Notably we do NOT
530
+ // reset on the limit-rise caused by `applyProbeFromZero`, because
531
+ // the rise alone doesn't confirm any traffic actually flowed
532
+ // through. A confirming success sample in `releaseAndRecordSuccess`
533
+ // is what eventually clears this.
534
+ this.probeFailures++;
535
+ }
536
+ catch {
537
+ // Ignore — see the method docstring. Future samples will re-trigger
538
+ // probe arming through the normal hooks.
539
+ }
540
+ }, ms);
541
+ }
542
+ catch {
543
+ // Ignore — see the method docstring.
544
+ }
545
+ }
546
+ /**
547
+ * Cancels the currently-armed recovery probe, if any. Idempotent and
548
+ * never throws (errors from the timer's `clearTimeout` are swallowed so
549
+ * callers don't have to wrap each invocation).
550
+ */
551
+ cancelProbe() {
552
+ if (this.probeTimerHandle === undefined)
553
+ return;
554
+ const handle = this.probeTimerHandle;
555
+ this.probeTimerHandle = undefined;
556
+ void fireAndForget(() => {
557
+ this.timer.clearTimeout(handle);
558
+ });
559
+ }
237
560
  }
238
561
  /**
239
562
  * Creates a helper that runs callbacks under acquired limiter allotments.
@@ -262,7 +585,7 @@ export function withLimiter(limiter) {
262
585
  return QuotaNotAvailable;
263
586
  }
264
587
  const [result] = await Promise.allSettled([
265
- fn({ context: options?.context, signal: options?.signal }),
588
+ Promise.resolve().then(() => fn({ context: options?.context, signal: options?.signal })),
266
589
  ]);
267
590
  if (result.status === "rejected") {
268
591
  if (isAdaptiveTimeoutError(result.reason)) {
@@ -293,3 +616,11 @@ export function withLimiter(limiter) {
293
616
  }
294
617
  return limited;
295
618
  }
619
+ async function fireAndForget(fn) {
620
+ try {
621
+ await fn();
622
+ }
623
+ catch {
624
+ // Ignore errors
625
+ }
626
+ }
@@ -27,6 +27,18 @@ export interface AIMDLimitOptions {
27
27
  * Must be in [0, 0.05]. Default: 0.02.
28
28
  */
29
29
  backoffJitter?: number;
30
+ /**
31
+ * Configuration for the limiter's recovery probe when the limit reaches 0.
32
+ * See {@link AdaptiveLimit.probeFromZeroInterval}.
33
+ */
34
+ recoveryProbe?: {
35
+ /**
36
+ * Base interval in milliseconds between probes. The probe interval grows
37
+ * as `baseMs * 2^failedProbes`, then is jittered and capped by the
38
+ * limiter. Default: the configured `timeout`.
39
+ */
40
+ baseMs?: number;
41
+ };
30
42
  }
31
43
  export declare class AIMDLimit implements AdaptiveLimit {
32
44
  private _limit;
@@ -36,7 +48,10 @@ export declare class AIMDLimit implements AdaptiveLimit {
36
48
  private readonly minLimit;
37
49
  private readonly maxLimit;
38
50
  private readonly backoffJitter;
51
+ private readonly recoveryProbeBaseMs;
39
52
  constructor(options?: AIMDLimitOptions);
53
+ probeFromZeroInterval(failedProbes: number): number;
54
+ applyProbeFromZero(): void;
40
55
  addSample(_startTime: number, rtt: number, inflight: number, didDrop: boolean, _operationName?: string): void;
41
56
  get currentLimit(): number;
42
57
  private applyNewLimit;
@@ -1 +1 @@
1
- {"version":3,"file":"AIMDLimit.d.ts","sourceRoot":"","sources":["../../src/limit/AIMDLimit.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,qBAAa,SAAU,YAAW,aAAa;IAC7C,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAqB;IAEpD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;gBAE3B,OAAO,GAAE,gBAAqB;IA0B1C,SAAS,CACP,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,OAAO,EAChB,cAAc,CAAC,EAAE,MAAM,GACtB,IAAI;IAuBP,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,OAAO,CAAC,aAAa;IAOrB,SAAS,CACP,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,EACpC,OAAO,GAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAO,GACrC,MAAM,IAAI;IAIb,QAAQ,IAAI,MAAM;CAGnB"}
1
+ {"version":3,"file":"AIMDLimit.d.ts","sourceRoot":"","sources":["../../src/limit/AIMDLimit.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;;OAGG;IACH,aAAa,CAAC,EAAE;QACd;;;;WAIG;QACH,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,CAAC;CACH;AAED,qBAAa,SAAU,YAAW,aAAa;IAC7C,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAqB;IAEpD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAS;gBAEjC,OAAO,GAAE,gBAAqB;IA8B1C,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM;IAInD,kBAAkB,IAAI,IAAI;IAI1B,SAAS,CACP,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,OAAO,EAChB,cAAc,CAAC,EAAE,MAAM,GACtB,IAAI;IAuBP,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,OAAO,CAAC,aAAa;IAOrB,SAAS,CACP,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,EACpC,OAAO,GAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAO,GACrC,MAAM,IAAI;IAIb,QAAQ,IAAI,MAAM;CAGnB"}
@@ -7,6 +7,7 @@ export class AIMDLimit {
7
7
  minLimit;
8
8
  maxLimit;
9
9
  backoffJitter;
10
+ recoveryProbeBaseMs;
10
11
  constructor(options = {}) {
11
12
  const initialLimit = options.initialLimit ?? 20;
12
13
  this._limit = initialLimit;
@@ -15,18 +16,28 @@ export class AIMDLimit {
15
16
  this.minLimit = options.minLimit ?? 20;
16
17
  this.maxLimit = options.maxLimit ?? 200;
17
18
  this.backoffJitter = options.backoffJitter ?? 0.02;
19
+ this.recoveryProbeBaseMs = options.recoveryProbe?.baseMs ?? this.timeout;
18
20
  if (this.backoffRatio >= 1.0 || this.backoffRatio < 0.5) {
19
- throw new Error("Backoff ratio must be in the range [0.5, 1.0)");
21
+ throw new RangeError("Backoff ratio must be in the range [0.5, 1.0)");
20
22
  }
21
23
  if (this.timeout <= 0) {
22
- throw new Error("Timeout must be positive");
24
+ throw new RangeError("Timeout must be positive");
23
25
  }
24
26
  if (this.backoffJitter < 0 || this.backoffJitter > 0.05) {
25
- throw new Error("backoffJitter must be in the range [0, 0.05]");
27
+ throw new RangeError("backoffJitter must be in the range [0, 0.05]");
26
28
  }
27
29
  if (this.backoffRatio + this.backoffJitter >= 1.0) {
28
- throw new Error("backoffRatio + backoffJitter must be < 1.0 to guarantee the limit decreases on drop");
30
+ throw new RangeError("backoffRatio + backoffJitter must be < 1.0 to guarantee the limit decreases on drop");
29
31
  }
32
+ if (!(this.recoveryProbeBaseMs > 0)) {
33
+ throw new RangeError("recoveryProbe.baseMs must be > 0");
34
+ }
35
+ }
36
+ probeFromZeroInterval(failedProbes) {
37
+ return this.recoveryProbeBaseMs * Math.pow(2, failedProbes);
38
+ }
39
+ applyProbeFromZero() {
40
+ this.applyNewLimit(1);
30
41
  }
31
42
  addSample(_startTime, rtt, inflight, didDrop, _operationName) {
32
43
  let currentLimit = this._limit;
@@ -80,6 +80,19 @@ export interface Gradient2LimitOptions {
80
80
  * Default: 600
81
81
  */
82
82
  longWindow?: number;
83
+ /**
84
+ * Configuration for the limiter's recovery probe when the limit reaches 0.
85
+ * See {@link AdaptiveLimit.probeFromZeroInterval}.
86
+ */
87
+ recoveryProbe?: {
88
+ /**
89
+ * Fallback base interval in milliseconds between probes, used when the
90
+ * algorithm has no usable RTT estimate (e.g. before any sample has been
91
+ * recorded). When an RTT estimate is available the probe interval is
92
+ * derived from `longRtt * 5`. Default: 1000.
93
+ */
94
+ baseMs?: number;
95
+ };
83
96
  metricRegistry?: MetricRegistry;
84
97
  }
85
98
  export declare class GradientLimit implements AdaptiveLimit {
@@ -104,11 +117,14 @@ export declare class GradientLimit implements AdaptiveLimit {
104
117
  private readonly queueSize;
105
118
  private readonly smoothing;
106
119
  private readonly tolerance;
120
+ private readonly recoveryProbeBaseMs;
107
121
  private readonly longRttSampleListener;
108
122
  private readonly shortRttSampleListener;
109
123
  private readonly queueSizeSampleListener;
110
124
  constructor(options?: Gradient2LimitOptions);
111
125
  addSample(_startTime: number, rtt: number, inflight: number, _didDrop: boolean, _operationName?: string): void;
126
+ probeFromZeroInterval(failedProbes: number): number;
127
+ applyProbeFromZero(): void;
112
128
  get currentLimit(): number;
113
129
  private applyNewLimit;
114
130
  subscribe(consumer: (newLimit: number) => void, options?: {
@@ -1 +1 @@
1
- {"version":3,"file":"GradientLimit.d.ts","sourceRoot":"","sources":["../../src/limit/GradientLimit.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAsB,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAI/E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,MAAM,WAAW,qBAAqB;IACpC,qDAAqD;IACrD,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,CAAC,CAAC;IAEvD;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC;AAED,qBAAa,aAAc,YAAW,aAAa;IACjD,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAqB;IAEpD,yDAAyD;IACzD,OAAO,CAAC,cAAc,CAAS;IAE/B;;;OAGG;IACH,OAAO,CAAC,OAAO,CAAK;IAEpB;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqB;IAE7C,8DAA8D;IAC9D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAElC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAkC;IAC5D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAqB;IAC3D,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAqB;IAC5D,OAAO,CAAC,QAAQ,CAAC,uBAAuB,CAAqB;gBAEjD,OAAO,GAAE,qBAA0B;IA4B/C,SAAS,CACP,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,OAAO,EACjB,cAAc,CAAC,EAAE,MAAM,GACtB,IAAI;IASP,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,OAAO,CAAC,aAAa;IAOrB,SAAS,CACP,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,EACpC,OAAO,GAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAO,GACrC,MAAM,IAAI;IAIb,OAAO,CAAC,yBAAyB;IA0CjC,UAAU,IAAI,MAAM;IAIpB,YAAY,IAAI,MAAM;IAItB,QAAQ,IAAI,MAAM;CAGnB"}
1
+ {"version":3,"file":"GradientLimit.d.ts","sourceRoot":"","sources":["../../src/limit/GradientLimit.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAsB,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAI/E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,MAAM,WAAW,qBAAqB;IACpC,qDAAqD;IACrD,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,CAAC,CAAC;IAEvD;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,aAAa,CAAC,EAAE;QACd;;;;;WAKG;QACH,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,CAAC;IAEF,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC;AAED,qBAAa,aAAc,YAAW,aAAa;IACjD,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAqB;IAEpD,yDAAyD;IACzD,OAAO,CAAC,cAAc,CAAS;IAE/B;;;OAGG;IACH,OAAO,CAAC,OAAO,CAAK;IAEpB;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqB;IAE7C,8DAA8D;IAC9D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAElC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAkC;IAC5D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAS;IAE7C,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAqB;IAC3D,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAqB;IAC5D,OAAO,CAAC,QAAQ,CAAC,uBAAuB,CAAqB;gBAEjD,OAAO,GAAE,qBAA0B;IAgC/C,SAAS,CACP,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,OAAO,EACjB,cAAc,CAAC,EAAE,MAAM,GACtB,IAAI;IASP,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM;IAMnD,kBAAkB,IAAI,IAAI;IAK1B,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,OAAO,CAAC,aAAa;IAOrB,SAAS,CACP,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,EACpC,OAAO,GAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAO,GACrC,MAAM,IAAI;IAIb,OAAO,CAAC,yBAAyB;IA0CjC,UAAU,IAAI,MAAM;IAIpB,YAAY,IAAI,MAAM;IAItB,QAAQ,IAAI,MAAM;CAGnB"}
@@ -24,6 +24,7 @@ export class GradientLimit {
24
24
  queueSize;
25
25
  smoothing;
26
26
  tolerance;
27
+ recoveryProbeBaseMs;
27
28
  longRttSampleListener;
28
29
  shortRttSampleListener;
29
30
  queueSizeSampleListener;
@@ -36,8 +37,12 @@ export class GradientLimit {
36
37
  this.smoothing = options.smoothing ?? 0.2;
37
38
  this.tolerance = options.rttTolerance ?? 1.5;
38
39
  this.longRtt = new ExpMovingAverage(options.longWindow ?? 600, 10);
40
+ this.recoveryProbeBaseMs = options.recoveryProbe?.baseMs ?? 1000;
39
41
  if (options.rttTolerance !== undefined && options.rttTolerance < 1.0) {
40
- throw new Error("Tolerance must be >= 1.0");
42
+ throw new RangeError("Tolerance must be >= 1.0");
43
+ }
44
+ if (!(this.recoveryProbeBaseMs > 0)) {
45
+ throw new RangeError("recoveryProbe.baseMs must be > 0");
41
46
  }
42
47
  const qs = options.queueSize ?? 4;
43
48
  this.queueSize = typeof qs === "number" ? () => qs : qs;
@@ -52,6 +57,15 @@ export class GradientLimit {
52
57
  const newLimit = Math.floor(newLimitNoFloor);
53
58
  this.applyNewLimit(newLimit);
54
59
  }
60
+ probeFromZeroInterval(failedProbes) {
61
+ const longRtt = this.longRtt.currentValue;
62
+ const base = longRtt > 0 ? longRtt * 5 : this.recoveryProbeBaseMs;
63
+ return base * Math.pow(2, failedProbes);
64
+ }
65
+ applyProbeFromZero() {
66
+ this.estimatedLimit = 1;
67
+ this.applyNewLimit(1);
68
+ }
55
69
  get currentLimit() {
56
70
  return this._limit;
57
71
  }
@@ -41,6 +41,7 @@ export declare class GroupAwareLimit implements AdaptiveLimit {
41
41
  private readonly recentRttWindow;
42
42
  private readonly minGroupSamples;
43
43
  private readonly clock;
44
+ private readonly recoveryProbeBaseMs;
44
45
  private readonly registry;
45
46
  private readonly congestionSignalGauge;
46
47
  private readonly warmedGroupsCountGauge;
@@ -59,9 +60,24 @@ export declare class GroupAwareLimit implements AdaptiveLimit {
59
60
  minGroupSamples?: number;
60
61
  clock?: () => number;
61
62
  metricRegistry?: MetricRegistry;
63
+ /**
64
+ * Configuration for the limiter's recovery probe when the limit reaches
65
+ * 0. See {@link AdaptiveLimit.probeFromZeroInterval}.
66
+ */
67
+ recoveryProbe?: {
68
+ /**
69
+ * Fallback base interval in milliseconds between probes, used when no
70
+ * group is warmed up enough to provide a recent RTT estimate. When
71
+ * warmed groups are available, the probe interval is derived from
72
+ * their weighted-mean recent RTT × 5. Default: 1000.
73
+ */
74
+ baseMs?: number;
75
+ };
62
76
  });
63
77
  addSample(_startTime: number, rtt: number, inflight: number, didDrop: boolean, operationName?: string): void;
64
78
  get currentLimit(): number;
79
+ probeFromZeroInterval(failedProbes: number): number;
80
+ applyProbeFromZero(): void;
65
81
  subscribe(consumer: (newLimit: number) => void, options?: {
66
82
  signal?: AbortSignal;
67
83
  }): () => void;
@@ -1 +1 @@
1
- {"version":3,"file":"GroupAwareLimit.d.ts","sourceRoot":"","sources":["../../src/limit/GroupAwareLimit.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAS,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAKlE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AA6DzD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBAAa,eAAgB,YAAW,aAAa;IACnD,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA0C;IACzE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkD;IAEzE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA8C;IACvE,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAC1C,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAC1C,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAe;IAErC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiB;IAC1C,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAQ;IAC9C,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAQ;IAC/C,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAQ;gBAE/B,OAAO,CAAC,EAAE;QACpB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,MAAM,CAAC;QACvD,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;QACrB,cAAc,CAAC,EAAE,cAAc,CAAC;KACjC;IA0BD,SAAS,CACP,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,OAAO,EAChB,aAAa,CAAC,EAAE,MAAM,GACrB,IAAI;IAgDP,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,SAAS,CACP,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,EACpC,OAAO,GAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAO,GACrC,MAAM,IAAI;IAIb,OAAO,CAAC,uBAAuB;IA8C/B,OAAO,CAAC,KAAK;IAIb,OAAO,CAAC,aAAa;IAOrB,QAAQ,IAAI,MAAM;CAGnB;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,IAAI;QACZ,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,KAAK,MAAM,CAAC;KACpD;CACF"}
1
+ {"version":3,"file":"GroupAwareLimit.d.ts","sourceRoot":"","sources":["../../src/limit/GroupAwareLimit.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAS,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAKlE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AA6DzD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBAAa,eAAgB,YAAW,aAAa;IACnD,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA0C;IACzE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkD;IAEzE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA8C;IACvE,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAC1C,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAC1C,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAe;IACrC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAS;IAE7C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiB;IAC1C,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAQ;IAC9C,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAQ;IAC/C,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAQ;gBAE/B,OAAO,CAAC,EAAE;QACpB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,MAAM,CAAC;QACvD,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;QACrB,cAAc,CAAC,EAAE,cAAc,CAAC;QAChC;;;WAGG;QACH,aAAa,CAAC,EAAE;YACd;;;;;eAKG;YACH,MAAM,CAAC,EAAE,MAAM,CAAC;SACjB,CAAC;KACH;IA8BD,SAAS,CACP,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,OAAO,EAChB,aAAa,CAAC,EAAE,MAAM,GACrB,IAAI;IAgDP,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM;IAiCnD,kBAAkB,IAAI,IAAI;IAI1B,SAAS,CACP,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,EACpC,OAAO,GAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAO,GACrC,MAAM,IAAI;IAIb,OAAO,CAAC,uBAAuB;IA+C/B,OAAO,CAAC,KAAK;IAIb,OAAO,CAAC,aAAa;IAOrB,QAAQ,IAAI,MAAM;CAGnB;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,IAAI;QACZ,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,KAAK,MAAM,CAAC;KACpD;CACF"}
@@ -1,3 +1,4 @@
1
+ import { sumBy } from "es-toolkit";
1
2
  import lruPkg from "lru_map";
2
3
  const { LRUMap } = lruPkg;
3
4
  import { ListenerSet } from "../ListenerSet.js";
@@ -94,6 +95,7 @@ export class GroupAwareLimit {
94
95
  recentRttWindow;
95
96
  minGroupSamples;
96
97
  clock;
98
+ recoveryProbeBaseMs;
97
99
  registry;
98
100
  congestionSignalGauge;
99
101
  warmedGroupsCountGauge;
@@ -111,6 +113,10 @@ export class GroupAwareLimit {
111
113
  this.recentRttWindow = options?.recentRttWindow ?? 100;
112
114
  this.minGroupSamples = options?.minGroupSamples ?? 20;
113
115
  this.clock = options?.clock ?? (() => performance.now());
116
+ this.recoveryProbeBaseMs = options?.recoveryProbe?.baseMs ?? 1000;
117
+ if (!(this.recoveryProbeBaseMs > 0)) {
118
+ throw new RangeError("recoveryProbe.baseMs must be > 0");
119
+ }
114
120
  this.registry = options?.metricRegistry ?? NoopMetricRegistry;
115
121
  this.congestionSignalGauge = this.registry.gauge(MetricIds.CONGESTION_SIGNAL_NAME);
116
122
  this.warmedGroupsCountGauge = this.registry.gauge(MetricIds.WARMED_GROUPS_COUNT_NAME);
@@ -156,6 +162,29 @@ export class GroupAwareLimit {
156
162
  get currentLimit() {
157
163
  return this._limit;
158
164
  }
165
+ probeFromZeroInterval(failedProbes) {
166
+ const now = this.clock();
167
+ const entries = [
168
+ ...this.groups.entries(),
169
+ ];
170
+ const warmedGroupInfos = entries.flatMap(([, groupState]) => {
171
+ const activity = groupState.activityCount(now);
172
+ const recentRtt = groupState.recentRtt.currentValue;
173
+ const validRecentRtt = Number.isFinite(recentRtt) && recentRtt > 0;
174
+ return validRecentRtt && activity >= this.minGroupSamples
175
+ ? [{ recentRtt, weight: Math.sqrt(activity) }]
176
+ : [];
177
+ });
178
+ const totalWeight = sumBy(warmedGroupInfos, ({ weight }) => weight);
179
+ const weightedRatioSum = sumBy(warmedGroupInfos, ({ weight, recentRtt }) => weight * recentRtt);
180
+ const base = totalWeight > 0
181
+ ? (weightedRatioSum / totalWeight) * 5
182
+ : this.recoveryProbeBaseMs;
183
+ return base * Math.pow(2, failedProbes);
184
+ }
185
+ applyProbeFromZero() {
186
+ this.applyNewLimit(1);
187
+ }
159
188
  subscribe(consumer, options = {}) {
160
189
  return this.limitListeners.subscribe(consumer, options);
161
190
  }
@@ -184,7 +213,7 @@ export class GroupAwareLimit {
184
213
  const totalWeight = sum(warmedGroupInfos.map(({ weight }) => weight));
185
214
  if (totalWeight <= 0)
186
215
  return undefined;
187
- const weightedRatioSum = sum(warmedGroupInfos.map(({ weight, ratio }) => weight * ratio));
216
+ const weightedRatioSum = sumBy(warmedGroupInfos, ({ weight, ratio }) => weight * ratio);
188
217
  return {
189
218
  signal: weightedRatioSum / totalWeight,
190
219
  warmedGroupInfos,
@@ -24,5 +24,30 @@ export interface AdaptiveLimit {
24
24
  * used by group-aware limits to distinguish heterogeneous workloads.
25
25
  */
26
26
  addSample(startTime: number, rtt: number, inflight: number, didDrop: boolean, operationName?: string): void;
27
+ /**
28
+ * Returns the recommended interval, in milliseconds, between recovery
29
+ * probes when the limit has fallen to 0 and there are no inflight requests
30
+ * left to produce samples. The {@link Limiter} uses this to schedule a
31
+ * probe that bumps the limit back to 1 so a single request can flow and
32
+ * generate a fresh sample.
33
+ *
34
+ * @param failedProbes The number of consecutive probes that have already
35
+ * fired without producing a non-drop sample since the limit last
36
+ * recovered. Implementations typically use this to grow the interval
37
+ * exponentially (e.g. `base * 2^failedProbes`).
38
+ *
39
+ * Strategies that cannot reach a limit of 0 (e.g. {@link VegasLimit}) or
40
+ * are intentionally inert (e.g. {@link FixedLimit}) may omit this method;
41
+ * the limiter will then skip recovery probing for them.
42
+ */
43
+ probeFromZeroInterval?(failedProbes: number): number;
44
+ /**
45
+ * Atomically raises the limit to (at least) 1 so a single request can get
46
+ * through, allowing the sample to be used to probe recovery. Called by the
47
+ * limiter when its probe timer fires while the limit is at 0.
48
+ *
49
+ * Must be implemented if and only if {@link probeFromZeroInterval} is.
50
+ */
51
+ applyProbeFromZero?(): void;
27
52
  }
28
53
  //# sourceMappingURL=StreamingLimit.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"StreamingLimit.d.ts","sourceRoot":"","sources":["../../src/limit/StreamingLimit.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,2CAA2C;IAC3C,IAAI,YAAY,IAAI,MAAM,CAAC;IAE3B;;;;;OAKG;IACH,SAAS,CACP,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,EACpC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GACjC,MAAM,IAAI,CAAC;IAEd;;;;;;;;OAQG;IACH,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7G"}
1
+ {"version":3,"file":"StreamingLimit.d.ts","sourceRoot":"","sources":["../../src/limit/StreamingLimit.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,2CAA2C;IAC3C,IAAI,YAAY,IAAI,MAAM,CAAC;IAE3B;;;;;OAKG;IACH,SAAS,CACP,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,EACpC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GACjC,MAAM,IAAI,CAAC;IAEd;;;;;;;;OAQG;IACH,SAAS,CACP,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,OAAO,EAChB,aAAa,CAAC,EAAE,MAAM,GACrB,IAAI,CAAC;IAER;;;;;;;;;;;;;;;OAeG;IACH,qBAAqB,CAAC,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC;IAErD;;;;;;OAMG;IACH,kBAAkB,CAAC,IAAI,IAAI,CAAC;CAC7B"}
@@ -45,6 +45,16 @@ export declare class WindowedLimit implements AdaptiveLimit {
45
45
  private readonly sampleWindowFactory;
46
46
  /** Object tracking stats for the current sample window */
47
47
  private sample;
48
+ /**
49
+ * Set when `applyProbeFromZero` fires so the very next `addSample` bypasses
50
+ * windowing and is forwarded directly to the delegate. Without this, a
51
+ * single dropped probe sample never reaches the delegate (the window
52
+ * requires `windowSize` samples to become ready), so the delegate stays at
53
+ * limit 1 and the exponential backoff never re-arms.
54
+ */
55
+ private probeActive;
56
+ readonly probeFromZeroInterval?: (failedProbes: number) => number;
57
+ readonly applyProbeFromZero?: () => void;
48
58
  constructor(delegate: AdaptiveLimit, options?: WindowedLimitOptions);
49
59
  addSample(startTime: number, rtt: number, inflight: number, didDrop: boolean, operationName?: string): void;
50
60
  get currentLimit(): number;
@@ -1 +1 @@
1
- {"version":3,"file":"WindowedLimit.d.ts","sourceRoot":"","sources":["../../src/limit/WindowedLimit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAS7D,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB,6EAA6E;IAC7E,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAE3B,+EAA+E;IAC/E,mBAAmB,CAAC,EAAE,MAAM,YAAY,CAAC;CAC1C;AAED;;;;;;;;;;;;GAYG;AACH,qBAAa,aAAc,YAAW,aAAa;IACjD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgB;IAEzC,kFAAkF;IAClF,OAAO,CAAC,cAAc,CAAK;IAE3B,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqB;IAEzD,0DAA0D;IAC1D,OAAO,CAAC,MAAM,CAAe;gBAEjB,QAAQ,EAAE,aAAa,EAAE,OAAO,GAAE,oBAAyB;IAwBvE,SAAS,CACP,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,OAAO,EAChB,aAAa,CAAC,EAAE,MAAM,GACrB,IAAI;IAsCP,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,SAAS,CACP,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,EACpC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GACjC,MAAM,IAAI;CAGd"}
1
+ {"version":3,"file":"WindowedLimit.d.ts","sourceRoot":"","sources":["../../src/limit/WindowedLimit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAS7D,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB,6EAA6E;IAC7E,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAE3B,+EAA+E;IAC/E,mBAAmB,CAAC,EAAE,MAAM,YAAY,CAAC;CAC1C;AAED;;;;;;;;;;;;GAYG;AACH,qBAAa,aAAc,YAAW,aAAa;IACjD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgB;IAEzC,kFAAkF;IAClF,OAAO,CAAC,cAAc,CAAK;IAE3B,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqB;IAEzD,0DAA0D;IAC1D,OAAO,CAAC,MAAM,CAAe;IAE7B;;;;;;OAMG;IACH,OAAO,CAAC,WAAW,CAAS;IAK5B,QAAQ,CAAC,qBAAqB,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC;IAClE,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,IAAI,CAAC;gBAE7B,QAAQ,EAAE,aAAa,EAAE,OAAO,GAAE,oBAAyB;IAoCvE,SAAS,CACP,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,OAAO,EAChB,aAAa,CAAC,EAAE,MAAM,GACrB,IAAI;IA4CP,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,SAAS,CACP,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,EACpC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GACjC,MAAM,IAAI;CAGd"}
@@ -28,8 +28,30 @@ export class WindowedLimit {
28
28
  sampleWindowFactory;
29
29
  /** Object tracking stats for the current sample window */
30
30
  sample;
31
+ /**
32
+ * Set when `applyProbeFromZero` fires so the very next `addSample` bypasses
33
+ * windowing and is forwarded directly to the delegate. Without this, a
34
+ * single dropped probe sample never reaches the delegate (the window
35
+ * requires `windowSize` samples to become ready), so the delegate stays at
36
+ * limit 1 and the exponential backoff never re-arms.
37
+ */
38
+ probeActive = false;
39
+ // Forward recovery-probe support to the delegate when supported. Both
40
+ // methods are provided iff the delegate provides them, so the limiter's
41
+ // "both methods present" check works correctly.
42
+ probeFromZeroInterval;
43
+ applyProbeFromZero;
31
44
  constructor(delegate, options = {}) {
32
45
  this.delegate = delegate;
46
+ const { probeFromZeroInterval, applyProbeFromZero } = delegate;
47
+ if (typeof probeFromZeroInterval === "function" &&
48
+ typeof applyProbeFromZero === "function") {
49
+ this.probeFromZeroInterval = probeFromZeroInterval.bind(delegate);
50
+ this.applyProbeFromZero = () => {
51
+ this.probeActive = true;
52
+ applyProbeFromZero.call(delegate);
53
+ };
54
+ }
33
55
  this.minWindowTime = options.minWindowTimeMs ?? DEFAULT_MIN_WINDOW_TIME;
34
56
  this.maxWindowTime = options.maxWindowTimeMs ?? DEFAULT_MAX_WINDOW_TIME;
35
57
  this.windowSize = options.windowSize ?? DEFAULT_WINDOW_SIZE;
@@ -38,17 +60,22 @@ export class WindowedLimit {
38
60
  this.sampleWindowFactory =
39
61
  options.sampleWindowFactory ?? makeAverageSampleWindow;
40
62
  if (this.minWindowTime < 100) {
41
- throw new Error("minWindowTime must be >= 100 ms");
63
+ throw new RangeError("minWindowTime must be >= 100 ms");
42
64
  }
43
65
  if (this.maxWindowTime < 100) {
44
- throw new Error("maxWindowTime must be >= 100 ms");
66
+ throw new RangeError("maxWindowTime must be >= 100 ms");
45
67
  }
46
68
  if (this.windowSize < 10) {
47
- throw new Error("Window size must be >= 10");
69
+ throw new RangeError("Window size must be >= 10");
48
70
  }
49
71
  this.sample = this.sampleWindowFactory();
50
72
  }
51
73
  addSample(startTime, rtt, inflight, didDrop, operationName) {
74
+ if (this.probeActive) {
75
+ this.probeActive = false;
76
+ this.delegate.addSample(startTime, rtt, inflight, didDrop);
77
+ return;
78
+ }
52
79
  const endTime = startTime + rtt;
53
80
  if (rtt < this.minRttThreshold) {
54
81
  return;
@@ -76,7 +76,7 @@ class ImmutablePercentileSampleWindow {
76
76
  */
77
77
  export function createPercentileSampleWindow(percentile, windowSize) {
78
78
  if (percentile <= 0 || percentile >= 1) {
79
- throw new Error("Percentile should belong to (0, 1.0)");
79
+ throw new RangeError("Percentile should belong to (0, 1.0)");
80
80
  }
81
81
  return new ImmutablePercentileSampleWindow({ percentile, windowSize });
82
82
  }
@@ -59,7 +59,7 @@ export class PartitionedStrategy {
59
59
  throw new Error("No partitions specified");
60
60
  }
61
61
  if (totalPercent > 1) {
62
- throw new Error("Sum of partition percentages must be <= 1.0");
62
+ throw new RangeError("Sum of partition percentages must be <= 1.0");
63
63
  }
64
64
  this.partitionResolver = options.partitionResolver;
65
65
  this.knownPartitions = new Map(partitionEntries.map(([name, cfg]) => [
@@ -139,7 +139,7 @@ class Partition {
139
139
  _limitAtGlobalSaturation;
140
140
  constructor(init) {
141
141
  if (init.percent < 0 || init.percent > 1) {
142
- throw new Error("Partition percentage must be in the range [0.0, 1.0]");
142
+ throw new RangeError("Partition percentage must be in the range [0.0, 1.0]");
143
143
  }
144
144
  this.name = init.name;
145
145
  this.percent = init.percent;
@@ -147,7 +147,7 @@ class Partition {
147
147
  this._limitAtGlobalSaturation = init.initialLimitAtGlobalSaturation;
148
148
  if (this.burstMode.kind === "capped" &&
149
149
  this.burstMode.maxBurstMultiplier < 1.0) {
150
- throw new Error("maxBurstMultiplier must be >= 1.0");
150
+ throw new RangeError("maxBurstMultiplier must be >= 1.0");
151
151
  }
152
152
  const registry = init.registry;
153
153
  this.inflightDistribution = registry.distribution(MetricIds.INFLIGHT_NAME, {
@@ -13,7 +13,7 @@ export class DelayedRejectStrategy {
13
13
  this.delayMsForContext = options.delayMsForContext;
14
14
  const max = options.maxConcurrentDelays ?? 100;
15
15
  if (max < 1 || !Number.isFinite(max)) {
16
- throw new Error("maxConcurrentDelays must be a finite number >= 1");
16
+ throw new RangeError("maxConcurrentDelays must be a finite number >= 1");
17
17
  }
18
18
  this.maxConcurrentDelays = max;
19
19
  }
@@ -25,10 +25,10 @@ export class DecayingHistogram {
25
25
  constructor(options) {
26
26
  const { halfLife, minValue = 0.1, maxValue = 100_000, binsPerDecade = 5, logWarning, } = options;
27
27
  if (halfLife <= 0) {
28
- throw new Error("halfLife must be positive");
28
+ throw new RangeError("halfLife must be positive");
29
29
  }
30
30
  if (minValue <= 0 || maxValue <= minValue) {
31
- throw new Error("Must have 0 < minValue < maxValue");
31
+ throw new RangeError("Must have 0 < minValue < maxValue");
32
32
  }
33
33
  this.lambda = Math.LN2 / halfLife;
34
34
  this.minValue = minValue;
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "dynamic rate limiting"
10
10
  ],
11
11
  "author": "Ethan Resnick <ethan.resnick@gmail.com>",
12
- "version": "0.12.0",
12
+ "version": "0.12.1",
13
13
  "type": "module",
14
14
  "exports": {
15
15
  ".": {
@@ -33,6 +33,7 @@
33
33
  "typescript": "^6.0.2"
34
34
  },
35
35
  "dependencies": {
36
+ "es-toolkit": "^1.46.1",
36
37
  "lru_map": "^0.4.1"
37
38
  }
38
39
  }