@imbingox/acex 0.4.0-beta.3 → 0.4.0-beta.4

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/docs/api.md CHANGED
@@ -967,6 +967,8 @@ interface AccountRuntimeOptions {
967
967
 
968
968
  interface CreateClientOptions {
969
969
  sandbox?: boolean; // 预留,当前不生效
970
+ clock?: TimeProvider; // 注入签名/请求时钟,默认本地时钟
971
+ rateLimiter?: RateLimiter; // 注入限流器,默认 reactive(观测 + Retry-After 退避,不主动节流)
970
972
  logger?: Logger; // 预留
971
973
  logLevel?: LogLevel; // 预留
972
974
  market?: MarketRuntimeOptions;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imbingox/acex",
3
- "version": "0.4.0-beta.3",
3
+ "version": "0.4.0-beta.4",
4
4
  "description": "Multi-exchange trading SDK for market data, account, and order management",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,6 +1,7 @@
1
1
  import { SubscriptionMultiplexer } from "../../internal/subscription-multiplexer.ts";
2
2
  import type {
3
3
  MarketDefinition,
4
+ RateLimiter,
4
5
  VenueMarketCapabilities,
5
6
  } from "../../types/index.ts";
6
7
  import type {
@@ -53,8 +54,16 @@ export class BinanceMarketAdapter implements MarketAdapter {
53
54
  private multiplexer: BinanceMarketMultiplexer | undefined;
54
55
  private multiplexerConfig: BinanceMultiplexerConfig | undefined;
55
56
 
57
+ constructor(
58
+ private readonly options: {
59
+ readonly rateLimiter?: RateLimiter;
60
+ } = {},
61
+ ) {}
62
+
56
63
  async loadMarkets(): Promise<MarketDefinition[]> {
57
- const markets = await loadBinanceMarkets();
64
+ const markets = await loadBinanceMarkets(fetch, {
65
+ rateLimiter: this.options.rateLimiter,
66
+ });
58
67
  this.definitions.clear();
59
68
 
60
69
  for (const market of markets) {
@@ -2,8 +2,15 @@ import { toCanonical } from "../../internal/decimal.ts";
2
2
  import {
3
3
  type HttpClientMessages,
4
4
  httpRequest,
5
+ isTransportError,
5
6
  } from "../../internal/http-client.ts";
6
- import type { MarketDefinition, MarketType } from "../../types/index.ts";
7
+ import type {
8
+ MarketDefinition,
9
+ MarketType,
10
+ RateLimiter,
11
+ RateLimitScope,
12
+ } from "../../types/index.ts";
13
+ import { parseBinanceRateLimitUsage } from "./rate-limit.ts";
7
14
 
8
15
  type FetchLike = typeof fetch;
9
16
 
@@ -224,21 +231,55 @@ function normalizeDerivativesSymbol(
224
231
  async function requestCatalogJson<T>(
225
232
  fetchFn: FetchLike,
226
233
  url: string,
234
+ rateLimiter: RateLimiter | undefined,
235
+ endpointKey: string,
227
236
  ): Promise<T> {
228
- const response = await httpRequest<T>({
229
- fetchFn,
230
- url,
231
- timeoutMs: DEFAULT_HTTP_TIMEOUT_MS,
232
- parseAs: "json",
233
- jsonParseMode: "response",
234
- retryPolicy: {
235
- idempotent: true,
236
- maxAttempts: 3,
237
- },
238
- messages: BINANCE_CATALOG_HTTP_MESSAGES,
239
- });
240
-
241
- return response.body;
237
+ const scope: RateLimitScope = {
238
+ venue: "binance",
239
+ endpointKey,
240
+ };
241
+
242
+ await rateLimiter?.beforeRequest({ scope });
243
+
244
+ try {
245
+ const response = await httpRequest<T>({
246
+ fetchFn,
247
+ url,
248
+ timeoutMs: DEFAULT_HTTP_TIMEOUT_MS,
249
+ parseAs: "json",
250
+ jsonParseMode: "response",
251
+ retryPolicy: {
252
+ idempotent: true,
253
+ maxAttempts: 3,
254
+ },
255
+ messages: BINANCE_CATALOG_HTTP_MESSAGES,
256
+ });
257
+
258
+ await rateLimiter?.afterResponse(
259
+ { scope },
260
+ {
261
+ status: response.status,
262
+ headers: response.headers,
263
+ usage: parseBinanceRateLimitUsage(response.headers),
264
+ },
265
+ );
266
+
267
+ return response.body;
268
+ } catch (error) {
269
+ if (isTransportError(error)) {
270
+ await rateLimiter?.onTransportError(
271
+ { scope },
272
+ {
273
+ status: error.status,
274
+ headers: error.headers,
275
+ retryAfterMs: error.retryAfterMs,
276
+ usage: parseBinanceRateLimitUsage(error.headers),
277
+ },
278
+ );
279
+ }
280
+
281
+ throw error;
282
+ }
242
283
  }
243
284
 
244
285
  function sortMarkets(
@@ -251,19 +292,26 @@ function sortMarkets(
251
292
 
252
293
  export async function loadBinanceMarkets(
253
294
  fetchFn: FetchLike = fetch,
295
+ options: { readonly rateLimiter?: RateLimiter } = {},
254
296
  ): Promise<BinanceMarketDefinition[]> {
255
297
  const [spot, usdm, coinm] = await Promise.all([
256
298
  requestCatalogJson<BinanceSpotExchangeInfo>(
257
299
  fetchFn,
258
300
  BINANCE_SPOT_EXCHANGE_INFO_URL,
301
+ options.rateLimiter,
302
+ "GET /api/v3/exchangeInfo",
259
303
  ),
260
304
  requestCatalogJson<BinanceDerivativesExchangeInfo>(
261
305
  fetchFn,
262
306
  BINANCE_USDM_EXCHANGE_INFO_URL,
307
+ options.rateLimiter,
308
+ "GET /fapi/v1/exchangeInfo",
263
309
  ),
264
310
  requestCatalogJson<BinanceDerivativesExchangeInfo>(
265
311
  fetchFn,
266
312
  BINANCE_COINM_EXCHANGE_INFO_URL,
313
+ options.rateLimiter,
314
+ "GET /dapi/v1/exchangeInfo",
267
315
  ),
268
316
  ]);
269
317
 
@@ -4,11 +4,14 @@ import {
4
4
  type HttpClientMessages,
5
5
  type HttpRetryPolicy,
6
6
  httpRequest,
7
+ isTransportError,
7
8
  } from "../../internal/http-client.ts";
8
9
  import { createManagedWebSocket } from "../../internal/managed-websocket.ts";
9
10
  import type {
10
11
  AccountCredentials,
11
12
  PositionSide,
13
+ RateLimiter,
14
+ RateLimitScope,
12
15
  TimeProvider,
13
16
  VenueAccountCapabilities,
14
17
  VenueOrderCapabilities,
@@ -28,6 +31,7 @@ import type {
28
31
  RawRiskUpdate,
29
32
  StreamHandle,
30
33
  } from "../types.ts";
34
+ import { parseBinanceRateLimitUsage } from "./rate-limit.ts";
31
35
 
32
36
  type TimerHandle = ReturnType<typeof setInterval>;
33
37
  type SignedRequestMethod = "GET" | "POST" | "DELETE";
@@ -205,6 +209,14 @@ function getNumberOption(
205
209
  : undefined;
206
210
  }
207
211
 
212
+ function getStringOption(
213
+ options: Record<string, unknown> | undefined,
214
+ key: string,
215
+ ): string | undefined {
216
+ const value = options?.[key];
217
+ return typeof value === "string" && value.length > 0 ? value : undefined;
218
+ }
219
+
208
220
  function signQuery(query: string, secret: string): string {
209
221
  return createHmac("sha256", secret).update(query).digest("hex");
210
222
  }
@@ -590,6 +602,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
590
602
  readonly fetchFn?: FetchLike;
591
603
  readonly httpTimeoutMs?: number;
592
604
  readonly signingClock?: TimeProvider;
605
+ readonly rateLimiter?: RateLimiter;
593
606
  } = {},
594
607
  ) {}
595
608
 
@@ -786,7 +799,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
786
799
  credentials: AccountCredentials,
787
800
  callbacks: PrivateStreamCallbacks,
788
801
  options: PrivateStreamOptions,
789
- _accountOptions?: Record<string, unknown>,
802
+ accountOptions?: Record<string, unknown>,
790
803
  ): StreamHandle {
791
804
  let closed = false;
792
805
  let listenKey: string | undefined;
@@ -808,17 +821,19 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
808
821
 
809
822
  const key = listenKey;
810
823
  listenKey = undefined;
811
- void this.closeUserDataStream(credentials, key).catch((error) => {
812
- callbacks.onError(
813
- error instanceof Error
814
- ? error
815
- : new Error("Failed to close Binance PAPI listenKey"),
816
- );
817
- });
824
+ void this.closeUserDataStream(credentials, key, accountOptions).catch(
825
+ (error) => {
826
+ callbacks.onError(
827
+ error instanceof Error
828
+ ? error
829
+ : new Error("Failed to close Binance PAPI listenKey"),
830
+ );
831
+ },
832
+ );
818
833
  };
819
834
 
820
835
  const ready = (async () => {
821
- listenKey = await this.startUserDataStream(credentials);
836
+ listenKey = await this.startUserDataStream(credentials, accountOptions);
822
837
  if (closed) {
823
838
  closeListenKey();
824
839
  return;
@@ -829,15 +844,17 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
829
844
  return;
830
845
  }
831
846
 
832
- void this.keepAliveUserDataStream(credentials, listenKey).catch(
833
- (error) => {
834
- callbacks.onError(
835
- error instanceof Error
836
- ? error
837
- : new Error("Failed to keep Binance PAPI listenKey alive"),
838
- );
839
- },
840
- );
847
+ void this.keepAliveUserDataStream(
848
+ credentials,
849
+ listenKey,
850
+ accountOptions,
851
+ ).catch((error) => {
852
+ callbacks.onError(
853
+ error instanceof Error
854
+ ? error
855
+ : new Error("Failed to keep Binance PAPI listenKey alive"),
856
+ );
857
+ });
841
858
  }, options.listenKeyKeepAliveMs);
842
859
 
843
860
  websocket = createManagedWebSocket<BinancePrivateMessage>({
@@ -905,6 +922,9 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
905
922
  retryPolicy?: HttpRetryPolicy,
906
923
  ): Promise<T> {
907
924
  const { apiKey, secret } = requirePrivateCredentials(credentials);
925
+ const scope = this.rateLimitScope(method, path, accountOptions);
926
+ await this.options.rateLimiter?.beforeRequest({ scope });
927
+
908
928
  const params = new URLSearchParams();
909
929
  for (const [key, value] of Object.entries(queryParams ?? {})) {
910
930
  if (value !== undefined) {
@@ -927,31 +947,58 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
927
947
 
928
948
  const url = `${BINANCE_PAPI_REST_BASE_URL}${path}?${params.toString()}`;
929
949
  const timeoutMs = this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
930
- const response = await httpRequest<T>({
931
- fetchFn: this.options.fetchFn,
932
- url,
933
- method,
934
- headers: {
935
- "X-MBX-APIKEY": apiKey,
936
- },
937
- timeoutMs,
938
- parseAs: "json",
939
- emptyBody: "empty_object",
940
- retryPolicy: retryPolicy ?? NO_RETRY_POLICY,
941
- messages: getBinancePapiHttpMessages(timeoutMs),
942
- });
950
+ try {
951
+ const response = await httpRequest<T>({
952
+ fetchFn: this.options.fetchFn,
953
+ url,
954
+ method,
955
+ headers: {
956
+ "X-MBX-APIKEY": apiKey,
957
+ },
958
+ timeoutMs,
959
+ parseAs: "json",
960
+ emptyBody: "empty_object",
961
+ retryPolicy: retryPolicy ?? NO_RETRY_POLICY,
962
+ messages: getBinancePapiHttpMessages(timeoutMs),
963
+ });
943
964
 
944
- return response.body;
965
+ await this.options.rateLimiter?.afterResponse(
966
+ { scope },
967
+ {
968
+ status: response.status,
969
+ headers: response.headers,
970
+ usage: parseBinanceRateLimitUsage(response.headers),
971
+ },
972
+ );
973
+
974
+ return response.body;
975
+ } catch (error) {
976
+ if (isTransportError(error)) {
977
+ await this.options.rateLimiter?.onTransportError(
978
+ { scope },
979
+ {
980
+ status: error.status,
981
+ headers: error.headers,
982
+ retryAfterMs: error.retryAfterMs,
983
+ usage: parseBinanceRateLimitUsage(error.headers),
984
+ },
985
+ );
986
+ }
987
+
988
+ throw error;
989
+ }
945
990
  }
946
991
 
947
992
  private async startUserDataStream(
948
993
  credentials: AccountCredentials,
994
+ accountOptions?: Record<string, unknown>,
949
995
  ): Promise<string> {
950
996
  const response = await this.userStreamRequest<BinanceListenKeyResponse>(
951
997
  "POST",
952
998
  credentials,
953
999
  undefined,
954
1000
  NO_RETRY_POLICY,
1001
+ accountOptions,
955
1002
  );
956
1003
  if (!response.listenKey) {
957
1004
  throw new Error("Binance PAPI did not return a listenKey");
@@ -963,24 +1010,28 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
963
1010
  private async keepAliveUserDataStream(
964
1011
  credentials: AccountCredentials,
965
1012
  listenKey: string,
1013
+ accountOptions?: Record<string, unknown>,
966
1014
  ): Promise<void> {
967
1015
  await this.userStreamRequest<Record<string, never>>(
968
1016
  "PUT",
969
1017
  credentials,
970
1018
  listenKey,
971
1019
  LISTEN_KEY_KEEPALIVE_RETRY_POLICY,
1020
+ accountOptions,
972
1021
  );
973
1022
  }
974
1023
 
975
1024
  private async closeUserDataStream(
976
1025
  credentials: AccountCredentials,
977
1026
  listenKey: string,
1027
+ accountOptions?: Record<string, unknown>,
978
1028
  ): Promise<void> {
979
1029
  await this.userStreamRequest<Record<string, never>>(
980
1030
  "DELETE",
981
1031
  credentials,
982
1032
  listenKey,
983
1033
  NO_RETRY_POLICY,
1034
+ accountOptions,
984
1035
  );
985
1036
  }
986
1037
 
@@ -989,8 +1040,16 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
989
1040
  credentials: AccountCredentials,
990
1041
  listenKey?: string,
991
1042
  retryPolicy: HttpRetryPolicy = NO_RETRY_POLICY,
1043
+ accountOptions?: Record<string, unknown>,
992
1044
  ): Promise<T> {
993
1045
  const { apiKey } = requirePrivateCredentials(credentials);
1046
+ const scope = this.rateLimitScope(
1047
+ method,
1048
+ "/papi/v1/listenKey",
1049
+ accountOptions,
1050
+ );
1051
+ await this.options.rateLimiter?.beforeRequest({ scope });
1052
+
994
1053
  const params = new URLSearchParams();
995
1054
  if (listenKey) {
996
1055
  params.set("listenKey", listenKey);
@@ -1001,20 +1060,57 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
1001
1060
  query ? `?${query}` : ""
1002
1061
  }`;
1003
1062
  const timeoutMs = this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
1004
- const response = await httpRequest<T>({
1005
- fetchFn: this.options.fetchFn,
1006
- url,
1007
- method,
1008
- headers: {
1009
- "X-MBX-APIKEY": apiKey,
1010
- },
1011
- timeoutMs,
1012
- parseAs: "json",
1013
- emptyBody: "empty_object",
1014
- retryPolicy,
1015
- messages: getBinancePapiHttpMessages(timeoutMs),
1016
- });
1063
+ try {
1064
+ const response = await httpRequest<T>({
1065
+ fetchFn: this.options.fetchFn,
1066
+ url,
1067
+ method,
1068
+ headers: {
1069
+ "X-MBX-APIKEY": apiKey,
1070
+ },
1071
+ timeoutMs,
1072
+ parseAs: "json",
1073
+ emptyBody: "empty_object",
1074
+ retryPolicy,
1075
+ messages: getBinancePapiHttpMessages(timeoutMs),
1076
+ });
1077
+
1078
+ await this.options.rateLimiter?.afterResponse(
1079
+ { scope },
1080
+ {
1081
+ status: response.status,
1082
+ headers: response.headers,
1083
+ usage: parseBinanceRateLimitUsage(response.headers),
1084
+ },
1085
+ );
1086
+
1087
+ return response.body;
1088
+ } catch (error) {
1089
+ if (isTransportError(error)) {
1090
+ await this.options.rateLimiter?.onTransportError(
1091
+ { scope },
1092
+ {
1093
+ status: error.status,
1094
+ headers: error.headers,
1095
+ retryAfterMs: error.retryAfterMs,
1096
+ usage: parseBinanceRateLimitUsage(error.headers),
1097
+ },
1098
+ );
1099
+ }
1017
1100
 
1018
- return response.body;
1101
+ throw error;
1102
+ }
1103
+ }
1104
+
1105
+ private rateLimitScope(
1106
+ method: string,
1107
+ path: string,
1108
+ accountOptions?: Record<string, unknown>,
1109
+ ): RateLimitScope {
1110
+ return {
1111
+ venue: "binance",
1112
+ accountId: getStringOption(accountOptions, "accountId"),
1113
+ endpointKey: `${method} ${path}`,
1114
+ };
1019
1115
  }
1020
1116
  }
@@ -0,0 +1,47 @@
1
+ import type { RateLimitUsage } from "../../types/index.ts";
2
+
3
+ const USED_WEIGHT_PREFIX = "x-mbx-used-weight-";
4
+ const ORDER_COUNT_PREFIX = "x-mbx-order-count-";
5
+
6
+ export function parseBinanceRateLimitUsage(
7
+ headers: Headers,
8
+ ): RateLimitUsage | undefined {
9
+ const weight: Record<string, number> = {};
10
+ const orderCount: Record<string, number> = {};
11
+
12
+ for (const name of headers.keys()) {
13
+ const normalizedName = name.toLowerCase();
14
+ if (normalizedName.startsWith(USED_WEIGHT_PREFIX)) {
15
+ const interval = normalizedName.slice(USED_WEIGHT_PREFIX.length);
16
+ const value = parseHeaderNumber(headers.get(name));
17
+ if (interval && value !== undefined) {
18
+ weight[interval] = value;
19
+ }
20
+ continue;
21
+ }
22
+
23
+ if (normalizedName.startsWith(ORDER_COUNT_PREFIX)) {
24
+ const interval = normalizedName.slice(ORDER_COUNT_PREFIX.length);
25
+ const value = parseHeaderNumber(headers.get(name));
26
+ if (interval && value !== undefined) {
27
+ orderCount[interval] = value;
28
+ }
29
+ }
30
+ }
31
+
32
+ return Object.keys(weight).length > 0 || Object.keys(orderCount).length > 0
33
+ ? {
34
+ weight: Object.keys(weight).length > 0 ? weight : undefined,
35
+ orderCount: Object.keys(orderCount).length > 0 ? orderCount : undefined,
36
+ }
37
+ : undefined;
38
+ }
39
+
40
+ function parseHeaderNumber(value: string | null): number | undefined {
41
+ if (value === null) {
42
+ return undefined;
43
+ }
44
+
45
+ const parsed = Number(value);
46
+ return Number.isFinite(parsed) ? parsed : undefined;
47
+ }
@@ -595,7 +595,7 @@ export class PrivateSubscriptionCoordinator {
595
595
  juplendPollIntervalMs: this.juplendPollIntervalMs,
596
596
  now: () => this.context.now(),
597
597
  },
598
- account.options,
598
+ { ...account.options, accountId: account.accountId },
599
599
  );
600
600
 
601
601
  record.stream = stream;
@@ -719,7 +719,7 @@ export class PrivateSubscriptionCoordinator {
719
719
  try {
720
720
  const snapshots = await this.getAdapter(record.venue).bootstrapOpenOrders(
721
721
  account.credentials ?? {},
722
- account.options,
722
+ { ...account.options, accountId: account.accountId },
723
723
  );
724
724
  if (!record.ordersSubscribed) {
725
725
  return;
@@ -12,6 +12,7 @@ import type {
12
12
  import { AcexError, type AcexErrorCode } from "../errors.ts";
13
13
  import { AsyncEventBus } from "../internal/async-event-bus.ts";
14
14
  import { matchesHealthFilter } from "../internal/filters.ts";
15
+ import { ReactiveRateLimiter } from "../internal/rate-limiter.ts";
15
16
  import { AccountManagerImpl } from "../managers/account-manager.ts";
16
17
  import { MarketManagerImpl } from "../managers/market-manager.ts";
17
18
  import { OrderManagerImpl } from "../managers/order-manager.ts";
@@ -102,11 +103,13 @@ export class AcexClientImpl implements AcexClient, ClientContext {
102
103
  constructor(options: CreateClientOptions = {}) {
103
104
  activeClients.add(this);
104
105
 
105
- const marketAdapter = new BinanceMarketAdapter();
106
+ const rateLimiter = options.rateLimiter ?? new ReactiveRateLimiter();
107
+ const marketAdapter = new BinanceMarketAdapter({ rateLimiter });
106
108
  this.marketAdapters = new Map([[marketAdapter.venue, marketAdapter]]);
107
109
  const privateAdapters = [
108
110
  new BinancePrivateAdapter({
109
111
  signingClock: options.clock,
112
+ rateLimiter,
110
113
  }),
111
114
  new JuplendPrivateAdapter(
112
115
  options.account?.juplend?.rpcUrl,
@@ -341,7 +344,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
341
344
  return this.getPrivateAdapter(account.venue).createOrder(
342
345
  account.credentials ?? {},
343
346
  request,
344
- account.options,
347
+ { ...account.options, accountId: account.accountId },
345
348
  );
346
349
  }
347
350
 
@@ -356,7 +359,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
356
359
  return this.getPrivateAdapter(account.venue).cancelOrder(
357
360
  account.credentials ?? {},
358
361
  request,
359
- account.options,
362
+ { ...account.options, accountId: account.accountId },
360
363
  );
361
364
  }
362
365
 
@@ -369,7 +372,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
369
372
  return this.getPrivateAdapter(account.venue).cancelAllOrders(
370
373
  account.credentials ?? {},
371
374
  request,
372
- account.options,
375
+ { ...account.options, accountId: account.accountId },
373
376
  );
374
377
  }
375
378
 
@@ -0,0 +1,181 @@
1
+ import type {
2
+ RateLimiter,
3
+ RateLimitRequestContext,
4
+ RateLimitResponseContext,
5
+ RateLimitScope,
6
+ RateLimitSnapshot,
7
+ RateLimitTransportErrorContext,
8
+ RateLimitUsage,
9
+ } from "../types/index.ts";
10
+
11
+ interface ReactiveRateLimiterOptions {
12
+ readonly now?: () => number;
13
+ readonly sleep?: (ms: number) => Promise<void>;
14
+ readonly defaultRateLimitMs?: number;
15
+ readonly defaultBanMs?: number;
16
+ }
17
+
18
+ interface RateLimitState {
19
+ usage?: RateLimitUsage;
20
+ blockedUntil?: number;
21
+ retryAfterMs?: number;
22
+ state: RateLimitSnapshot["state"];
23
+ updatedAt?: number;
24
+ }
25
+
26
+ const DEFAULT_RATE_LIMIT_MS = 0;
27
+ const DEFAULT_BAN_MS = 60_000;
28
+
29
+ export class ReactiveRateLimiter implements RateLimiter {
30
+ private readonly now: () => number;
31
+ private readonly sleep: (ms: number) => Promise<void>;
32
+ private readonly defaultRateLimitMs: number;
33
+ private readonly defaultBanMs: number;
34
+ private readonly states = new Map<string, RateLimitState>();
35
+
36
+ constructor(options: ReactiveRateLimiterOptions = {}) {
37
+ this.now = options.now ?? Date.now;
38
+ this.sleep = options.sleep ?? defaultSleep;
39
+ this.defaultRateLimitMs =
40
+ options.defaultRateLimitMs ?? DEFAULT_RATE_LIMIT_MS;
41
+ this.defaultBanMs = options.defaultBanMs ?? DEFAULT_BAN_MS;
42
+ }
43
+
44
+ async beforeRequest(ctx: RateLimitRequestContext): Promise<void> {
45
+ const snapshot = this.getSnapshot(ctx.scope);
46
+ if (!snapshot?.blockedUntil || snapshot.blockedUntil <= this.now()) {
47
+ return;
48
+ }
49
+
50
+ await this.sleep(Math.max(0, snapshot.blockedUntil - this.now()));
51
+ }
52
+
53
+ afterResponse(
54
+ ctx: RateLimitRequestContext,
55
+ response: RateLimitResponseContext,
56
+ ): void {
57
+ if (response.usage) {
58
+ const existing = this.getState(ctx.scope);
59
+ const hasActiveBlock =
60
+ existing?.blockedUntil !== undefined &&
61
+ existing.blockedUntil > this.now();
62
+ this.updateState(ctx.scope, {
63
+ usage: cloneUsage(response.usage),
64
+ state: hasActiveBlock ? existing.state : "ok",
65
+ });
66
+ }
67
+ }
68
+
69
+ onTransportError(
70
+ ctx: RateLimitRequestContext,
71
+ error: RateLimitTransportErrorContext,
72
+ ): void {
73
+ if (error.usage) {
74
+ this.updateState(ctx.scope, {
75
+ usage: cloneUsage(error.usage),
76
+ });
77
+ }
78
+
79
+ if (error.status !== 429 && error.status !== 418) {
80
+ return;
81
+ }
82
+
83
+ const now = this.now();
84
+ 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;
92
+
93
+ this.updateState(ctx.scope, {
94
+ blockedUntil,
95
+ retryAfterMs,
96
+ state: isBan ? "banned" : "rate_limited",
97
+ });
98
+ }
99
+
100
+ getSnapshot(scope: RateLimitScope): RateLimitSnapshot | undefined {
101
+ const state = this.getState(scope);
102
+ if (!state) {
103
+ return undefined;
104
+ }
105
+
106
+ const now = this.now();
107
+ const blockedUntil =
108
+ state.blockedUntil !== undefined && state.blockedUntil > now
109
+ ? state.blockedUntil
110
+ : undefined;
111
+ const runtimeState =
112
+ blockedUntil === undefined && state.state !== "ok" ? "ok" : state.state;
113
+
114
+ return {
115
+ scope: { ...scope },
116
+ usage: state.usage ? cloneUsage(state.usage) : undefined,
117
+ blockedUntil,
118
+ retryAfterMs: blockedUntil ? state.retryAfterMs : undefined,
119
+ state: runtimeState,
120
+ updatedAt: state.updatedAt,
121
+ };
122
+ }
123
+
124
+ private getState(scope: RateLimitScope): RateLimitState | undefined {
125
+ return this.states.get(scopeKey(scope));
126
+ }
127
+
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);
142
+
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
+ });
150
+ }
151
+ }
152
+
153
+ function scopeKey(scope: RateLimitScope): string {
154
+ return [scope.venue, scope.accountId ?? "", scope.endpointKey].join("\0");
155
+ }
156
+
157
+ function cloneUsage(usage: RateLimitUsage): RateLimitUsage {
158
+ return {
159
+ weight: usage.weight ? { ...usage.weight } : undefined,
160
+ orderCount: usage.orderCount ? { ...usage.orderCount } : undefined,
161
+ };
162
+ }
163
+
164
+ function maxOptional(
165
+ left: number | undefined,
166
+ right: number | undefined,
167
+ ): number | undefined {
168
+ if (left === undefined) {
169
+ return right;
170
+ }
171
+ if (right === undefined) {
172
+ return left;
173
+ }
174
+ return Math.max(left, right);
175
+ }
176
+
177
+ function defaultSleep(ms: number): Promise<void> {
178
+ return new Promise((resolve) => {
179
+ setTimeout(resolve, ms);
180
+ });
181
+ }
@@ -29,6 +29,58 @@ export interface TimeProvider {
29
29
  now(): number;
30
30
  }
31
31
 
32
+ export interface RateLimitScope {
33
+ venue: Venue;
34
+ accountId?: string;
35
+ endpointKey: string;
36
+ }
37
+
38
+ export interface RateLimitUsage {
39
+ /** Exchange request-weight usage by interval key, e.g. "1m". */
40
+ weight?: Record<string, number>;
41
+ /** Exchange order-count usage by interval key, separate from request weight. */
42
+ orderCount?: Record<string, number>;
43
+ }
44
+
45
+ export interface RateLimitRequestContext {
46
+ scope: RateLimitScope;
47
+ }
48
+
49
+ export interface RateLimitResponseContext {
50
+ status: number;
51
+ headers?: Headers;
52
+ usage?: RateLimitUsage;
53
+ }
54
+
55
+ export interface RateLimitTransportErrorContext {
56
+ status?: number;
57
+ headers?: Headers;
58
+ retryAfterMs?: number;
59
+ usage?: RateLimitUsage;
60
+ }
61
+
62
+ export interface RateLimitSnapshot {
63
+ scope: RateLimitScope;
64
+ usage?: RateLimitUsage;
65
+ blockedUntil?: number;
66
+ retryAfterMs?: number;
67
+ state: "ok" | "rate_limited" | "banned";
68
+ updatedAt?: number;
69
+ }
70
+
71
+ export interface RateLimiter {
72
+ beforeRequest(ctx: RateLimitRequestContext): Promise<void> | void;
73
+ afterResponse(
74
+ ctx: RateLimitRequestContext,
75
+ response: RateLimitResponseContext,
76
+ ): Promise<void> | void;
77
+ onTransportError(
78
+ ctx: RateLimitRequestContext,
79
+ error: RateLimitTransportErrorContext,
80
+ ): Promise<void> | void;
81
+ getSnapshot(scope: RateLimitScope): RateLimitSnapshot | undefined;
82
+ }
83
+
32
84
  export interface MarketRuntimeOptions {
33
85
  l1InitialMessageTimeoutMs?: number;
34
86
  l1StaleAfterMs?: number;
@@ -55,6 +107,7 @@ export interface CreateClientOptions {
55
107
  sandbox?: boolean;
56
108
  /** Request/signing clock; local receivedAt/freshness clocks stay independent. */
57
109
  clock?: TimeProvider;
110
+ rateLimiter?: RateLimiter;
58
111
  logger?: Logger;
59
112
  logLevel?: LogLevel;
60
113
  market?: MarketRuntimeOptions;