@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
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RateLimiter,
|
|
3
|
+
RateLimitPriority,
|
|
4
|
+
RateLimitTopology,
|
|
5
|
+
RateLimitTopologyRegistry,
|
|
6
|
+
} from "../../types/index.ts";
|
|
7
|
+
|
|
8
|
+
const ONE_MINUTE_MS = 60_000;
|
|
9
|
+
|
|
10
|
+
const SPOT_REQUEST_WEIGHT_LIMIT_1M = 6_000;
|
|
11
|
+
const FAPI_REQUEST_WEIGHT_LIMIT_1M = 2_400;
|
|
12
|
+
const DAPI_REQUEST_WEIGHT_LIMIT_1M = 6_000;
|
|
13
|
+
const PAPI_REQUEST_WEIGHT_LIMIT_1M = 6_000;
|
|
14
|
+
const PAPI_CANCEL_REQUEST_WEIGHT_RESERVE_1M = 300;
|
|
15
|
+
const PAPI_ORDERS_LIMIT_1M = 1_200;
|
|
16
|
+
|
|
17
|
+
export const BINANCE_RATE_LIMIT_BUCKETS = {
|
|
18
|
+
spotRequestWeight1m: "binance:spot:request-weight:1m",
|
|
19
|
+
fapiRequestWeight1m: "binance:fapi:request-weight:1m",
|
|
20
|
+
dapiRequestWeight1m: "binance:dapi:request-weight:1m",
|
|
21
|
+
papiRequestWeight1m: "binance:papi:request-weight:1m",
|
|
22
|
+
papiOrders1m: "binance:papi:orders:1m",
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export const BINANCE_RATE_LIMIT_PLANS = {
|
|
26
|
+
spotExchangeInfo: "binance:spot:exchange-info",
|
|
27
|
+
fapiExchangeInfo: "binance:fapi:exchange-info",
|
|
28
|
+
dapiExchangeInfo: "binance:dapi:exchange-info",
|
|
29
|
+
fapiServerTime: "binance:fapi:server-time",
|
|
30
|
+
papiBalance: "binance:papi:balance",
|
|
31
|
+
papiAccount: "binance:papi:account",
|
|
32
|
+
papiPositionRisk: "binance:papi:position-risk",
|
|
33
|
+
papiQueryOrder: "binance:papi:query-order",
|
|
34
|
+
papiOpenOrdersSymbol: "binance:papi:open-orders:symbol",
|
|
35
|
+
papiOpenOrdersAll: "binance:papi:open-orders:all",
|
|
36
|
+
papiNewOrder: "binance:papi:new-order",
|
|
37
|
+
papiCancelOrder: "binance:papi:cancel-order",
|
|
38
|
+
papiCancelAllOrders: "binance:papi:cancel-all-orders",
|
|
39
|
+
papiListenKey: "binance:papi:listen-key",
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
export const BINANCE_RATE_LIMIT_TOPOLOGY: RateLimitTopology = {
|
|
43
|
+
id: "binance-rest-rate-limits:v1",
|
|
44
|
+
buckets: [
|
|
45
|
+
{
|
|
46
|
+
id: BINANCE_RATE_LIMIT_BUCKETS.spotRequestWeight1m,
|
|
47
|
+
kind: "request_weight",
|
|
48
|
+
limit: SPOT_REQUEST_WEIGHT_LIMIT_1M,
|
|
49
|
+
intervalMs: ONE_MINUTE_MS,
|
|
50
|
+
scope: ["venue"],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: BINANCE_RATE_LIMIT_BUCKETS.fapiRequestWeight1m,
|
|
54
|
+
kind: "request_weight",
|
|
55
|
+
limit: FAPI_REQUEST_WEIGHT_LIMIT_1M,
|
|
56
|
+
intervalMs: ONE_MINUTE_MS,
|
|
57
|
+
scope: ["venue"],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: BINANCE_RATE_LIMIT_BUCKETS.dapiRequestWeight1m,
|
|
61
|
+
kind: "request_weight",
|
|
62
|
+
limit: DAPI_REQUEST_WEIGHT_LIMIT_1M,
|
|
63
|
+
intervalMs: ONE_MINUTE_MS,
|
|
64
|
+
scope: ["venue"],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: BINANCE_RATE_LIMIT_BUCKETS.papiRequestWeight1m,
|
|
68
|
+
kind: "request_weight",
|
|
69
|
+
limit: PAPI_REQUEST_WEIGHT_LIMIT_1M,
|
|
70
|
+
intervalMs: ONE_MINUTE_MS,
|
|
71
|
+
scope: ["venue"],
|
|
72
|
+
reserve: {
|
|
73
|
+
priority: "cancel",
|
|
74
|
+
units: PAPI_CANCEL_REQUEST_WEIGHT_RESERVE_1M,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: BINANCE_RATE_LIMIT_BUCKETS.papiOrders1m,
|
|
79
|
+
kind: "orders",
|
|
80
|
+
limit: PAPI_ORDERS_LIMIT_1M,
|
|
81
|
+
intervalMs: ONE_MINUTE_MS,
|
|
82
|
+
scope: ["venue", "account"],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
plans: [
|
|
86
|
+
requestWeightPlan(
|
|
87
|
+
BINANCE_RATE_LIMIT_PLANS.spotExchangeInfo,
|
|
88
|
+
BINANCE_RATE_LIMIT_BUCKETS.spotRequestWeight1m,
|
|
89
|
+
20,
|
|
90
|
+
),
|
|
91
|
+
requestWeightPlan(
|
|
92
|
+
BINANCE_RATE_LIMIT_PLANS.fapiExchangeInfo,
|
|
93
|
+
BINANCE_RATE_LIMIT_BUCKETS.fapiRequestWeight1m,
|
|
94
|
+
1,
|
|
95
|
+
),
|
|
96
|
+
requestWeightPlan(
|
|
97
|
+
BINANCE_RATE_LIMIT_PLANS.dapiExchangeInfo,
|
|
98
|
+
BINANCE_RATE_LIMIT_BUCKETS.dapiRequestWeight1m,
|
|
99
|
+
1,
|
|
100
|
+
),
|
|
101
|
+
requestWeightPlan(
|
|
102
|
+
BINANCE_RATE_LIMIT_PLANS.fapiServerTime,
|
|
103
|
+
BINANCE_RATE_LIMIT_BUCKETS.fapiRequestWeight1m,
|
|
104
|
+
1,
|
|
105
|
+
),
|
|
106
|
+
requestWeightPlan(
|
|
107
|
+
BINANCE_RATE_LIMIT_PLANS.papiBalance,
|
|
108
|
+
BINANCE_RATE_LIMIT_BUCKETS.papiRequestWeight1m,
|
|
109
|
+
20,
|
|
110
|
+
),
|
|
111
|
+
requestWeightPlan(
|
|
112
|
+
BINANCE_RATE_LIMIT_PLANS.papiAccount,
|
|
113
|
+
BINANCE_RATE_LIMIT_BUCKETS.papiRequestWeight1m,
|
|
114
|
+
20,
|
|
115
|
+
),
|
|
116
|
+
requestWeightPlan(
|
|
117
|
+
BINANCE_RATE_LIMIT_PLANS.papiPositionRisk,
|
|
118
|
+
BINANCE_RATE_LIMIT_BUCKETS.papiRequestWeight1m,
|
|
119
|
+
5,
|
|
120
|
+
),
|
|
121
|
+
requestWeightPlan(
|
|
122
|
+
BINANCE_RATE_LIMIT_PLANS.papiQueryOrder,
|
|
123
|
+
BINANCE_RATE_LIMIT_BUCKETS.papiRequestWeight1m,
|
|
124
|
+
1,
|
|
125
|
+
),
|
|
126
|
+
requestWeightPlan(
|
|
127
|
+
BINANCE_RATE_LIMIT_PLANS.papiOpenOrdersSymbol,
|
|
128
|
+
BINANCE_RATE_LIMIT_BUCKETS.papiRequestWeight1m,
|
|
129
|
+
1,
|
|
130
|
+
),
|
|
131
|
+
requestWeightPlan(
|
|
132
|
+
BINANCE_RATE_LIMIT_PLANS.papiOpenOrdersAll,
|
|
133
|
+
BINANCE_RATE_LIMIT_BUCKETS.papiRequestWeight1m,
|
|
134
|
+
40,
|
|
135
|
+
),
|
|
136
|
+
{
|
|
137
|
+
id: BINANCE_RATE_LIMIT_PLANS.papiNewOrder,
|
|
138
|
+
costs: [
|
|
139
|
+
{
|
|
140
|
+
bucketId: BINANCE_RATE_LIMIT_BUCKETS.papiRequestWeight1m,
|
|
141
|
+
cost: 0,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
bucketId: BINANCE_RATE_LIMIT_BUCKETS.papiOrders1m,
|
|
145
|
+
cost: 1,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
requestWeightPlan(
|
|
150
|
+
BINANCE_RATE_LIMIT_PLANS.papiCancelOrder,
|
|
151
|
+
BINANCE_RATE_LIMIT_BUCKETS.papiRequestWeight1m,
|
|
152
|
+
1,
|
|
153
|
+
"cancel",
|
|
154
|
+
),
|
|
155
|
+
requestWeightPlan(
|
|
156
|
+
BINANCE_RATE_LIMIT_PLANS.papiCancelAllOrders,
|
|
157
|
+
BINANCE_RATE_LIMIT_BUCKETS.papiRequestWeight1m,
|
|
158
|
+
1,
|
|
159
|
+
"cancel",
|
|
160
|
+
),
|
|
161
|
+
requestWeightPlan(
|
|
162
|
+
BINANCE_RATE_LIMIT_PLANS.papiListenKey,
|
|
163
|
+
BINANCE_RATE_LIMIT_BUCKETS.papiRequestWeight1m,
|
|
164
|
+
1,
|
|
165
|
+
),
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export function registerBinanceRateLimitTopology(
|
|
170
|
+
rateLimiter: RateLimiter | undefined,
|
|
171
|
+
): void {
|
|
172
|
+
const registry = getRateLimitTopologyRegistry(rateLimiter);
|
|
173
|
+
registry?.registerRateLimitTopology(BINANCE_RATE_LIMIT_TOPOLOGY);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function getBinanceCatalogRateLimitPlanId(
|
|
177
|
+
endpointKey: string,
|
|
178
|
+
): string | undefined {
|
|
179
|
+
switch (endpointKey) {
|
|
180
|
+
case "GET /api/v3/exchangeInfo":
|
|
181
|
+
return BINANCE_RATE_LIMIT_PLANS.spotExchangeInfo;
|
|
182
|
+
case "GET /fapi/v1/exchangeInfo":
|
|
183
|
+
return BINANCE_RATE_LIMIT_PLANS.fapiExchangeInfo;
|
|
184
|
+
case "GET /dapi/v1/exchangeInfo":
|
|
185
|
+
return BINANCE_RATE_LIMIT_PLANS.dapiExchangeInfo;
|
|
186
|
+
default:
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function getBinanceServerTimeRateLimitPlanId(): string {
|
|
192
|
+
return BINANCE_RATE_LIMIT_PLANS.fapiServerTime;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function getBinancePapiRateLimitPlanId(
|
|
196
|
+
method: string,
|
|
197
|
+
path: string,
|
|
198
|
+
queryParams?: Record<string, string | undefined>,
|
|
199
|
+
): string | undefined {
|
|
200
|
+
switch (`${method} ${path}`) {
|
|
201
|
+
case "GET /papi/v1/balance":
|
|
202
|
+
return BINANCE_RATE_LIMIT_PLANS.papiBalance;
|
|
203
|
+
case "GET /papi/v1/account":
|
|
204
|
+
return BINANCE_RATE_LIMIT_PLANS.papiAccount;
|
|
205
|
+
case "GET /papi/v1/um/positionRisk":
|
|
206
|
+
return BINANCE_RATE_LIMIT_PLANS.papiPositionRisk;
|
|
207
|
+
case "GET /papi/v1/um/order":
|
|
208
|
+
return BINANCE_RATE_LIMIT_PLANS.papiQueryOrder;
|
|
209
|
+
case "GET /papi/v1/um/openOrders":
|
|
210
|
+
return queryParams?.symbol
|
|
211
|
+
? BINANCE_RATE_LIMIT_PLANS.papiOpenOrdersSymbol
|
|
212
|
+
: BINANCE_RATE_LIMIT_PLANS.papiOpenOrdersAll;
|
|
213
|
+
case "POST /papi/v1/um/order":
|
|
214
|
+
return BINANCE_RATE_LIMIT_PLANS.papiNewOrder;
|
|
215
|
+
case "DELETE /papi/v1/um/order":
|
|
216
|
+
return BINANCE_RATE_LIMIT_PLANS.papiCancelOrder;
|
|
217
|
+
case "DELETE /papi/v1/um/allOpenOrders":
|
|
218
|
+
return BINANCE_RATE_LIMIT_PLANS.papiCancelAllOrders;
|
|
219
|
+
case "POST /papi/v1/listenKey":
|
|
220
|
+
case "PUT /papi/v1/listenKey":
|
|
221
|
+
case "DELETE /papi/v1/listenKey":
|
|
222
|
+
return BINANCE_RATE_LIMIT_PLANS.papiListenKey;
|
|
223
|
+
default:
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function requestWeightPlan(
|
|
229
|
+
id: string,
|
|
230
|
+
bucketId: string,
|
|
231
|
+
cost: number,
|
|
232
|
+
priority?: RateLimitPriority,
|
|
233
|
+
): RateLimitTopology["plans"][number] {
|
|
234
|
+
return {
|
|
235
|
+
id,
|
|
236
|
+
costs: [{ bucketId, cost }],
|
|
237
|
+
priority,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function getRateLimitTopologyRegistry(
|
|
242
|
+
rateLimiter: RateLimiter | undefined,
|
|
243
|
+
): RateLimitTopologyRegistry | undefined {
|
|
244
|
+
if (!rateLimiter) {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const candidate = rateLimiter as RateLimiter &
|
|
249
|
+
Partial<RateLimitTopologyRegistry>;
|
|
250
|
+
return isRateLimitTopologyRegistry(candidate) ? candidate : undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function isRateLimitTopologyRegistry(
|
|
254
|
+
value: RateLimiter & Partial<RateLimitTopologyRegistry>,
|
|
255
|
+
): value is RateLimiter & RateLimitTopologyRegistry {
|
|
256
|
+
return typeof value.registerRateLimitTopology === "function";
|
|
257
|
+
}
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
VenueServerTime,
|
|
10
10
|
} from "../../types/index.ts";
|
|
11
11
|
import { parseBinanceRateLimitUsage } from "./rate-limit.ts";
|
|
12
|
+
import { getBinanceServerTimeRateLimitPlanId } from "./rate-limit-topology.ts";
|
|
12
13
|
|
|
13
14
|
type FetchLike = (
|
|
14
15
|
input: string | URL | Request,
|
|
@@ -43,8 +44,13 @@ export async function fetchBinanceServerTime(
|
|
|
43
44
|
venue: "binance",
|
|
44
45
|
endpointKey: "GET /fapi/v1/time",
|
|
45
46
|
};
|
|
47
|
+
const requestContext = {
|
|
48
|
+
scope,
|
|
49
|
+
planId: getBinanceServerTimeRateLimitPlanId(),
|
|
50
|
+
};
|
|
46
51
|
|
|
47
|
-
|
|
52
|
+
const reservation =
|
|
53
|
+
(await options.rateLimiter?.beforeRequest(requestContext)) ?? undefined;
|
|
48
54
|
|
|
49
55
|
const requestSentAt = now();
|
|
50
56
|
const startMono = monotonicNow();
|
|
@@ -65,14 +71,12 @@ export async function fetchBinanceServerTime(
|
|
|
65
71
|
const responseReceivedAt = now();
|
|
66
72
|
const endMono = monotonicNow();
|
|
67
73
|
|
|
68
|
-
await options.rateLimiter?.afterResponse(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
},
|
|
75
|
-
);
|
|
74
|
+
await options.rateLimiter?.afterResponse(requestContext, {
|
|
75
|
+
status: response.status,
|
|
76
|
+
headers: response.headers,
|
|
77
|
+
usage: parseBinanceRateLimitUsage(response.headers),
|
|
78
|
+
reservation,
|
|
79
|
+
});
|
|
76
80
|
|
|
77
81
|
const { serverTime } = response.body;
|
|
78
82
|
if (typeof serverTime !== "number" || !Number.isFinite(serverTime)) {
|
|
@@ -90,15 +94,13 @@ export async function fetchBinanceServerTime(
|
|
|
90
94
|
};
|
|
91
95
|
} catch (error) {
|
|
92
96
|
if (isTransportError(error)) {
|
|
93
|
-
await options.rateLimiter?.onTransportError(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
},
|
|
101
|
-
);
|
|
97
|
+
await options.rateLimiter?.onTransportError(requestContext, {
|
|
98
|
+
status: error.status,
|
|
99
|
+
headers: error.headers,
|
|
100
|
+
retryAfterMs: error.retryAfterMs,
|
|
101
|
+
usage: parseBinanceRateLimitUsage(error.headers),
|
|
102
|
+
reservation,
|
|
103
|
+
});
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
throw error;
|
package/src/client/runtime.ts
CHANGED
|
@@ -127,7 +127,11 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
127
127
|
constructor(options: CreateClientOptions = {}) {
|
|
128
128
|
activeClients.add(this);
|
|
129
129
|
|
|
130
|
-
const rateLimiter =
|
|
130
|
+
const rateLimiter =
|
|
131
|
+
options.rateLimiter ??
|
|
132
|
+
new ReactiveRateLimiter({
|
|
133
|
+
utilizationTarget: options.rateLimit?.utilizationTarget,
|
|
134
|
+
});
|
|
131
135
|
const marketAdapter = new BinanceMarketAdapter({ rateLimiter });
|
|
132
136
|
this.marketAdapters = new Map([[marketAdapter.venue, marketAdapter]]);
|
|
133
137
|
const privateAdapters = [
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RateLimitBucketSnapshot,
|
|
3
|
+
RateLimitSnapshot,
|
|
4
|
+
} from "../../types/index.ts";
|
|
5
|
+
import { maxOptional, stateSeverity } from "./state.ts";
|
|
6
|
+
|
|
7
|
+
export function aggregateBucketSnapshots(
|
|
8
|
+
base: RateLimitSnapshot,
|
|
9
|
+
buckets: RateLimitBucketSnapshot[],
|
|
10
|
+
): Pick<
|
|
11
|
+
RateLimitSnapshot,
|
|
12
|
+
"blockedUntil" | "retryAfterMs" | "state" | "updatedAt"
|
|
13
|
+
> {
|
|
14
|
+
let selectedBlock: RateLimitBlockedSnapshot | undefined =
|
|
15
|
+
blockCandidate(base);
|
|
16
|
+
let updatedAt = base.updatedAt;
|
|
17
|
+
|
|
18
|
+
for (const bucket of buckets) {
|
|
19
|
+
updatedAt = maxOptional(updatedAt, bucket.updatedAt);
|
|
20
|
+
selectedBlock = selectLaterBlock(selectedBlock, blockCandidate(bucket));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
blockedUntil: selectedBlock?.blockedUntil,
|
|
25
|
+
retryAfterMs: selectedBlock?.retryAfterMs,
|
|
26
|
+
state: selectedBlock?.state ?? base.state,
|
|
27
|
+
updatedAt,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RateLimitBlockedSnapshot {
|
|
32
|
+
blockedUntil: number;
|
|
33
|
+
retryAfterMs?: number;
|
|
34
|
+
state: RateLimitSnapshot["state"];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function blockCandidate(
|
|
38
|
+
snapshot: Pick<RateLimitSnapshot, "blockedUntil" | "retryAfterMs" | "state">,
|
|
39
|
+
): RateLimitBlockedSnapshot | undefined {
|
|
40
|
+
if (snapshot.blockedUntil === undefined) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
blockedUntil: snapshot.blockedUntil,
|
|
45
|
+
retryAfterMs: snapshot.retryAfterMs,
|
|
46
|
+
state: snapshot.state,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function selectLaterBlock(
|
|
51
|
+
current: RateLimitBlockedSnapshot | undefined,
|
|
52
|
+
candidate: RateLimitBlockedSnapshot | undefined,
|
|
53
|
+
): RateLimitBlockedSnapshot | undefined {
|
|
54
|
+
if (!candidate) {
|
|
55
|
+
return current;
|
|
56
|
+
}
|
|
57
|
+
if (!current || candidate.blockedUntil > current.blockedUntil) {
|
|
58
|
+
return candidate;
|
|
59
|
+
}
|
|
60
|
+
if (
|
|
61
|
+
candidate.blockedUntil === current.blockedUntil &&
|
|
62
|
+
stateSeverity(candidate.state) > stateSeverity(current.state)
|
|
63
|
+
) {
|
|
64
|
+
return candidate;
|
|
65
|
+
}
|
|
66
|
+
return current;
|
|
67
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RateLimitBucketDescriptor,
|
|
3
|
+
RateLimitScope,
|
|
4
|
+
RateLimitSnapshot,
|
|
5
|
+
} from "../../types/index.ts";
|
|
6
|
+
|
|
7
|
+
export function scopeKey(scope: RateLimitScope): string {
|
|
8
|
+
return [scope.venue, scope.accountId ?? "", scope.endpointKey].join("\0");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function bucketStateKey(
|
|
12
|
+
scope: RateLimitScope,
|
|
13
|
+
bucket: RateLimitBucketDescriptor,
|
|
14
|
+
): string {
|
|
15
|
+
return [
|
|
16
|
+
bucket.id,
|
|
17
|
+
...bucket.scope.map((dimension) => scopeValue(scope, dimension)),
|
|
18
|
+
].join("\0");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function scopeValue(
|
|
22
|
+
scope: RateLimitScope,
|
|
23
|
+
dimension: RateLimitBucketDescriptor["scope"][number],
|
|
24
|
+
): string {
|
|
25
|
+
switch (dimension) {
|
|
26
|
+
case "venue":
|
|
27
|
+
return scope.venue;
|
|
28
|
+
case "account":
|
|
29
|
+
return scope.accountId ?? "";
|
|
30
|
+
case "endpoint":
|
|
31
|
+
return scope.endpointKey;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function windowStartMs(now: number, intervalMs: number): number {
|
|
36
|
+
return Math.floor(now / intervalMs) * intervalMs;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function windowEndMs(windowStart: number, intervalMs: number): number {
|
|
40
|
+
return windowStart + intervalMs;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function maxOptional(
|
|
44
|
+
left: number | undefined,
|
|
45
|
+
right: number | undefined,
|
|
46
|
+
): number | undefined {
|
|
47
|
+
if (left === undefined) {
|
|
48
|
+
return right;
|
|
49
|
+
}
|
|
50
|
+
if (right === undefined) {
|
|
51
|
+
return left;
|
|
52
|
+
}
|
|
53
|
+
return Math.max(left, right);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function nextRateLimitState(
|
|
57
|
+
existingState: RateLimitSnapshot["state"] | undefined,
|
|
58
|
+
patchState: RateLimitSnapshot["state"] | undefined,
|
|
59
|
+
patchWinsBlock: boolean,
|
|
60
|
+
blockedUntil: number | undefined,
|
|
61
|
+
): RateLimitSnapshot["state"] | undefined {
|
|
62
|
+
if (!patchState) {
|
|
63
|
+
return blockedUntil !== undefined ? (existingState ?? "ok") : existingState;
|
|
64
|
+
}
|
|
65
|
+
if (!patchWinsBlock && existingState) {
|
|
66
|
+
return moreSevereState(existingState, patchState);
|
|
67
|
+
}
|
|
68
|
+
return patchState;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function nextRetryAfterMs<T extends { retryAfterMs?: number }>(
|
|
72
|
+
existing: T | undefined,
|
|
73
|
+
patch: Partial<T>,
|
|
74
|
+
patchWinsBlock: boolean,
|
|
75
|
+
): number | undefined {
|
|
76
|
+
if (patchWinsBlock) {
|
|
77
|
+
return patch.retryAfterMs ?? existing?.retryAfterMs;
|
|
78
|
+
}
|
|
79
|
+
return existing?.retryAfterMs;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function moreSevereState(
|
|
83
|
+
left: RateLimitSnapshot["state"],
|
|
84
|
+
right: RateLimitSnapshot["state"],
|
|
85
|
+
): RateLimitSnapshot["state"] {
|
|
86
|
+
return stateSeverity(left) >= stateSeverity(right) ? left : right;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function stateSeverity(state: RateLimitSnapshot["state"]): number {
|
|
90
|
+
switch (state) {
|
|
91
|
+
case "banned":
|
|
92
|
+
return 2;
|
|
93
|
+
case "rate_limited":
|
|
94
|
+
return 1;
|
|
95
|
+
case "ok":
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RateLimitBucketDescriptor,
|
|
3
|
+
RateLimitPlan,
|
|
4
|
+
} from "../../types/index.ts";
|
|
5
|
+
|
|
6
|
+
export function validateBucketDescriptor(
|
|
7
|
+
bucket: RateLimitBucketDescriptor,
|
|
8
|
+
): void {
|
|
9
|
+
if (!bucket.id) {
|
|
10
|
+
throw new Error("Rate limit bucket descriptor id is required");
|
|
11
|
+
}
|
|
12
|
+
if (!Number.isFinite(bucket.limit) || bucket.limit < 0) {
|
|
13
|
+
throw new Error(`Invalid rate limit bucket limit: ${bucket.id}`);
|
|
14
|
+
}
|
|
15
|
+
if (!Number.isFinite(bucket.intervalMs) || bucket.intervalMs <= 0) {
|
|
16
|
+
throw new Error(`Invalid rate limit bucket interval: ${bucket.id}`);
|
|
17
|
+
}
|
|
18
|
+
if (bucket.reserve) {
|
|
19
|
+
if (!bucket.reserve.priority) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Invalid rate limit bucket reserve priority: ${bucket.id}`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
if (
|
|
25
|
+
!Number.isFinite(bucket.reserve.units) ||
|
|
26
|
+
bucket.reserve.units < 0 ||
|
|
27
|
+
bucket.reserve.units > bucket.limit
|
|
28
|
+
) {
|
|
29
|
+
throw new Error(`Invalid rate limit bucket reserve units: ${bucket.id}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function validatePlan(
|
|
35
|
+
plan: RateLimitPlan,
|
|
36
|
+
buckets: ReadonlyMap<string, RateLimitBucketDescriptor>,
|
|
37
|
+
): void {
|
|
38
|
+
if (!plan.id) {
|
|
39
|
+
throw new Error("Rate limit plan id is required");
|
|
40
|
+
}
|
|
41
|
+
for (const cost of plan.costs) {
|
|
42
|
+
if (!buckets.has(cost.bucketId)) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Rate limit plan ${plan.id} references unknown bucket: ${cost.bucketId}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (!Number.isFinite(cost.cost) || cost.cost < 0) {
|
|
48
|
+
throw new Error(`Invalid rate limit cost for plan: ${plan.id}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function cloneBucketDescriptor(
|
|
54
|
+
bucket: RateLimitBucketDescriptor,
|
|
55
|
+
): RateLimitBucketDescriptor {
|
|
56
|
+
return {
|
|
57
|
+
...bucket,
|
|
58
|
+
reserve: bucket.reserve ? { ...bucket.reserve } : undefined,
|
|
59
|
+
scope: [...bucket.scope],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function clonePlan(plan: RateLimitPlan): RateLimitPlan {
|
|
64
|
+
return {
|
|
65
|
+
...plan,
|
|
66
|
+
costs: plan.costs.map((cost) => ({ ...cost })),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function bucketDescriptorsEqual(
|
|
71
|
+
left: RateLimitBucketDescriptor,
|
|
72
|
+
right: RateLimitBucketDescriptor,
|
|
73
|
+
): boolean {
|
|
74
|
+
return (
|
|
75
|
+
left.id === right.id &&
|
|
76
|
+
left.kind === right.kind &&
|
|
77
|
+
left.limit === right.limit &&
|
|
78
|
+
left.intervalMs === right.intervalMs &&
|
|
79
|
+
left.utilizationTarget === right.utilizationTarget &&
|
|
80
|
+
reservesEqual(left.reserve, right.reserve) &&
|
|
81
|
+
arraysEqual(left.scope, right.scope)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function plansEqual(left: RateLimitPlan, right: RateLimitPlan): boolean {
|
|
86
|
+
if (
|
|
87
|
+
left.id !== right.id ||
|
|
88
|
+
left.priority !== right.priority ||
|
|
89
|
+
left.costs.length !== right.costs.length
|
|
90
|
+
) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return left.costs.every((cost, index) => {
|
|
95
|
+
const other = right.costs[index];
|
|
96
|
+
if (!other) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return cost.bucketId === other.bucketId && cost.cost === other.cost;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean {
|
|
104
|
+
return (
|
|
105
|
+
left.length === right.length &&
|
|
106
|
+
left.every((value, index) => value === right[index])
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function reservesEqual(
|
|
111
|
+
left: RateLimitBucketDescriptor["reserve"],
|
|
112
|
+
right: RateLimitBucketDescriptor["reserve"],
|
|
113
|
+
): boolean {
|
|
114
|
+
if (!left || !right) {
|
|
115
|
+
return left === right;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return left.priority === right.priority && left.units === right.units;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function uniqueStrings(values: readonly string[]): string[] {
|
|
122
|
+
return [...new Set(values)];
|
|
123
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RateLimitPriority,
|
|
3
|
+
RateLimitSnapshot,
|
|
4
|
+
RateLimitUsage,
|
|
5
|
+
} from "../../types/index.ts";
|
|
6
|
+
|
|
7
|
+
export interface ReactiveRateLimiterOptions {
|
|
8
|
+
readonly now?: () => number;
|
|
9
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
10
|
+
readonly random?: () => number;
|
|
11
|
+
readonly defaultRateLimitMs?: number;
|
|
12
|
+
readonly defaultBanMs?: number;
|
|
13
|
+
readonly retryJitterMs?: number;
|
|
14
|
+
readonly utilizationTarget?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface EndpointRateLimitState {
|
|
18
|
+
usage?: RateLimitUsage;
|
|
19
|
+
blockedUntil?: number;
|
|
20
|
+
retryAfterMs?: number;
|
|
21
|
+
banStrikeCount?: number;
|
|
22
|
+
state: RateLimitSnapshot["state"];
|
|
23
|
+
updatedAt?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface BucketRateLimitState {
|
|
27
|
+
used?: number;
|
|
28
|
+
windowStartMs?: number;
|
|
29
|
+
blockedUntil?: number;
|
|
30
|
+
retryAfterMs?: number;
|
|
31
|
+
banStrikeCount?: number;
|
|
32
|
+
state: RateLimitSnapshot["state"];
|
|
33
|
+
updatedAt?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RateLimitReservationBucket {
|
|
37
|
+
bucketId: string;
|
|
38
|
+
stateKey: string;
|
|
39
|
+
cost: number;
|
|
40
|
+
windowStartMs: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BudgetRateLimitReservation {
|
|
44
|
+
readonly __opaqueRateLimitReservation?: never;
|
|
45
|
+
readonly admittedAt: number;
|
|
46
|
+
readonly planId: string;
|
|
47
|
+
readonly priority: RateLimitPriority;
|
|
48
|
+
readonly buckets: readonly RateLimitReservationBucket[];
|
|
49
|
+
}
|