@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.
@@ -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
- await options.rateLimiter?.beforeRequest({ scope });
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
- { scope },
70
- {
71
- status: response.status,
72
- headers: response.headers,
73
- usage: parseBinanceRateLimitUsage(response.headers),
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
- { scope },
95
- {
96
- status: error.status,
97
- headers: error.headers,
98
- retryAfterMs: error.retryAfterMs,
99
- usage: parseBinanceRateLimitUsage(error.headers),
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;
@@ -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(): AsyncIterable<AcexInternalError> {
81
- return this.errorBus.stream();
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(filter?: HealthEventFilter): AsyncIterable<HealthEvent> {
85
- return this.healthBus.stream((event) => matchesHealthFilter(event, filter));
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 = options.rateLimiter ?? new ReactiveRateLimiter();
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(this.healthBus, this.errorBus);
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
@@ -7,6 +7,7 @@ export type AcexErrorCode =
7
7
  | "ACCOUNT_NOT_FOUND"
8
8
  | "CLIENT_NOT_STARTED"
9
9
  | "CREDENTIALS_MISSING"
10
+ | "EVENT_BUFFER_OVERFLOW"
10
11
  | "VENUE_NOT_SUPPORTED"
11
12
  | "MARKET_CATALOG_LOAD_FAILED"
12
13
  | "MARKET_SERVER_TIME_FETCH_FAILED"
@@ -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 queue: U[] = [];
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
- queue.push(typedEvent);
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 = queue.shift();
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
+ }