@imbingox/acex 0.4.0-beta.17 → 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.
- package/CHANGELOG.md +6 -0
- package/docs/api.md +73 -2
- package/package.json +1 -1
- package/src/adapters/binance/adapter.ts +4 -1
- package/src/adapters/binance/market-catalog.ts +25 -20
- package/src/adapters/binance/private-adapter.ts +68 -53
- package/src/adapters/binance/rate-limit-topology.ts +257 -0
- package/src/adapters/binance/server-time.ts +20 -18
- package/src/client/runtime.ts +5 -1
- package/src/internal/rate-limiter/snapshot.ts +67 -0
- package/src/internal/rate-limiter/state.ts +98 -0
- package/src/internal/rate-limiter/topology.ts +123 -0
- package/src/internal/rate-limiter/types.ts +49 -0
- package/src/internal/rate-limiter/usage.ts +48 -0
- package/src/internal/rate-limiter.ts +792 -74
- package/src/types/shared.ts +196 -1
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
state: RateLimitSnapshot["state"];
|
|
23
|
-
updatedAt?: number;
|
|
51
|
+
interface PlanBucketCost {
|
|
52
|
+
bucket: RateLimitBucketDescriptor;
|
|
53
|
+
cost: number;
|
|
54
|
+
stateKey: string;
|
|
24
55
|
}
|
|
25
56
|
|
|
26
|
-
|
|
27
|
-
|
|
57
|
+
type AdmissionResult =
|
|
58
|
+
| {
|
|
59
|
+
admitted: true;
|
|
60
|
+
reservation: BudgetRateLimitReservation;
|
|
61
|
+
}
|
|
62
|
+
| {
|
|
63
|
+
admitted: false;
|
|
64
|
+
retryAt: number;
|
|
65
|
+
};
|
|
28
66
|
|
|
29
|
-
export class
|
|
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
|
|
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
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
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
|
-
|
|
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.
|
|
184
|
+
const existing = this.getEndpointState(ctx.scope);
|
|
59
185
|
const hasActiveBlock =
|
|
60
186
|
existing?.blockedUntil !== undefined &&
|
|
61
187
|
existing.blockedUntil > this.now();
|
|
62
|
-
this.
|
|
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.
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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.
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
125
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
):
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
154
|
-
return [scope.venue, scope.accountId ?? "", scope.endpointKey].join("\0");
|
|
155
|
-
}
|
|
873
|
+
export class ReactiveRateLimiter extends BudgetRateLimiter {}
|
|
156
874
|
|
|
157
|
-
function
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
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 (
|
|
172
|
-
|
|
889
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
890
|
+
throw new Error("rateLimit.retryJitterMs must be >= 0");
|
|
173
891
|
}
|
|
174
|
-
return
|
|
892
|
+
return value;
|
|
175
893
|
}
|
|
176
894
|
|
|
177
895
|
function defaultSleep(ms: number): Promise<void> {
|