@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.
- package/CHANGELOG.md +12 -0
- package/docs/api.md +105 -7
- 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 +47 -6
- package/src/errors.ts +1 -0
- package/src/internal/async-event-bus.ts +75 -3
- 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/managers/account-manager.ts +43 -16
- package/src/managers/market-manager.ts +111 -7
- package/src/managers/order-manager.ts +42 -16
- package/src/types/account.ts +9 -2
- package/src/types/client.ts +8 -2
- package/src/types/market.ts +19 -4
- package/src/types/order.ts +9 -2
- package/src/types/shared.ts +209 -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
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
buildAcexErrorDetails,
|
|
16
16
|
type VenueErrorReason,
|
|
17
17
|
} from "../errors.ts";
|
|
18
|
+
import type { AsyncEventBusOverflowInfo } from "../internal/async-event-bus.ts";
|
|
18
19
|
import { AsyncEventBus } from "../internal/async-event-bus.ts";
|
|
19
20
|
import { matchesHealthFilter } from "../internal/filters.ts";
|
|
20
21
|
import { ReactiveRateLimiter } from "../internal/rate-limiter.ts";
|
|
@@ -26,6 +27,7 @@ import type {
|
|
|
26
27
|
AccountManager,
|
|
27
28
|
AcexClient,
|
|
28
29
|
AcexInternalError,
|
|
30
|
+
BufferedEventStreamOptions,
|
|
29
31
|
CancelAllOrdersInput,
|
|
30
32
|
CancelOrderInput,
|
|
31
33
|
ClientEventStreams,
|
|
@@ -75,14 +77,30 @@ class ClientEventStreamsImpl implements ClientEventStreams {
|
|
|
75
77
|
constructor(
|
|
76
78
|
private readonly healthBus: AsyncEventBus<HealthEvent>,
|
|
77
79
|
private readonly errorBus: AsyncEventBus<AcexInternalError>,
|
|
80
|
+
private readonly onHealthOverflow: (
|
|
81
|
+
info: AsyncEventBusOverflowInfo,
|
|
82
|
+
) => void,
|
|
78
83
|
) {}
|
|
79
84
|
|
|
80
|
-
errors(
|
|
81
|
-
|
|
85
|
+
errors(
|
|
86
|
+
options?: BufferedEventStreamOptions,
|
|
87
|
+
): AsyncIterable<AcexInternalError> {
|
|
88
|
+
return this.errorBus.stream(() => true, {
|
|
89
|
+
maxBuffer: options?.maxBuffer,
|
|
90
|
+
});
|
|
82
91
|
}
|
|
83
92
|
|
|
84
|
-
health(
|
|
85
|
-
|
|
93
|
+
health(
|
|
94
|
+
filter?: HealthEventFilter,
|
|
95
|
+
options?: BufferedEventStreamOptions,
|
|
96
|
+
): AsyncIterable<HealthEvent> {
|
|
97
|
+
return this.healthBus.stream(
|
|
98
|
+
(event) => matchesHealthFilter(event, filter),
|
|
99
|
+
{
|
|
100
|
+
maxBuffer: options?.maxBuffer,
|
|
101
|
+
onOverflow: this.onHealthOverflow,
|
|
102
|
+
},
|
|
103
|
+
);
|
|
86
104
|
}
|
|
87
105
|
}
|
|
88
106
|
|
|
@@ -109,7 +127,11 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
109
127
|
constructor(options: CreateClientOptions = {}) {
|
|
110
128
|
activeClients.add(this);
|
|
111
129
|
|
|
112
|
-
const rateLimiter =
|
|
130
|
+
const rateLimiter =
|
|
131
|
+
options.rateLimiter ??
|
|
132
|
+
new ReactiveRateLimiter({
|
|
133
|
+
utilizationTarget: options.rateLimit?.utilizationTarget,
|
|
134
|
+
});
|
|
113
135
|
const marketAdapter = new BinanceMarketAdapter({ rateLimiter });
|
|
114
136
|
this.marketAdapters = new Map([[marketAdapter.venue, marketAdapter]]);
|
|
115
137
|
const privateAdapters = [
|
|
@@ -149,7 +171,11 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
149
171
|
this.market = this.marketManager;
|
|
150
172
|
this.account = this.accountManager;
|
|
151
173
|
this.order = this.orderManager;
|
|
152
|
-
this.events = new ClientEventStreamsImpl(
|
|
174
|
+
this.events = new ClientEventStreamsImpl(
|
|
175
|
+
this.healthBus,
|
|
176
|
+
this.errorBus,
|
|
177
|
+
this.createOverflowHandler("client.health"),
|
|
178
|
+
);
|
|
153
179
|
}
|
|
154
180
|
|
|
155
181
|
// --- AcexClient public API ---
|
|
@@ -421,6 +447,21 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
421
447
|
this.healthBus.publish(event);
|
|
422
448
|
}
|
|
423
449
|
|
|
450
|
+
private createOverflowHandler(
|
|
451
|
+
stream: string,
|
|
452
|
+
): (info: AsyncEventBusOverflowInfo) => void {
|
|
453
|
+
return ({ maxBuffer }) => {
|
|
454
|
+
const error = new AcexError(
|
|
455
|
+
"EVENT_BUFFER_OVERFLOW",
|
|
456
|
+
`Event stream buffer overflow: ${stream}`,
|
|
457
|
+
);
|
|
458
|
+
this.publishRuntimeError("runtime", error, {
|
|
459
|
+
stream,
|
|
460
|
+
maxBuffer,
|
|
461
|
+
});
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
424
465
|
// --- Private ---
|
|
425
466
|
|
|
426
467
|
private setClientStatus(status: ClientStatus): void {
|
package/src/errors.ts
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
type EventPredicate<T> = (event: T) => boolean;
|
|
2
2
|
|
|
3
|
+
export type AsyncEventBusStreamMode = "buffer" | "conflate";
|
|
4
|
+
|
|
5
|
+
export interface AsyncEventBusOverflowInfo {
|
|
6
|
+
maxBuffer: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AsyncEventBusStreamOptions<T> {
|
|
10
|
+
mode?: AsyncEventBusStreamMode;
|
|
11
|
+
maxBuffer?: number;
|
|
12
|
+
conflateKey?: (event: T) => string;
|
|
13
|
+
onOverflow?: (info: AsyncEventBusOverflowInfo) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
3
16
|
interface BusListener<T> {
|
|
4
17
|
close(): void;
|
|
5
18
|
dispatch(event: T): void;
|
|
6
19
|
}
|
|
7
20
|
|
|
21
|
+
const DEFAULT_MAX_BUFFER = 10_000;
|
|
22
|
+
|
|
8
23
|
function doneResult<T>(): IteratorResult<T> {
|
|
9
24
|
return { done: true, value: undefined as T };
|
|
10
25
|
}
|
|
@@ -20,11 +35,67 @@ export class AsyncEventBus<T> {
|
|
|
20
35
|
|
|
21
36
|
stream<U extends T = T>(
|
|
22
37
|
filter: ((event: T) => event is U) | EventPredicate<T> = () => true,
|
|
38
|
+
options: AsyncEventBusStreamOptions<U> = {},
|
|
23
39
|
): AsyncIterable<U> {
|
|
24
40
|
let closed = false;
|
|
25
|
-
const
|
|
41
|
+
const mode = options.mode ?? "buffer";
|
|
42
|
+
const maxBuffer = options.maxBuffer ?? DEFAULT_MAX_BUFFER;
|
|
43
|
+
const bufferQueue: U[] = [];
|
|
44
|
+
const conflateQueue =
|
|
45
|
+
mode === "conflate" ? new Map<string, U>() : undefined;
|
|
46
|
+
let overflowNotified = false;
|
|
26
47
|
let pendingResolve: ((result: IteratorResult<U>) => void) | undefined;
|
|
27
48
|
|
|
49
|
+
if (mode === "conflate" && !options.conflateKey) {
|
|
50
|
+
throw new Error("AsyncEventBus conflate mode requires conflateKey");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const resetOverflowIfDrained = () => {
|
|
54
|
+
if (bufferQueue.length === 0) {
|
|
55
|
+
overflowNotified = false;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const enqueue = (event: U) => {
|
|
60
|
+
if (conflateQueue) {
|
|
61
|
+
const key = options.conflateKey?.(event);
|
|
62
|
+
if (key === undefined) {
|
|
63
|
+
throw new Error("AsyncEventBus conflate mode requires conflateKey");
|
|
64
|
+
}
|
|
65
|
+
conflateQueue.set(key, event);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
bufferQueue.push(event);
|
|
70
|
+
|
|
71
|
+
if (bufferQueue.length <= maxBuffer) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
bufferQueue.shift();
|
|
76
|
+
if (!overflowNotified) {
|
|
77
|
+
overflowNotified = true;
|
|
78
|
+
options.onOverflow?.({ maxBuffer });
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const dequeue = (): U | undefined => {
|
|
83
|
+
if (conflateQueue) {
|
|
84
|
+
const first = conflateQueue.entries().next();
|
|
85
|
+
if (first.done) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const [key, event] = first.value;
|
|
90
|
+
conflateQueue.delete(key);
|
|
91
|
+
return event;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const event = bufferQueue.shift();
|
|
95
|
+
resetOverflowIfDrained();
|
|
96
|
+
return event;
|
|
97
|
+
};
|
|
98
|
+
|
|
28
99
|
const close = () => {
|
|
29
100
|
if (closed) {
|
|
30
101
|
return;
|
|
@@ -55,7 +126,7 @@ export class AsyncEventBus<T> {
|
|
|
55
126
|
return;
|
|
56
127
|
}
|
|
57
128
|
|
|
58
|
-
|
|
129
|
+
enqueue(typedEvent);
|
|
59
130
|
},
|
|
60
131
|
};
|
|
61
132
|
|
|
@@ -70,11 +141,12 @@ export class AsyncEventBus<T> {
|
|
|
70
141
|
return doneResult<U>();
|
|
71
142
|
}
|
|
72
143
|
|
|
73
|
-
const queued =
|
|
144
|
+
const queued = dequeue();
|
|
74
145
|
if (queued !== undefined) {
|
|
75
146
|
return { done: false, value: queued };
|
|
76
147
|
}
|
|
77
148
|
|
|
149
|
+
resetOverflowIfDrained();
|
|
78
150
|
return await new Promise<IteratorResult<U>>((resolve) => {
|
|
79
151
|
pendingResolve = resolve;
|
|
80
152
|
});
|
|
@@ -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
|
+
}
|