@imbingox/acex 0.4.0-beta.16 → 0.4.0-beta.18

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.
@@ -1,65 +1,191 @@
1
1
  import type {
2
+ RateLimitBucketDescriptor,
3
+ RateLimitBucketSnapshot,
2
4
  RateLimiter,
5
+ RateLimitPlan,
6
+ RateLimitPriority,
3
7
  RateLimitRequestContext,
8
+ RateLimitReservation,
4
9
  RateLimitResponseContext,
5
10
  RateLimitScope,
6
11
  RateLimitSnapshot,
12
+ RateLimitTopology,
13
+ RateLimitTopologyRegistry,
7
14
  RateLimitTransportErrorContext,
8
15
  RateLimitUsage,
9
16
  } from "../types/index.ts";
17
+ import { aggregateBucketSnapshots } from "./rate-limiter/snapshot.ts";
18
+ import {
19
+ bucketStateKey,
20
+ maxOptional,
21
+ nextRateLimitState,
22
+ nextRetryAfterMs,
23
+ scopeKey,
24
+ windowEndMs,
25
+ windowStartMs,
26
+ } from "./rate-limiter/state.ts";
27
+ import {
28
+ bucketDescriptorsEqual,
29
+ cloneBucketDescriptor,
30
+ clonePlan,
31
+ plansEqual,
32
+ uniqueStrings,
33
+ validateBucketDescriptor,
34
+ validatePlan,
35
+ } from "./rate-limiter/topology.ts";
36
+ import type {
37
+ BucketRateLimitState,
38
+ BudgetRateLimitReservation,
39
+ EndpointRateLimitState,
40
+ ReactiveRateLimiterOptions,
41
+ } from "./rate-limiter/types.ts";
42
+ import { cloneUsage, usageForBucket } from "./rate-limiter/usage.ts";
10
43
 
11
- interface ReactiveRateLimiterOptions {
12
- readonly now?: () => number;
13
- readonly sleep?: (ms: number) => Promise<void>;
14
- readonly defaultRateLimitMs?: number;
15
- readonly defaultBanMs?: number;
16
- }
44
+ const DEFAULT_RATE_LIMIT_MS = 0;
45
+ const DEFAULT_BAN_MS = 120_000;
46
+ const MAX_BAN_MS = 3 * 24 * 60 * 60 * 1_000;
47
+ const MIN_RATE_LIMIT_BLOCK_MS = 1;
48
+ const DEFAULT_RETRY_JITTER_MS = 250;
49
+ const DEFAULT_UTILIZATION_TARGET = 0.9;
17
50
 
18
- interface RateLimitState {
19
- usage?: RateLimitUsage;
20
- blockedUntil?: number;
21
- retryAfterMs?: number;
22
- state: RateLimitSnapshot["state"];
23
- updatedAt?: number;
51
+ interface PlanBucketCost {
52
+ bucket: RateLimitBucketDescriptor;
53
+ cost: number;
54
+ stateKey: string;
24
55
  }
25
56
 
26
- const DEFAULT_RATE_LIMIT_MS = 0;
27
- const DEFAULT_BAN_MS = 60_000;
57
+ type AdmissionResult =
58
+ | {
59
+ admitted: true;
60
+ reservation: BudgetRateLimitReservation;
61
+ }
62
+ | {
63
+ admitted: false;
64
+ retryAt: number;
65
+ };
28
66
 
29
- export class ReactiveRateLimiter implements RateLimiter {
67
+ export class BudgetRateLimiter
68
+ implements RateLimiter, RateLimitTopologyRegistry
69
+ {
30
70
  private readonly now: () => number;
31
71
  private readonly sleep: (ms: number) => Promise<void>;
72
+ private readonly random: () => number;
32
73
  private readonly defaultRateLimitMs: number;
33
74
  private readonly defaultBanMs: number;
34
- private readonly states = new Map<string, RateLimitState>();
75
+ private readonly retryJitterMs: number;
76
+ private readonly utilizationTarget: number;
77
+ private readonly endpointStates = new Map<string, EndpointRateLimitState>();
78
+ private readonly bucketDescriptors = new Map<
79
+ string,
80
+ RateLimitBucketDescriptor
81
+ >();
82
+ private readonly plans = new Map<string, RateLimitPlan>();
83
+ private readonly bucketStates = new Map<string, BucketRateLimitState>();
84
+ private readonly lastPlanIdByScope = new Map<string, string>();
35
85
 
36
86
  constructor(options: ReactiveRateLimiterOptions = {}) {
37
87
  this.now = options.now ?? Date.now;
38
88
  this.sleep = options.sleep ?? defaultSleep;
89
+ this.random = options.random ?? Math.random;
39
90
  this.defaultRateLimitMs =
40
91
  options.defaultRateLimitMs ?? DEFAULT_RATE_LIMIT_MS;
41
92
  this.defaultBanMs = options.defaultBanMs ?? DEFAULT_BAN_MS;
93
+ this.retryJitterMs = normalizeRetryJitterMs(options.retryJitterMs);
94
+ this.utilizationTarget = normalizeUtilizationTarget(
95
+ options.utilizationTarget,
96
+ );
42
97
  }
43
98
 
44
- async beforeRequest(ctx: RateLimitRequestContext): Promise<void> {
45
- const snapshot = this.getSnapshot(ctx.scope);
46
- if (!snapshot?.blockedUntil || snapshot.blockedUntil <= this.now()) {
99
+ registerRateLimitTopology(topology: RateLimitTopology): void {
100
+ const nextBuckets = new Map(this.bucketDescriptors);
101
+
102
+ for (const bucket of topology.buckets) {
103
+ validateBucketDescriptor(bucket);
104
+ const existing = nextBuckets.get(bucket.id);
105
+ if (existing) {
106
+ if (!bucketDescriptorsEqual(existing, bucket)) {
107
+ throw new Error(
108
+ `Conflicting rate limit bucket descriptor: ${bucket.id}`,
109
+ );
110
+ }
111
+ continue;
112
+ }
113
+
114
+ nextBuckets.set(bucket.id, cloneBucketDescriptor(bucket));
115
+ }
116
+
117
+ const nextPlans = new Map(this.plans);
118
+ for (const plan of topology.plans) {
119
+ validatePlan(plan, nextBuckets);
120
+ const existing = nextPlans.get(plan.id);
121
+ if (existing) {
122
+ if (!plansEqual(existing, plan)) {
123
+ throw new Error(`Conflicting rate limit plan: ${plan.id}`);
124
+ }
125
+ continue;
126
+ }
127
+
128
+ nextPlans.set(plan.id, clonePlan(plan));
129
+ }
130
+
131
+ this.bucketDescriptors.clear();
132
+ for (const [id, bucket] of nextBuckets) {
133
+ this.bucketDescriptors.set(id, bucket);
134
+ }
135
+
136
+ this.plans.clear();
137
+ for (const [id, plan] of nextPlans) {
138
+ this.plans.set(id, plan);
139
+ }
140
+ }
141
+
142
+ async beforeRequest(
143
+ ctx: RateLimitRequestContext,
144
+ ): Promise<RateLimitReservation | undefined> {
145
+ const plan = this.getKnownPlan(ctx);
146
+ if (!plan) {
147
+ await this.sleepForEndpointBlock(ctx.scope);
47
148
  return;
48
149
  }
49
150
 
50
- await this.sleep(Math.max(0, snapshot.blockedUntil - this.now()));
151
+ this.rememberPlan(ctx.scope, plan.id);
152
+ while (true) {
153
+ const admission = this.tryAdmit(
154
+ ctx.scope,
155
+ plan,
156
+ this.resolvePriority(ctx, plan),
157
+ );
158
+ if (admission.admitted) {
159
+ return admission.reservation;
160
+ }
161
+
162
+ await this.sleep(Math.max(0, admission.retryAt - this.now()));
163
+ }
51
164
  }
52
165
 
53
166
  afterResponse(
54
167
  ctx: RateLimitRequestContext,
55
168
  response: RateLimitResponseContext,
56
169
  ): void {
170
+ const plan = this.getKnownPlan(ctx);
171
+ if (plan) {
172
+ this.rememberPlan(ctx.scope, plan.id);
173
+ if (response.usage) {
174
+ this.updateBucketUsage(
175
+ ctx.scope,
176
+ plan,
177
+ response.usage,
178
+ response.reservation,
179
+ );
180
+ }
181
+ }
182
+
57
183
  if (response.usage) {
58
- const existing = this.getState(ctx.scope);
184
+ const existing = this.getEndpointState(ctx.scope);
59
185
  const hasActiveBlock =
60
186
  existing?.blockedUntil !== undefined &&
61
187
  existing.blockedUntil > this.now();
62
- this.updateState(ctx.scope, {
188
+ this.updateEndpointState(ctx.scope, {
63
189
  usage: cloneUsage(response.usage),
64
190
  state: hasActiveBlock ? existing.state : "ok",
65
191
  });
@@ -70,8 +196,20 @@ export class ReactiveRateLimiter implements RateLimiter {
70
196
  ctx: RateLimitRequestContext,
71
197
  error: RateLimitTransportErrorContext,
72
198
  ): void {
199
+ const plan = this.getKnownPlan(ctx);
200
+ if (error.requestNotSent) {
201
+ this.refundReservation(error.reservation);
202
+ }
203
+
204
+ if (plan) {
205
+ this.rememberPlan(ctx.scope, plan.id);
206
+ if (error.usage) {
207
+ this.updateBucketUsage(ctx.scope, plan, error.usage, error.reservation);
208
+ }
209
+ }
210
+
73
211
  if (error.usage) {
74
- this.updateState(ctx.scope, {
212
+ this.updateEndpointState(ctx.scope, {
75
213
  usage: cloneUsage(error.usage),
76
214
  });
77
215
  }
@@ -80,29 +218,590 @@ export class ReactiveRateLimiter implements RateLimiter {
80
218
  return;
81
219
  }
82
220
 
221
+ if (!plan) {
222
+ this.blockEndpoint(ctx.scope, error);
223
+ return;
224
+ }
225
+
226
+ const affectedBuckets = this.getAffectedBuckets(plan, error.status);
227
+ if (affectedBuckets.length === 0) {
228
+ this.blockEndpoint(ctx.scope, error);
229
+ return;
230
+ }
231
+
232
+ for (const bucket of affectedBuckets) {
233
+ this.blockBucket(ctx.scope, bucket, error);
234
+ }
235
+ }
236
+
237
+ getSnapshot(scope: RateLimitScope): RateLimitSnapshot | undefined {
238
+ const endpointState = this.getEndpointState(scope);
239
+ const bucketSnapshots = this.getBucketSnapshots(scope);
240
+ if (!endpointState && bucketSnapshots.length === 0) {
241
+ return undefined;
242
+ }
243
+
244
+ const endpointSnapshot = endpointState
245
+ ? this.createEndpointSnapshot(scope, endpointState)
246
+ : {
247
+ scope: { ...scope },
248
+ state: "ok" as const,
249
+ };
250
+
251
+ const aggregate = aggregateBucketSnapshots(
252
+ endpointSnapshot,
253
+ bucketSnapshots,
254
+ );
255
+
256
+ return {
257
+ ...endpointSnapshot,
258
+ ...aggregate,
259
+ buckets: bucketSnapshots.length > 0 ? bucketSnapshots : undefined,
260
+ };
261
+ }
262
+
263
+ private async sleepForEndpointBlock(scope: RateLimitScope): Promise<void> {
264
+ while (true) {
265
+ const snapshot = this.getSnapshot(scope);
266
+ if (!snapshot?.blockedUntil || snapshot.blockedUntil <= this.now()) {
267
+ return;
268
+ }
269
+
270
+ await this.sleep(Math.max(0, snapshot.blockedUntil - this.now()));
271
+ }
272
+ }
273
+
274
+ private getKnownPlan(
275
+ ctx: RateLimitRequestContext,
276
+ ): RateLimitPlan | undefined {
277
+ return ctx.planId ? this.plans.get(ctx.planId) : undefined;
278
+ }
279
+
280
+ private rememberPlan(scope: RateLimitScope, planId: string): void {
281
+ this.lastPlanIdByScope.set(scopeKey(scope), planId);
282
+ }
283
+
284
+ private resolvePriority(
285
+ ctx: RateLimitRequestContext,
286
+ plan: RateLimitPlan,
287
+ ): RateLimitPriority {
288
+ return ctx.priority ?? plan.priority ?? "normal";
289
+ }
290
+
291
+ private tryAdmit(
292
+ scope: RateLimitScope,
293
+ plan: RateLimitPlan,
294
+ priority: RateLimitPriority,
295
+ ): AdmissionResult {
296
+ const now = this.now();
297
+ const bucketCosts = this.getPlanBucketCosts(scope, plan);
298
+ let retryAt: number | undefined;
299
+
300
+ for (const bucketCost of bucketCosts) {
301
+ const state = this.rolloverBucketState(bucketCost, now);
302
+ if (state.blockedUntil !== undefined && state.blockedUntil > now) {
303
+ retryAt = maxOptional(retryAt, state.blockedUntil);
304
+ continue;
305
+ }
306
+
307
+ if (bucketCost.cost <= 0) {
308
+ continue;
309
+ }
310
+
311
+ const limit = this.effectiveLimit(bucketCost.bucket, priority);
312
+ const used = state.used ?? 0;
313
+ if (used + bucketCost.cost > limit) {
314
+ retryAt = maxOptional(
315
+ retryAt,
316
+ windowEndMs(state.windowStartMs ?? 0, bucketCost.bucket.intervalMs),
317
+ );
318
+ }
319
+ }
320
+
321
+ if (retryAt !== undefined && retryAt > now) {
322
+ return {
323
+ admitted: false,
324
+ retryAt,
325
+ };
326
+ }
327
+
328
+ const reservationBuckets = bucketCosts.map((bucketCost) => {
329
+ const state = this.rolloverBucketState(bucketCost, now);
330
+ if (bucketCost.cost > 0) {
331
+ this.updateBucketState(scope, bucketCost.bucket, {
332
+ used: (state.used ?? 0) + bucketCost.cost,
333
+ windowStartMs: state.windowStartMs,
334
+ state: "ok",
335
+ });
336
+ }
337
+
338
+ return {
339
+ bucketId: bucketCost.bucket.id,
340
+ stateKey: bucketCost.stateKey,
341
+ cost: bucketCost.cost,
342
+ windowStartMs:
343
+ state.windowStartMs ??
344
+ windowStartMs(now, bucketCost.bucket.intervalMs),
345
+ };
346
+ });
347
+
348
+ return {
349
+ admitted: true,
350
+ reservation: {
351
+ admittedAt: now,
352
+ planId: plan.id,
353
+ priority,
354
+ buckets: reservationBuckets,
355
+ },
356
+ };
357
+ }
358
+
359
+ private getPlanBucketCosts(
360
+ scope: RateLimitScope,
361
+ plan: RateLimitPlan,
362
+ ): PlanBucketCost[] {
363
+ const bucketCosts: PlanBucketCost[] = [];
364
+ const costByBucketId = new Map<string, number>();
365
+ for (const cost of plan.costs) {
366
+ costByBucketId.set(
367
+ cost.bucketId,
368
+ (costByBucketId.get(cost.bucketId) ?? 0) + cost.cost,
369
+ );
370
+ }
371
+
372
+ for (const [bucketId, cost] of costByBucketId) {
373
+ const bucket = this.bucketDescriptors.get(bucketId);
374
+ if (!bucket) {
375
+ continue;
376
+ }
377
+
378
+ bucketCosts.push({
379
+ bucket,
380
+ cost,
381
+ stateKey: bucketStateKey(scope, bucket),
382
+ });
383
+ }
384
+
385
+ return bucketCosts;
386
+ }
387
+
388
+ private rolloverBucketState(
389
+ bucketCost: PlanBucketCost,
390
+ now: number,
391
+ ): BucketRateLimitState {
392
+ const currentWindowStart = windowStartMs(now, bucketCost.bucket.intervalMs);
393
+ const existing = this.bucketStates.get(bucketCost.stateKey);
394
+ if (
395
+ existing?.windowStartMs !== undefined &&
396
+ existing.windowStartMs >= currentWindowStart
397
+ ) {
398
+ return existing;
399
+ }
400
+
401
+ const next: BucketRateLimitState = {
402
+ blockedUntil: existing?.blockedUntil,
403
+ retryAfterMs: existing?.retryAfterMs,
404
+ state:
405
+ existing?.blockedUntil !== undefined && existing.blockedUntil > now
406
+ ? existing.state
407
+ : "ok",
408
+ updatedAt: now,
409
+ used: existing?.windowStartMs === undefined ? existing?.used : 0,
410
+ windowStartMs: currentWindowStart,
411
+ };
412
+ this.bucketStates.set(bucketCost.stateKey, next);
413
+ return next;
414
+ }
415
+
416
+ private effectiveLimit(
417
+ bucket: RateLimitBucketDescriptor,
418
+ priority: RateLimitPriority,
419
+ ): number {
420
+ const rawTargetLimit = Math.floor(
421
+ bucket.limit * (bucket.utilizationTarget ?? this.utilizationTarget),
422
+ );
423
+ const targetLimit =
424
+ bucket.limit > 0 ? Math.max(1, rawTargetLimit) : rawTargetLimit;
425
+ if (!bucket.reserve) {
426
+ return targetLimit;
427
+ }
428
+
429
+ if (priority === bucket.reserve.priority) {
430
+ return bucket.limit;
431
+ }
432
+
433
+ return Math.max(0, targetLimit - bucket.reserve.units);
434
+ }
435
+
436
+ private updateBucketUsage(
437
+ scope: RateLimitScope,
438
+ plan: RateLimitPlan,
439
+ usage: RateLimitUsage,
440
+ reservation: RateLimitReservation | undefined,
441
+ ): void {
442
+ for (const bucket of this.getPlanBuckets(plan)) {
443
+ const used = usageForBucket(usage, bucket);
444
+ if (used === undefined) {
445
+ continue;
446
+ }
447
+
448
+ const stateKey = bucketStateKey(scope, bucket);
449
+ const reservationBucket = this.findReservationBucket(
450
+ reservation,
451
+ bucket.id,
452
+ stateKey,
453
+ );
454
+ if (
455
+ this.isBudgetRateLimitReservation(reservation) &&
456
+ !reservationBucket
457
+ ) {
458
+ continue;
459
+ }
460
+
461
+ const now = this.now();
462
+ const currentWindowStart = windowStartMs(now, bucket.intervalMs);
463
+ if (
464
+ reservationBucket &&
465
+ reservationBucket.windowStartMs < currentWindowStart
466
+ ) {
467
+ continue;
468
+ }
469
+
470
+ const existing = this.bucketStates.get(stateKey);
471
+ if (
472
+ reservationBucket &&
473
+ existing?.windowStartMs !== undefined &&
474
+ existing.windowStartMs > reservationBucket.windowStartMs
475
+ ) {
476
+ continue;
477
+ }
478
+
479
+ const windowRolled =
480
+ existing?.windowStartMs !== undefined &&
481
+ existing.windowStartMs < currentWindowStart;
482
+ const nextWindowStart = windowRolled
483
+ ? currentWindowStart
484
+ : (existing?.windowStartMs ?? currentWindowStart);
485
+ const nextUsed = windowRolled
486
+ ? used
487
+ : Math.max(existing?.used ?? 0, used);
488
+ const hasActiveBlock =
489
+ existing?.blockedUntil !== undefined && existing.blockedUntil > now;
490
+ this.updateBucketState(scope, bucket, {
491
+ used: nextUsed,
492
+ windowStartMs: nextWindowStart,
493
+ state: hasActiveBlock ? existing.state : "ok",
494
+ });
495
+ }
496
+ }
497
+
498
+ private findReservationBucket(
499
+ reservation: RateLimitReservation | undefined,
500
+ bucketId: string,
501
+ stateKey: string,
502
+ ): BudgetRateLimitReservation["buckets"][number] | undefined {
503
+ if (!this.isBudgetRateLimitReservation(reservation)) {
504
+ return undefined;
505
+ }
506
+
507
+ return reservation.buckets.find(
508
+ (bucket) => bucket.bucketId === bucketId && bucket.stateKey === stateKey,
509
+ );
510
+ }
511
+
512
+ private refundReservation(
513
+ reservation: RateLimitReservation | undefined,
514
+ ): void {
515
+ if (!this.isBudgetRateLimitReservation(reservation)) {
516
+ return;
517
+ }
518
+
519
+ for (const reservedBucket of reservation.buckets) {
520
+ if (reservedBucket.cost <= 0) {
521
+ continue;
522
+ }
523
+
524
+ const state = this.bucketStates.get(reservedBucket.stateKey);
525
+ if (!state || state.windowStartMs !== reservedBucket.windowStartMs) {
526
+ continue;
527
+ }
528
+
529
+ this.bucketStates.set(reservedBucket.stateKey, {
530
+ ...state,
531
+ used: Math.max(0, (state.used ?? 0) - reservedBucket.cost),
532
+ updatedAt: this.now(),
533
+ });
534
+ }
535
+ }
536
+
537
+ private isBudgetRateLimitReservation(
538
+ reservation: RateLimitReservation | undefined,
539
+ ): reservation is BudgetRateLimitReservation {
540
+ return (
541
+ !!reservation &&
542
+ typeof (reservation as BudgetRateLimitReservation).admittedAt ===
543
+ "number" &&
544
+ Array.isArray((reservation as BudgetRateLimitReservation).buckets)
545
+ );
546
+ }
547
+
548
+ private getAffectedBuckets(
549
+ plan: RateLimitPlan,
550
+ status: 429 | 418,
551
+ ): RateLimitBucketDescriptor[] {
552
+ const buckets = this.getPlanBuckets(plan);
553
+ if (status === 418) {
554
+ return buckets.filter((bucket) => bucket.kind === "request_weight");
555
+ }
556
+
557
+ const positiveCostBucketIds = uniqueStrings(
558
+ plan.costs.filter((cost) => cost.cost > 0).map((cost) => cost.bucketId),
559
+ );
560
+ const bucketIds =
561
+ positiveCostBucketIds.length > 0
562
+ ? positiveCostBucketIds
563
+ : uniqueStrings(plan.costs.map((cost) => cost.bucketId));
564
+
565
+ const onlyBucketId = bucketIds[0];
566
+ if (bucketIds.length === 1 && onlyBucketId !== undefined) {
567
+ const bucket = this.bucketDescriptors.get(onlyBucketId);
568
+ return bucket ? [bucket] : [];
569
+ }
570
+
571
+ return buckets;
572
+ }
573
+
574
+ private getPlanBuckets(plan: RateLimitPlan): RateLimitBucketDescriptor[] {
575
+ const buckets: RateLimitBucketDescriptor[] = [];
576
+ const seen = new Set<string>();
577
+ for (const cost of plan.costs) {
578
+ if (seen.has(cost.bucketId)) {
579
+ continue;
580
+ }
581
+ seen.add(cost.bucketId);
582
+ const bucket = this.bucketDescriptors.get(cost.bucketId);
583
+ if (bucket) {
584
+ buckets.push(bucket);
585
+ }
586
+ }
587
+
588
+ return buckets;
589
+ }
590
+
591
+ private blockEndpoint(
592
+ scope: RateLimitScope,
593
+ error: RateLimitTransportErrorContext,
594
+ ): void {
83
595
  const now = this.now();
84
596
  const isBan = error.status === 418;
85
- const retryAfterMs =
86
- error.retryAfterMs ??
87
- (isBan ? this.defaultBanMs : this.defaultRateLimitMs);
88
- const blockedUntil =
89
- retryAfterMs > 0
90
- ? now + retryAfterMs
91
- : this.getState(ctx.scope)?.blockedUntil;
597
+ const existing = this.getEndpointState(scope);
598
+ const banStrikeCount = this.nextBanStrikeCount(
599
+ isBan,
600
+ error.retryAfterMs,
601
+ existing,
602
+ now,
603
+ );
604
+ const retryAfterMs = this.resolveRetryAfterMs(
605
+ isBan,
606
+ error.retryAfterMs,
607
+ banStrikeCount,
608
+ );
609
+ const blockedUntil = now + retryAfterMs;
92
610
 
93
- this.updateState(ctx.scope, {
611
+ this.updateEndpointState(scope, {
612
+ banStrikeCount,
94
613
  blockedUntil,
95
614
  retryAfterMs,
96
615
  state: isBan ? "banned" : "rate_limited",
97
616
  });
98
617
  }
99
618
 
100
- getSnapshot(scope: RateLimitScope): RateLimitSnapshot | undefined {
101
- const state = this.getState(scope);
102
- if (!state) {
619
+ private blockBucket(
620
+ scope: RateLimitScope,
621
+ bucket: RateLimitBucketDescriptor,
622
+ error: RateLimitTransportErrorContext,
623
+ ): void {
624
+ const now = this.now();
625
+ const isBan = error.status === 418;
626
+ const existing = this.bucketStates.get(bucketStateKey(scope, bucket));
627
+ const banStrikeCount = this.nextBanStrikeCount(
628
+ isBan,
629
+ error.retryAfterMs,
630
+ existing,
631
+ now,
632
+ );
633
+ const retryAfterMs = this.resolveBucketRetryAfterMs(
634
+ bucket,
635
+ isBan,
636
+ error.retryAfterMs,
637
+ now,
638
+ banStrikeCount,
639
+ );
640
+ const blockedUntil = now + retryAfterMs;
641
+
642
+ this.updateBucketState(scope, bucket, {
643
+ banStrikeCount,
644
+ blockedUntil,
645
+ retryAfterMs,
646
+ state: isBan ? "banned" : "rate_limited",
647
+ });
648
+ }
649
+
650
+ private resolveBucketRetryAfterMs(
651
+ bucket: RateLimitBucketDescriptor,
652
+ isBan: boolean,
653
+ retryAfterMs: number | undefined,
654
+ now: number,
655
+ banStrikeCount: number | undefined,
656
+ ): number {
657
+ if (isBan || retryAfterMs !== undefined) {
658
+ return this.resolveRetryAfterMs(isBan, retryAfterMs, banStrikeCount);
659
+ }
660
+
661
+ return Math.max(
662
+ MIN_RATE_LIMIT_BLOCK_MS,
663
+ windowEndMs(windowStartMs(now, bucket.intervalMs), bucket.intervalMs) -
664
+ now +
665
+ this.nextRetryJitterMs(),
666
+ );
667
+ }
668
+
669
+ private nextRetryJitterMs(): number {
670
+ if (this.retryJitterMs <= 0) {
671
+ return 0;
672
+ }
673
+
674
+ const sample = this.random();
675
+ if (!Number.isFinite(sample)) {
676
+ return 0;
677
+ }
678
+
679
+ return Math.floor(Math.min(Math.max(sample, 0), 1) * this.retryJitterMs);
680
+ }
681
+
682
+ private resolveRetryAfterMs(
683
+ isBan: boolean,
684
+ retryAfterMs: number | undefined,
685
+ banStrikeCount?: number,
686
+ ): number {
687
+ return Math.max(
688
+ MIN_RATE_LIMIT_BLOCK_MS,
689
+ retryAfterMs ??
690
+ (isBan
691
+ ? this.defaultBanMsForStrike(banStrikeCount)
692
+ : this.defaultRateLimitMs),
693
+ );
694
+ }
695
+
696
+ private nextBanStrikeCount(
697
+ isBan: boolean,
698
+ retryAfterMs: number | undefined,
699
+ existing:
700
+ | Pick<
701
+ EndpointRateLimitState,
702
+ "banStrikeCount" | "blockedUntil" | "state"
703
+ >
704
+ | Pick<BucketRateLimitState, "banStrikeCount" | "blockedUntil" | "state">
705
+ | undefined,
706
+ now: number,
707
+ ): number | undefined {
708
+ if (!isBan || retryAfterMs !== undefined) {
103
709
  return undefined;
104
710
  }
105
711
 
712
+ const activeBan =
713
+ existing?.state === "banned" &&
714
+ existing.blockedUntil !== undefined &&
715
+ existing.blockedUntil > now;
716
+ return (activeBan ? (existing.banStrikeCount ?? 1) : 0) + 1;
717
+ }
718
+
719
+ private defaultBanMsForStrike(banStrikeCount: number | undefined): number {
720
+ const exponent = Math.max(0, (banStrikeCount ?? 1) - 1);
721
+ return Math.min(MAX_BAN_MS, this.defaultBanMs * 2 ** exponent);
722
+ }
723
+
724
+ private getEndpointState(
725
+ scope: RateLimitScope,
726
+ ): EndpointRateLimitState | undefined {
727
+ return this.endpointStates.get(scopeKey(scope));
728
+ }
729
+
730
+ private updateEndpointState(
731
+ scope: RateLimitScope,
732
+ patch: Partial<EndpointRateLimitState>,
733
+ ): void {
734
+ const existing = this.getEndpointState(scope);
735
+ const nextBlockedUntil = maxOptional(
736
+ existing?.blockedUntil,
737
+ patch.blockedUntil,
738
+ );
739
+ const patchWinsBlock =
740
+ patch.blockedUntil !== undefined &&
741
+ (existing?.blockedUntil === undefined ||
742
+ patch.blockedUntil > existing.blockedUntil);
743
+ const nextState = nextRateLimitState(
744
+ existing?.state,
745
+ patch.state,
746
+ patchWinsBlock,
747
+ nextBlockedUntil,
748
+ );
749
+ const nextBanStrikeCount =
750
+ nextState === "banned"
751
+ ? (patch.banStrikeCount ?? existing?.banStrikeCount)
752
+ : undefined;
753
+
754
+ this.endpointStates.set(scopeKey(scope), {
755
+ usage: patch.usage ?? existing?.usage,
756
+ banStrikeCount: nextBanStrikeCount,
757
+ blockedUntil: nextBlockedUntil,
758
+ retryAfterMs: nextRetryAfterMs(existing, patch, patchWinsBlock),
759
+ state: nextState ?? "ok",
760
+ updatedAt: this.now(),
761
+ });
762
+ }
763
+
764
+ private updateBucketState(
765
+ scope: RateLimitScope,
766
+ bucket: RateLimitBucketDescriptor,
767
+ patch: Partial<BucketRateLimitState>,
768
+ ): void {
769
+ const key = bucketStateKey(scope, bucket);
770
+ const existing = this.bucketStates.get(key);
771
+ const nextBlockedUntil = maxOptional(
772
+ existing?.blockedUntil,
773
+ patch.blockedUntil,
774
+ );
775
+ const patchWinsBlock =
776
+ patch.blockedUntil !== undefined &&
777
+ (existing?.blockedUntil === undefined ||
778
+ patch.blockedUntil > existing.blockedUntil);
779
+ const nextState = nextRateLimitState(
780
+ existing?.state,
781
+ patch.state,
782
+ patchWinsBlock,
783
+ nextBlockedUntil,
784
+ );
785
+ const nextBanStrikeCount =
786
+ nextState === "banned"
787
+ ? (patch.banStrikeCount ?? existing?.banStrikeCount)
788
+ : undefined;
789
+
790
+ this.bucketStates.set(key, {
791
+ used: patch.used ?? existing?.used,
792
+ windowStartMs: patch.windowStartMs ?? existing?.windowStartMs,
793
+ banStrikeCount: nextBanStrikeCount,
794
+ blockedUntil: nextBlockedUntil,
795
+ retryAfterMs: nextRetryAfterMs(existing, patch, patchWinsBlock),
796
+ state: nextState ?? "ok",
797
+ updatedAt: this.now(),
798
+ });
799
+ }
800
+
801
+ private createEndpointSnapshot(
802
+ scope: RateLimitScope,
803
+ state: EndpointRateLimitState,
804
+ ): RateLimitSnapshot {
106
805
  const now = this.now();
107
806
  const blockedUntil =
108
807
  state.blockedUntil !== undefined && state.blockedUntil > now
@@ -121,57 +820,76 @@ export class ReactiveRateLimiter implements RateLimiter {
121
820
  };
122
821
  }
123
822
 
124
- private getState(scope: RateLimitScope): RateLimitState | undefined {
125
- return this.states.get(scopeKey(scope));
823
+ private getBucketSnapshots(scope: RateLimitScope): RateLimitBucketSnapshot[] {
824
+ const planId = this.lastPlanIdByScope.get(scopeKey(scope));
825
+ const plan = planId ? this.plans.get(planId) : undefined;
826
+ if (!plan) {
827
+ return [];
828
+ }
829
+
830
+ const snapshots: RateLimitBucketSnapshot[] = [];
831
+ for (const bucketCost of this.getPlanBucketCosts(scope, plan)) {
832
+ const state = this.rolloverBucketState(bucketCost, this.now());
833
+
834
+ snapshots.push(this.createBucketSnapshot(bucketCost.bucket, state));
835
+ }
836
+
837
+ return snapshots;
126
838
  }
127
839
 
128
- private updateState(
129
- scope: RateLimitScope,
130
- patch: Partial<RateLimitState>,
131
- ): void {
132
- const existing = this.getState(scope);
133
- const nextBlockedUntil = maxOptional(
134
- existing?.blockedUntil,
135
- patch.blockedUntil,
136
- );
137
- const nextState =
138
- patch.state ??
139
- (nextBlockedUntil !== undefined
140
- ? (existing?.state ?? "ok")
141
- : existing?.state);
840
+ private createBucketSnapshot(
841
+ bucket: RateLimitBucketDescriptor,
842
+ state: BucketRateLimitState,
843
+ ): RateLimitBucketSnapshot {
844
+ const now = this.now();
845
+ const blockedUntil =
846
+ state.blockedUntil !== undefined && state.blockedUntil > now
847
+ ? state.blockedUntil
848
+ : undefined;
849
+ const runtimeState =
850
+ blockedUntil === undefined && state.state !== "ok" ? "ok" : state.state;
142
851
 
143
- this.states.set(scopeKey(scope), {
144
- usage: patch.usage ?? existing?.usage,
145
- blockedUntil: nextBlockedUntil,
146
- retryAfterMs: patch.retryAfterMs ?? existing?.retryAfterMs,
147
- state: nextState ?? "ok",
148
- updatedAt: this.now(),
149
- });
852
+ return {
853
+ bucketId: bucket.id,
854
+ kind: bucket.kind,
855
+ limit: bucket.limit,
856
+ intervalMs: bucket.intervalMs,
857
+ utilizationTarget: bucket.utilizationTarget ?? this.utilizationTarget,
858
+ reserve: bucket.reserve ? { ...bucket.reserve } : undefined,
859
+ used: state.used,
860
+ windowStartMs: state.windowStartMs,
861
+ windowEndMs:
862
+ state.windowStartMs !== undefined
863
+ ? windowEndMs(state.windowStartMs, bucket.intervalMs)
864
+ : undefined,
865
+ blockedUntil,
866
+ retryAfterMs: blockedUntil ? state.retryAfterMs : undefined,
867
+ state: runtimeState,
868
+ updatedAt: state.updatedAt,
869
+ };
150
870
  }
151
871
  }
152
872
 
153
- function scopeKey(scope: RateLimitScope): string {
154
- return [scope.venue, scope.accountId ?? "", scope.endpointKey].join("\0");
155
- }
873
+ export class ReactiveRateLimiter extends BudgetRateLimiter {}
156
874
 
157
- function cloneUsage(usage: RateLimitUsage): RateLimitUsage {
158
- return {
159
- weight: usage.weight ? { ...usage.weight } : undefined,
160
- orderCount: usage.orderCount ? { ...usage.orderCount } : undefined,
161
- };
875
+ function normalizeUtilizationTarget(value: number | undefined): number {
876
+ if (value === undefined) {
877
+ return DEFAULT_UTILIZATION_TARGET;
878
+ }
879
+ if (!Number.isFinite(value) || value <= 0 || value > 1) {
880
+ throw new Error("rateLimit.utilizationTarget must be > 0 and <= 1");
881
+ }
882
+ return value;
162
883
  }
163
884
 
164
- function maxOptional(
165
- left: number | undefined,
166
- right: number | undefined,
167
- ): number | undefined {
168
- if (left === undefined) {
169
- return right;
885
+ function normalizeRetryJitterMs(value: number | undefined): number {
886
+ if (value === undefined) {
887
+ return DEFAULT_RETRY_JITTER_MS;
170
888
  }
171
- if (right === undefined) {
172
- return left;
889
+ if (!Number.isFinite(value) || value < 0) {
890
+ throw new Error("rateLimit.retryJitterMs must be >= 0");
173
891
  }
174
- return Math.max(left, right);
892
+ return value;
175
893
  }
176
894
 
177
895
  function defaultSleep(ms: number): Promise<void> {