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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imbingox/acex",
3
- "version": "0.4.0-beta.1",
3
+ "version": "0.4.0-beta.3",
4
4
  "description": "Multi-exchange trading SDK for market data, account, and order management",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,4 +1,8 @@
1
1
  import { toCanonical } from "../../internal/decimal.ts";
2
+ import {
3
+ type HttpClientMessages,
4
+ httpRequest,
5
+ } from "../../internal/http-client.ts";
2
6
  import type { MarketDefinition, MarketType } from "../../types/index.ts";
3
7
 
4
8
  type FetchLike = typeof fetch;
@@ -54,6 +58,11 @@ const BINANCE_USDM_EXCHANGE_INFO_URL =
54
58
  "https://fapi.binance.com/fapi/v1/exchangeInfo";
55
59
  const BINANCE_COINM_EXCHANGE_INFO_URL =
56
60
  "https://dapi.binance.com/dapi/v1/exchangeInfo";
61
+ const DEFAULT_HTTP_TIMEOUT_MS = 10_000;
62
+ const BINANCE_CATALOG_HTTP_MESSAGES: HttpClientMessages = {
63
+ http: ({ status, statusText }) =>
64
+ `Binance request failed: ${status} ${statusText ?? ""}`,
65
+ };
57
66
 
58
67
  function toRecord(value: unknown): Record<string, unknown> {
59
68
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -212,15 +221,24 @@ function normalizeDerivativesSymbol(
212
221
  };
213
222
  }
214
223
 
215
- async function fetchJson<T>(fetchFn: FetchLike, url: string): Promise<T> {
216
- const response = await fetchFn(url);
217
- if (!response.ok) {
218
- throw new Error(
219
- `Binance request failed: ${response.status} ${response.statusText}`,
220
- );
221
- }
224
+ async function requestCatalogJson<T>(
225
+ fetchFn: FetchLike,
226
+ url: string,
227
+ ): 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
+ });
222
240
 
223
- return (await response.json()) as T;
241
+ return response.body;
224
242
  }
225
243
 
226
244
  function sortMarkets(
@@ -235,12 +253,15 @@ export async function loadBinanceMarkets(
235
253
  fetchFn: FetchLike = fetch,
236
254
  ): Promise<BinanceMarketDefinition[]> {
237
255
  const [spot, usdm, coinm] = await Promise.all([
238
- fetchJson<BinanceSpotExchangeInfo>(fetchFn, BINANCE_SPOT_EXCHANGE_INFO_URL),
239
- fetchJson<BinanceDerivativesExchangeInfo>(
256
+ requestCatalogJson<BinanceSpotExchangeInfo>(
257
+ fetchFn,
258
+ BINANCE_SPOT_EXCHANGE_INFO_URL,
259
+ ),
260
+ requestCatalogJson<BinanceDerivativesExchangeInfo>(
240
261
  fetchFn,
241
262
  BINANCE_USDM_EXCHANGE_INFO_URL,
242
263
  ),
243
- fetchJson<BinanceDerivativesExchangeInfo>(
264
+ requestCatalogJson<BinanceDerivativesExchangeInfo>(
244
265
  fetchFn,
245
266
  BINANCE_COINM_EXCHANGE_INFO_URL,
246
267
  ),
@@ -1,9 +1,15 @@
1
1
  import { createHmac } from "node:crypto";
2
2
  import BigNumber from "bignumber.js";
3
+ import {
4
+ type HttpClientMessages,
5
+ type HttpRetryPolicy,
6
+ httpRequest,
7
+ } from "../../internal/http-client.ts";
3
8
  import { createManagedWebSocket } from "../../internal/managed-websocket.ts";
4
9
  import type {
5
10
  AccountCredentials,
6
11
  PositionSide,
12
+ TimeProvider,
7
13
  VenueAccountCapabilities,
8
14
  VenueOrderCapabilities,
9
15
  } from "../../types/index.ts";
@@ -25,6 +31,7 @@ import type {
25
31
 
26
32
  type TimerHandle = ReturnType<typeof setInterval>;
27
33
  type SignedRequestMethod = "GET" | "POST" | "DELETE";
34
+ type FetchLike = typeof fetch;
28
35
 
29
36
  interface BinancePapiBalance {
30
37
  asset?: string;
@@ -144,7 +151,31 @@ type BinancePrivateMessage =
144
151
  const BINANCE_PAPI_REST_BASE_URL = "https://papi.binance.com";
145
152
  const BINANCE_PAPI_WS_BASE_URL = "wss://fstream.binance.com/pm/ws";
146
153
  const DEFAULT_RECV_WINDOW = 5_000;
154
+ const DEFAULT_HTTP_TIMEOUT_MS = 10_000;
147
155
  const USDM_QUOTE_ASSETS = ["FDUSD", "USDC", "BUSD", "USDT"];
156
+ const SAFE_READ_RETRY_POLICY: HttpRetryPolicy = {
157
+ idempotent: true,
158
+ maxAttempts: 3,
159
+ };
160
+ const NO_RETRY_POLICY: HttpRetryPolicy = {
161
+ idempotent: false,
162
+ maxAttempts: 1,
163
+ };
164
+ const LISTEN_KEY_KEEPALIVE_RETRY_POLICY: HttpRetryPolicy = {
165
+ idempotent: true,
166
+ maxAttempts: 3,
167
+ };
168
+ function getBinancePapiHttpMessages(timeoutMs: number): HttpClientMessages {
169
+ return {
170
+ http: ({ status, statusText, url, rawBody }) =>
171
+ `Binance PAPI request failed: ${status} ${statusText ?? ""} ${url}${
172
+ rawBody ? ` ${rawBody}` : ""
173
+ }`,
174
+ timeout: () => `Binance PAPI fetch timeout after ${timeoutMs}ms`,
175
+ aborted: () => "Binance PAPI fetch aborted",
176
+ parse: ({ url }) => `Binance PAPI response parse failed: ${url}`,
177
+ };
178
+ }
148
179
 
149
180
  function requirePrivateCredentials(credentials: AccountCredentials): {
150
181
  apiKey: string;
@@ -521,21 +552,6 @@ function mapOrderUpdate(
521
552
  };
522
553
  }
523
554
 
524
- async function readJson<T>(response: Response, url: string): Promise<T> {
525
- const text = await response.text();
526
- if (!response.ok) {
527
- throw new Error(
528
- `Binance PAPI request failed: ${response.status} ${response.statusText} ${url} ${text}`,
529
- );
530
- }
531
-
532
- if (!text) {
533
- return {} as T;
534
- }
535
-
536
- return JSON.parse(text) as T;
537
- }
538
-
539
555
  export class BinancePrivateAdapter implements PrivateUserDataAdapter {
540
556
  readonly venue = "binance" as const;
541
557
  readonly readOnly = false;
@@ -569,6 +585,14 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
569
585
  clientOrderId: true,
570
586
  };
571
587
 
588
+ constructor(
589
+ private readonly options: {
590
+ readonly fetchFn?: FetchLike;
591
+ readonly httpTimeoutMs?: number;
592
+ readonly signingClock?: TimeProvider;
593
+ } = {},
594
+ ) {}
595
+
572
596
  async bootstrapAccount(
573
597
  credentials: AccountCredentials,
574
598
  accountOptions?: Record<string, unknown>,
@@ -580,18 +604,24 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
580
604
  "/papi/v1/balance",
581
605
  credentials,
582
606
  accountOptions,
607
+ undefined,
608
+ SAFE_READ_RETRY_POLICY,
583
609
  ),
584
610
  this.signedRequest<BinancePapiAccount>(
585
611
  "GET",
586
612
  "/papi/v1/account",
587
613
  credentials,
588
614
  accountOptions,
615
+ undefined,
616
+ SAFE_READ_RETRY_POLICY,
589
617
  ),
590
618
  this.signedRequest<BinancePapiUmPosition[]>(
591
619
  "GET",
592
620
  "/papi/v1/um/positionRisk",
593
621
  credentials,
594
622
  accountOptions,
623
+ undefined,
624
+ SAFE_READ_RETRY_POLICY,
595
625
  ),
596
626
  ]);
597
627
 
@@ -621,12 +651,16 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
621
651
  "/papi/v1/account",
622
652
  credentials,
623
653
  accountOptions,
654
+ undefined,
655
+ SAFE_READ_RETRY_POLICY,
624
656
  ),
625
657
  this.signedRequest<BinancePapiUmPosition[]>(
626
658
  "GET",
627
659
  "/papi/v1/um/positionRisk",
628
660
  credentials,
629
661
  accountOptions,
662
+ undefined,
663
+ SAFE_READ_RETRY_POLICY,
630
664
  ),
631
665
  ]);
632
666
 
@@ -643,6 +677,8 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
643
677
  "/papi/v1/um/openOrders",
644
678
  credentials,
645
679
  accountOptions,
680
+ undefined,
681
+ SAFE_READ_RETRY_POLICY,
646
682
  );
647
683
 
648
684
  return orders.flatMap((order) => {
@@ -681,6 +717,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
681
717
  : `${request.reduceOnly}`,
682
718
  positionSide: encodePositionSide(request.positionSide),
683
719
  },
720
+ NO_RETRY_POLICY,
684
721
  );
685
722
 
686
723
  const mapped = mapOpenOrder(response, receivedAt);
@@ -709,6 +746,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
709
746
  orderId: request.orderId,
710
747
  origClientOrderId: request.clientOrderId,
711
748
  },
749
+ NO_RETRY_POLICY,
712
750
  );
713
751
 
714
752
  const mapped = mapOpenOrder(response, receivedAt);
@@ -735,6 +773,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
735
773
  {
736
774
  symbol: encodeUmSymbol(request.symbol),
737
775
  },
776
+ NO_RETRY_POLICY,
738
777
  );
739
778
 
740
779
  return responses.flatMap((response) => {
@@ -863,6 +902,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
863
902
  credentials: AccountCredentials,
864
903
  accountOptions?: Record<string, unknown>,
865
904
  queryParams?: Record<string, string | undefined>,
905
+ retryPolicy?: HttpRetryPolicy,
866
906
  ): Promise<T> {
867
907
  const { apiKey, secret } = requirePrivateCredentials(credentials);
868
908
  const params = new URLSearchParams();
@@ -873,7 +913,11 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
873
913
  }
874
914
  params.set(
875
915
  "timestamp",
876
- `${getNumberOption(accountOptions, "timestamp") ?? Date.now()}`,
916
+ `${
917
+ getNumberOption(accountOptions, "timestamp") ??
918
+ this.options.signingClock?.now() ??
919
+ Date.now()
920
+ }`,
877
921
  );
878
922
  params.set(
879
923
  "recvWindow",
@@ -882,14 +926,22 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
882
926
  params.set("signature", signQuery(params.toString(), secret));
883
927
 
884
928
  const url = `${BINANCE_PAPI_REST_BASE_URL}${path}?${params.toString()}`;
885
- const response = await fetch(url, {
929
+ const timeoutMs = this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
930
+ const response = await httpRequest<T>({
931
+ fetchFn: this.options.fetchFn,
932
+ url,
886
933
  method,
887
934
  headers: {
888
935
  "X-MBX-APIKEY": apiKey,
889
936
  },
937
+ timeoutMs,
938
+ parseAs: "json",
939
+ emptyBody: "empty_object",
940
+ retryPolicy: retryPolicy ?? NO_RETRY_POLICY,
941
+ messages: getBinancePapiHttpMessages(timeoutMs),
890
942
  });
891
943
 
892
- return readJson<T>(response, url);
944
+ return response.body;
893
945
  }
894
946
 
895
947
  private async startUserDataStream(
@@ -898,6 +950,8 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
898
950
  const response = await this.userStreamRequest<BinanceListenKeyResponse>(
899
951
  "POST",
900
952
  credentials,
953
+ undefined,
954
+ NO_RETRY_POLICY,
901
955
  );
902
956
  if (!response.listenKey) {
903
957
  throw new Error("Binance PAPI did not return a listenKey");
@@ -914,6 +968,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
914
968
  "PUT",
915
969
  credentials,
916
970
  listenKey,
971
+ LISTEN_KEY_KEEPALIVE_RETRY_POLICY,
917
972
  );
918
973
  }
919
974
 
@@ -925,6 +980,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
925
980
  "DELETE",
926
981
  credentials,
927
982
  listenKey,
983
+ NO_RETRY_POLICY,
928
984
  );
929
985
  }
930
986
 
@@ -932,6 +988,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
932
988
  method: "POST" | "PUT" | "DELETE",
933
989
  credentials: AccountCredentials,
934
990
  listenKey?: string,
991
+ retryPolicy: HttpRetryPolicy = NO_RETRY_POLICY,
935
992
  ): Promise<T> {
936
993
  const { apiKey } = requirePrivateCredentials(credentials);
937
994
  const params = new URLSearchParams();
@@ -943,13 +1000,21 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
943
1000
  const url = `${BINANCE_PAPI_REST_BASE_URL}/papi/v1/listenKey${
944
1001
  query ? `?${query}` : ""
945
1002
  }`;
946
- const response = await fetch(url, {
1003
+ const timeoutMs = this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
1004
+ const response = await httpRequest<T>({
1005
+ fetchFn: this.options.fetchFn,
1006
+ url,
947
1007
  method,
948
1008
  headers: {
949
1009
  "X-MBX-APIKEY": apiKey,
950
1010
  },
1011
+ timeoutMs,
1012
+ parseAs: "json",
1013
+ emptyBody: "empty_object",
1014
+ retryPolicy,
1015
+ messages: getBinancePapiHttpMessages(timeoutMs),
951
1016
  });
952
1017
 
953
- return readJson<T>(response, url);
1018
+ return response.body;
954
1019
  }
955
1020
  }
@@ -1,5 +1,9 @@
1
1
  import BigNumber from "bignumber.js";
2
2
  import { AcexError } from "../../errors.ts";
3
+ import {
4
+ type HttpClientMessages,
5
+ httpRequest,
6
+ } from "../../internal/http-client.ts";
3
7
  import type {
4
8
  AccountCredentials,
5
9
  VenueAccountCapabilities,
@@ -66,6 +70,8 @@ interface JuplendPriceApiEntry {
66
70
  decimals?: number | string;
67
71
  }
68
72
 
73
+ type FetchLike = typeof fetch;
74
+
69
75
  interface JuplendTokenSearchEntry {
70
76
  id?: string;
71
77
  address?: string;
@@ -86,6 +92,13 @@ const DEFAULT_HTTP_TIMEOUT_MS = 10_000;
86
92
  // not mint-atomic token amounts.
87
93
  const POSITION_AMOUNT_SCALE_DECIMALS = 9;
88
94
  const VAULT_CACHE_TTL_MS = 60 * 60 * 1_000;
95
+ function getJuplendHttpMessages(timeoutMs: number): HttpClientMessages {
96
+ return {
97
+ http: ({ status, statusText }) => `Juplend HTTP ${status}: ${statusText}`,
98
+ timeout: () => `Juplend fetch timeout after ${timeoutMs}ms`,
99
+ aborted: () => "Juplend fetch aborted",
100
+ };
101
+ }
89
102
 
90
103
  interface JuplendVaultEnrichmentCacheEntry {
91
104
  loadedAt: number;
@@ -260,52 +273,30 @@ function buildRisk(input: {
260
273
  };
261
274
  }
262
275
 
263
- async function readJson<T>(url: string, init?: RequestInit): Promise<T> {
264
- const controller = new AbortController();
265
- const upstreamSignal = init?.signal;
266
- let timedOut = false;
267
- const onUpstreamAbort = () => {
268
- controller.abort();
269
- };
270
-
271
- if (upstreamSignal?.aborted) {
272
- controller.abort();
273
- } else if (upstreamSignal) {
274
- upstreamSignal.addEventListener("abort", onUpstreamAbort, { once: true });
275
- }
276
-
277
- const timeout = setTimeout(() => {
278
- timedOut = true;
279
- controller.abort();
280
- }, DEFAULT_HTTP_TIMEOUT_MS);
281
-
282
- try {
283
- const response = await fetch(url, {
284
- ...init,
285
- signal: controller.signal,
286
- });
287
- if (!response.ok) {
288
- throw new Error(
289
- `Juplend HTTP ${response.status}: ${response.statusText}`,
290
- );
291
- }
276
+ async function requestJuplendJson<T>(
277
+ url: string,
278
+ init: RequestInit | undefined,
279
+ fetchFn: FetchLike | undefined,
280
+ timeoutMs: number,
281
+ ): Promise<T> {
282
+ const response = await httpRequest<T>({
283
+ fetchFn,
284
+ url,
285
+ method: init?.method,
286
+ headers: init?.headers,
287
+ body: init?.body,
288
+ signal: init?.signal ?? undefined,
289
+ timeoutMs,
290
+ parseAs: "json",
291
+ jsonParseMode: "response",
292
+ retryPolicy: {
293
+ idempotent: true,
294
+ maxAttempts: 3,
295
+ },
296
+ messages: getJuplendHttpMessages(timeoutMs),
297
+ });
292
298
 
293
- return (await response.json()) as T;
294
- } catch (error) {
295
- if (error instanceof Error && error.name === "AbortError") {
296
- throw new Error(
297
- timedOut
298
- ? `Juplend fetch timeout after ${DEFAULT_HTTP_TIMEOUT_MS}ms`
299
- : "Juplend fetch aborted",
300
- );
301
- }
302
- throw error;
303
- } finally {
304
- clearTimeout(timeout);
305
- if (upstreamSignal) {
306
- upstreamSignal.removeEventListener("abort", onUpstreamAbort);
307
- }
308
- }
299
+ return response.body;
309
300
  }
310
301
 
311
302
  function getJupApiKey(explicitApiKey?: string): string | undefined {
@@ -326,12 +317,19 @@ function withBaseUrl(baseUrl: string, path: string): string {
326
317
 
327
318
  async function loadVaultMetadataFromLiteApi(
328
319
  apiKey?: string,
320
+ fetchFn?: FetchLike,
321
+ timeoutMs = DEFAULT_HTTP_TIMEOUT_MS,
329
322
  ): Promise<Map<string, JuplendVaultMetadata>> {
330
- const response = await readJson<
323
+ const response = await requestJuplendJson<
331
324
  JuplendVaultMetadata[] | { data?: JuplendVaultMetadata[] }
332
- >(withBaseUrl(JUP_LITE_API_BASE_URL, LEND_VAULTS_PATH), {
333
- headers: buildApiHeaders(apiKey),
334
- });
325
+ >(
326
+ withBaseUrl(JUP_LITE_API_BASE_URL, LEND_VAULTS_PATH),
327
+ {
328
+ headers: buildApiHeaders(apiKey),
329
+ },
330
+ fetchFn,
331
+ timeoutMs,
332
+ );
335
333
  const rawVaults = Array.isArray(response) ? response : response.data;
336
334
  const vaults = new Map<string, JuplendVaultMetadata>();
337
335
 
@@ -348,17 +346,21 @@ async function loadVaultMetadataFromLiteApi(
348
346
  async function loadTokenSearchMap(
349
347
  mintAddresses: string[],
350
348
  apiKey?: string,
349
+ fetchFn?: FetchLike,
350
+ timeoutMs = DEFAULT_HTTP_TIMEOUT_MS,
351
351
  ): Promise<Map<string, JuplendTokenMetadata>> {
352
352
  if (mintAddresses.length === 0) {
353
353
  return new Map();
354
354
  }
355
355
 
356
356
  const query = encodeURIComponent(mintAddresses.join(","));
357
- const response = await readJson<JuplendTokenSearchEntry[]>(
357
+ const response = await requestJuplendJson<JuplendTokenSearchEntry[]>(
358
358
  `${withBaseUrl(JUP_API_BASE_URL, TOKENS_SEARCH_PATH)}?query=${query}`,
359
359
  {
360
360
  headers: buildApiHeaders(apiKey),
361
361
  },
362
+ fetchFn,
363
+ timeoutMs,
362
364
  );
363
365
 
364
366
  const tokens = new Map<string, JuplendTokenMetadata>();
@@ -385,17 +387,23 @@ async function loadTokenSearchMap(
385
387
  async function loadPriceMap(
386
388
  mintAddresses: string[],
387
389
  apiKey?: string,
390
+ fetchFn?: FetchLike,
391
+ timeoutMs = DEFAULT_HTTP_TIMEOUT_MS,
388
392
  ): Promise<Map<string, JuplendPriceApiEntry>> {
389
393
  if (mintAddresses.length === 0) {
390
394
  return new Map();
391
395
  }
392
396
 
393
397
  const ids = encodeURIComponent(mintAddresses.join(","));
394
- const response = await readJson<Record<string, JuplendPriceApiEntry>>(
398
+ const response = await requestJuplendJson<
399
+ Record<string, JuplendPriceApiEntry>
400
+ >(
395
401
  `${withBaseUrl(JUP_API_BASE_URL, PRICE_V3_PATH)}?ids=${ids}`,
396
402
  {
397
403
  headers: buildApiHeaders(apiKey),
398
404
  },
405
+ fetchFn,
406
+ timeoutMs,
399
407
  );
400
408
 
401
409
  return new Map(Object.entries(response ?? {}));
@@ -436,6 +444,8 @@ function mergeTokenMetadata(
436
444
  async function enrichVaultsWithJupApi(input: {
437
445
  apiKey?: string;
438
446
  baseVaults: Map<string, JuplendVaultMetadata>;
447
+ fetchFn?: FetchLike;
448
+ timeoutMs: number;
439
449
  }): Promise<Map<string, JuplendVaultMetadata>> {
440
450
  const mintAddresses = new Set<string>();
441
451
  for (const vault of input.baseVaults.values()) {
@@ -450,8 +460,18 @@ async function enrichVaultsWithJupApi(input: {
450
460
  }
451
461
 
452
462
  const [tokenMap, priceMap] = await Promise.all([
453
- loadTokenSearchMap([...mintAddresses], input.apiKey),
454
- loadPriceMap([...mintAddresses], input.apiKey),
463
+ loadTokenSearchMap(
464
+ [...mintAddresses],
465
+ input.apiKey,
466
+ input.fetchFn,
467
+ input.timeoutMs,
468
+ ),
469
+ loadPriceMap(
470
+ [...mintAddresses],
471
+ input.apiKey,
472
+ input.fetchFn,
473
+ input.timeoutMs,
474
+ ),
455
475
  ]);
456
476
 
457
477
  const enriched = new Map<string, JuplendVaultMetadata>();
@@ -480,6 +500,8 @@ async function enrichVaultsWithJupApi(input: {
480
500
  async function loadVaults(
481
501
  now: number,
482
502
  apiKey?: string,
503
+ fetchFn?: FetchLike,
504
+ timeoutMs = DEFAULT_HTTP_TIMEOUT_MS,
483
505
  ): Promise<Map<string, JuplendVaultMetadata>> {
484
506
  const cacheKey = getEnrichmentCacheKey(apiKey);
485
507
  const cached = enrichmentCache.get(cacheKey);
@@ -492,7 +514,11 @@ async function loadVaults(
492
514
  const inflight = enrichmentCachePromise.get(cacheKey);
493
515
  if (!inflight) {
494
516
  const nextPromise = (async () => {
495
- const baseVaults = await loadVaultMetadataFromLiteApi(apiKey);
517
+ const baseVaults = await loadVaultMetadataFromLiteApi(
518
+ apiKey,
519
+ fetchFn,
520
+ timeoutMs,
521
+ );
496
522
  if (!apiKey) {
497
523
  enrichmentCache.set(cacheKey, {
498
524
  loadedAt: now,
@@ -506,6 +532,8 @@ async function loadVaults(
506
532
  const enrichedVaults = await enrichVaultsWithJupApi({
507
533
  apiKey,
508
534
  baseVaults,
535
+ fetchFn,
536
+ timeoutMs,
509
537
  });
510
538
  enrichmentCache.set(cacheKey, {
511
539
  loadedAt: now,
@@ -545,9 +573,11 @@ async function mapAccount(
545
573
  receivedAt: number,
546
574
  rpcUrl: string | undefined,
547
575
  jupApiKey: string | undefined,
576
+ fetchFn: FetchLike | undefined,
577
+ timeoutMs: number,
548
578
  ): Promise<JuplendMappedAccount> {
549
579
  const [vaults, positionResult] = await Promise.all([
550
- loadVaults(receivedAt, jupApiKey),
580
+ loadVaults(receivedAt, jupApiKey, fetchFn, timeoutMs),
551
581
  readJuplendPositions({
552
582
  walletAddress: accountOptions.walletAddress,
553
583
  vaultId: accountOptions.vaultId,
@@ -666,6 +696,10 @@ export class JuplendPrivateAdapter implements PrivateUserDataAdapter {
666
696
  constructor(
667
697
  private readonly rpcUrl?: string,
668
698
  private readonly jupApiKey?: string,
699
+ private readonly options: {
700
+ readonly fetchFn?: FetchLike;
701
+ readonly httpTimeoutMs?: number;
702
+ } = {},
669
703
  ) {}
670
704
 
671
705
  async bootstrapAccount(
@@ -679,6 +713,8 @@ export class JuplendPrivateAdapter implements PrivateUserDataAdapter {
679
713
  receivedAt,
680
714
  this.rpcUrl,
681
715
  getJupApiKey(this.jupApiKey),
716
+ this.options.fetchFn,
717
+ this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS,
682
718
  );
683
719
 
684
720
  return {
@@ -3,7 +3,12 @@ import type {
3
3
  StreamHandle,
4
4
  } from "../adapters/types.ts";
5
5
  import { AcexError } from "../errors.ts";
6
- import type { AccountRuntimeOptions, Venue } from "../types/index.ts";
6
+ import { isTransportError, redactSecrets } from "../internal/http-client.ts";
7
+ import type {
8
+ AccountRuntimeOptions,
9
+ PrivateRuntimeReason,
10
+ Venue,
11
+ } from "../types/index.ts";
7
12
  import type {
8
13
  ClientContext,
9
14
  PrivateAccountDataConsumer,
@@ -41,6 +46,23 @@ function normalizePositiveInterval(
41
46
  : fallback;
42
47
  }
43
48
 
49
+ function transportReason(
50
+ error: unknown,
51
+ fallback: PrivateRuntimeReason,
52
+ ): PrivateRuntimeReason {
53
+ return isTransportError(error) && error.kind === "rate_limited"
54
+ ? "rate_limited"
55
+ : fallback;
56
+ }
57
+
58
+ function bootstrapErrorDetail(error: unknown): string {
59
+ if (!(error instanceof Error) || !error.message) {
60
+ return "";
61
+ }
62
+
63
+ return ` (${redactSecrets(error.message)})`;
64
+ }
65
+
44
66
  export class PrivateSubscriptionCoordinator {
45
67
  private readonly context: ClientContext;
46
68
  private readonly adapters: Map<Venue, PrivateUserDataAdapter>;
@@ -435,7 +457,7 @@ export class PrivateSubscriptionCoordinator {
435
457
  {
436
458
  runtimeStatus: "degraded",
437
459
  ready: record.accountReady,
438
- reason: "http_failed",
460
+ reason: transportReason(error, "http_failed"),
439
461
  },
440
462
  );
441
463
  }
@@ -559,7 +581,7 @@ export class PrivateSubscriptionCoordinator {
559
581
  {
560
582
  runtimeStatus: "degraded",
561
583
  ready: record.accountReady,
562
- reason: "http_failed",
584
+ reason: transportReason(error, "http_failed"),
563
585
  },
564
586
  );
565
587
  }
@@ -676,11 +698,13 @@ export class PrivateSubscriptionCoordinator {
676
698
  {
677
699
  runtimeStatus: "degraded",
678
700
  ready: false,
679
- reason: record.venue === "juplend" ? "http_failed" : "auth_failed",
701
+ reason: transportReason(
702
+ error,
703
+ record.venue === "juplend" ? "http_failed" : "auth_failed",
704
+ ),
680
705
  },
681
706
  );
682
- const reason =
683
- error instanceof Error && error.message ? ` (${error.message})` : "";
707
+ const reason = bootstrapErrorDetail(error);
684
708
  throw new AcexError(
685
709
  "ACCOUNT_BOOTSTRAP_FAILED",
686
710
  `Failed to bootstrap account data: ${record.accountId}${reason}`,
@@ -727,7 +751,7 @@ export class PrivateSubscriptionCoordinator {
727
751
  {
728
752
  runtimeStatus: "degraded",
729
753
  ready: false,
730
- reason: "auth_failed",
754
+ reason: transportReason(error, "auth_failed"),
731
755
  },
732
756
  );
733
757
  throw new AcexError(
@@ -105,7 +105,9 @@ export class AcexClientImpl implements AcexClient, ClientContext {
105
105
  const marketAdapter = new BinanceMarketAdapter();
106
106
  this.marketAdapters = new Map([[marketAdapter.venue, marketAdapter]]);
107
107
  const privateAdapters = [
108
- new BinancePrivateAdapter(),
108
+ new BinancePrivateAdapter({
109
+ signingClock: options.clock,
110
+ }),
109
111
  new JuplendPrivateAdapter(
110
112
  options.account?.juplend?.rpcUrl,
111
113
  options.account?.juplend?.jupApiKey,
@@ -0,0 +1,608 @@
1
+ export type TransportErrorKind =
2
+ | "timeout"
3
+ | "http"
4
+ | "network"
5
+ | "rate_limited"
6
+ | "parse";
7
+
8
+ export type HttpParseAs = "json" | "text" | "none";
9
+ export type JsonParseMode = "text" | "response";
10
+ export type EmptyBodyStrategy = "empty_object" | "empty_string" | "undefined";
11
+
12
+ export interface HttpRetryPolicy {
13
+ readonly idempotent: boolean;
14
+ readonly maxAttempts: number;
15
+ readonly initialDelayMs?: number;
16
+ readonly maxDelayMs?: number;
17
+ readonly jitterRatio?: number;
18
+ readonly random?: () => number;
19
+ readonly sleep?: (ms: number) => Promise<void>;
20
+ }
21
+
22
+ export interface HttpClientMessages {
23
+ http?(input: HttpErrorMessageInput): string;
24
+ timeout?(input: HttpErrorMessageInput): string;
25
+ aborted?(input: HttpErrorMessageInput): string;
26
+ network?(input: HttpErrorMessageInput): string;
27
+ parse?(input: HttpErrorMessageInput): string;
28
+ }
29
+
30
+ export interface HttpRequestOptions {
31
+ readonly fetchFn?: FetchLike;
32
+ readonly url: string | URL;
33
+ readonly method?: string;
34
+ readonly headers?: RequestInit["headers"];
35
+ readonly body?: RequestInit["body"];
36
+ readonly signal?: AbortSignal;
37
+ readonly timeoutMs?: number;
38
+ readonly parseAs: HttpParseAs;
39
+ readonly jsonParseMode?: JsonParseMode;
40
+ readonly emptyBody?: EmptyBodyStrategy;
41
+ readonly retryPolicy: HttpRetryPolicy;
42
+ readonly messages?: HttpClientMessages;
43
+ }
44
+
45
+ export interface HttpClientResponse<T> {
46
+ readonly body: T;
47
+ readonly status: number;
48
+ readonly statusText: string;
49
+ readonly headers: Headers;
50
+ readonly rawBody?: string;
51
+ readonly url: string;
52
+ readonly redactedUrl: string;
53
+ readonly attempts: number;
54
+ }
55
+
56
+ export interface HttpErrorMessageInput {
57
+ readonly kind: TransportErrorKind;
58
+ readonly status?: number;
59
+ readonly statusText?: string;
60
+ readonly retryAfterMs?: number;
61
+ readonly attempts: number;
62
+ readonly rawBody?: string;
63
+ readonly url: string;
64
+ }
65
+
66
+ export interface TransportErrorInit extends HttpErrorMessageInput {
67
+ readonly headers?: Headers;
68
+ readonly retryable: boolean;
69
+ readonly cause?: unknown;
70
+ }
71
+
72
+ export class TransportError extends Error {
73
+ readonly isAcexTransportError = true;
74
+ readonly kind: TransportErrorKind;
75
+ readonly status?: number;
76
+ readonly statusText?: string;
77
+ readonly retryAfterMs?: number;
78
+ readonly retryable: boolean;
79
+ readonly attempts: number;
80
+ readonly headers: Headers;
81
+ readonly rawBody?: string;
82
+ readonly url: string;
83
+ override readonly cause?: unknown;
84
+
85
+ constructor(message: string, init: TransportErrorInit) {
86
+ super(message, { cause: init.cause });
87
+ this.name = "TransportError";
88
+ this.kind = init.kind;
89
+ this.status = init.status;
90
+ this.statusText = init.statusText;
91
+ this.retryAfterMs = init.retryAfterMs;
92
+ this.retryable = init.retryable;
93
+ this.attempts = init.attempts;
94
+ this.headers = init.headers ?? new Headers();
95
+ this.rawBody = init.rawBody;
96
+ this.url = init.url;
97
+ this.cause = init.cause;
98
+ }
99
+ }
100
+
101
+ type FetchLike = (
102
+ input: string | URL | Request,
103
+ init?: RequestInit,
104
+ ) => Promise<Response>;
105
+
106
+ interface AttemptErrorInput {
107
+ readonly kind: TransportErrorKind;
108
+ readonly status?: number;
109
+ readonly statusText?: string;
110
+ readonly headers?: Headers;
111
+ readonly rawBody?: string;
112
+ readonly retryAfterMs?: number;
113
+ readonly attempts: number;
114
+ readonly redactedUrl: string;
115
+ readonly retryable: boolean;
116
+ readonly aborted?: boolean;
117
+ readonly cause?: unknown;
118
+ readonly messages?: HttpClientMessages;
119
+ }
120
+
121
+ const DEFAULT_INITIAL_DELAY_MS = 100;
122
+ const DEFAULT_MAX_DELAY_MS = 1_000;
123
+ const DEFAULT_JITTER_RATIO = 0.2;
124
+ const SENSITIVE_QUERY_KEYS = new Set([
125
+ "apikey",
126
+ "api_key",
127
+ "api-key",
128
+ "key",
129
+ "secret",
130
+ "signature",
131
+ "token",
132
+ "access_token",
133
+ "listenkey",
134
+ "listen_key",
135
+ "passphrase",
136
+ ]);
137
+
138
+ export function isTransportError(error: unknown): error is TransportError {
139
+ if (!error || typeof error !== "object") {
140
+ return false;
141
+ }
142
+
143
+ const record = error as Record<string, unknown>;
144
+ return (
145
+ record.isAcexTransportError === true &&
146
+ typeof record.kind === "string" &&
147
+ typeof record.retryable === "boolean" &&
148
+ typeof record.attempts === "number"
149
+ );
150
+ }
151
+
152
+ export function redactSecrets(value: string): string {
153
+ let redacted = value.replace(/https?:\/\/[^\s)]+/g, redactUrlMatch);
154
+ // Bare (non-URL) signed query fragments — e.g. a relative path like
155
+ // "/papi/v1/order?symbol=...&signature=..." — never match the http(s) URL
156
+ // branch above. Fold the whole fragment so the non-secret params riding
157
+ // alongside a signature do not leak; mirrors the "?query=[REDACTED]"
158
+ // collapse redactUrl applies to full signed URLs.
159
+ redacted = redacted.replace(
160
+ /\?[^\s)#?]*\bsignature=[^&\s)#]+/gi,
161
+ "?query=[REDACTED]",
162
+ );
163
+ redacted = redacted.replace(
164
+ /([?&](?:api[_-]?key|key|secret|signature|token|access_token|listen[_-]?key|passphrase)=)[^&\s)]+/gi,
165
+ "$1[REDACTED]",
166
+ );
167
+ redacted = redacted.replace(
168
+ /("(?:api[_-]?key|key|secret|signature|token|access_token|listen[_-]?key|passphrase)"\s*:\s*")[^"]*(")/gi,
169
+ "$1[REDACTED]$2",
170
+ );
171
+ redacted = redacted.replace(
172
+ /((?:api[_-]?key|key|secret|signature|token|access_token|listen[_-]?key|passphrase)\s*[:=]\s*)[^\s,;)"']+/gi,
173
+ "$1[REDACTED]",
174
+ );
175
+ redacted = redacted.replace(
176
+ /([?&])signature=\[REDACTED\]/gi,
177
+ "$1query=[REDACTED]",
178
+ );
179
+ redacted = redacted.replace(
180
+ /"signature"\s*:\s*"\[REDACTED\]"/gi,
181
+ '"redacted":"[REDACTED]"',
182
+ );
183
+ redacted = redacted.replace(
184
+ /signature\s*[:=]\s*\[REDACTED\]/gi,
185
+ "query=[REDACTED]",
186
+ );
187
+ return redacted;
188
+ }
189
+
190
+ function redactUrlMatch(match: string): string {
191
+ try {
192
+ const url = new URL(match);
193
+ if (url.searchParams.has("signature")) {
194
+ url.search = "?query=[REDACTED]";
195
+ return url.toString();
196
+ }
197
+
198
+ for (const key of [...url.searchParams.keys()]) {
199
+ if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase())) {
200
+ url.searchParams.set(key, "[REDACTED]");
201
+ }
202
+ }
203
+
204
+ return url.toString();
205
+ } catch {
206
+ return match;
207
+ }
208
+ }
209
+
210
+ export function redactUrl(input: string | URL): string {
211
+ const rawUrl = input.toString();
212
+ try {
213
+ const url = new URL(rawUrl);
214
+ const hasSignature = url.searchParams.has("signature");
215
+ if (hasSignature) {
216
+ url.search = "?query=[REDACTED]";
217
+ return url.toString();
218
+ }
219
+
220
+ let changed = false;
221
+ for (const key of [...url.searchParams.keys()]) {
222
+ if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase())) {
223
+ url.searchParams.set(key, "[REDACTED]");
224
+ changed = true;
225
+ }
226
+ }
227
+
228
+ return changed ? url.toString() : rawUrl;
229
+ } catch {
230
+ return redactSecrets(rawUrl);
231
+ }
232
+ }
233
+
234
+ export function parseRetryAfterMs(value: string | null): number | undefined {
235
+ if (!value) {
236
+ return undefined;
237
+ }
238
+
239
+ const seconds = Number(value);
240
+ if (Number.isFinite(seconds) && seconds >= 0) {
241
+ return Math.round(seconds * 1_000);
242
+ }
243
+
244
+ const dateMs = Date.parse(value);
245
+ if (!Number.isFinite(dateMs)) {
246
+ return undefined;
247
+ }
248
+
249
+ const deltaMs = dateMs - Date.now();
250
+ return deltaMs > 0 ? deltaMs : 0;
251
+ }
252
+
253
+ export async function httpRequest<T>(
254
+ options: HttpRequestOptions,
255
+ ): Promise<HttpClientResponse<T>> {
256
+ const fetchFn = options.fetchFn ?? fetch;
257
+ const url = options.url.toString();
258
+ const redactedUrl = redactUrl(options.url);
259
+ const maxAttempts = Math.max(1, Math.floor(options.retryPolicy.maxAttempts));
260
+ let lastError: TransportError | undefined;
261
+
262
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
263
+ if (options.signal?.aborted) {
264
+ throw buildAttemptError({
265
+ kind: "network",
266
+ attempts: attempt,
267
+ redactedUrl,
268
+ retryable: false,
269
+ aborted: true,
270
+ messages: options.messages,
271
+ });
272
+ }
273
+
274
+ try {
275
+ return await executeAttempt<T>(
276
+ options,
277
+ fetchFn,
278
+ url,
279
+ redactedUrl,
280
+ attempt,
281
+ );
282
+ } catch (error) {
283
+ const transportError = isTransportError(error)
284
+ ? error
285
+ : buildAttemptError({
286
+ kind: "network",
287
+ attempts: attempt,
288
+ redactedUrl,
289
+ retryable: retryableForKind("network", undefined, options),
290
+ cause: error,
291
+ messages: options.messages,
292
+ });
293
+ lastError = transportError;
294
+ if (!shouldRetry(transportError, attempt, maxAttempts, options)) {
295
+ throw transportError;
296
+ }
297
+
298
+ await delayBeforeRetry(attempt, options.retryPolicy, options.signal);
299
+ }
300
+ }
301
+
302
+ throw lastError;
303
+ }
304
+
305
+ async function executeAttempt<T>(
306
+ options: HttpRequestOptions,
307
+ fetchFn: FetchLike,
308
+ url: string,
309
+ redactedUrl: string,
310
+ attempts: number,
311
+ ): Promise<HttpClientResponse<T>> {
312
+ const controller = new AbortController();
313
+ const timeoutMs = options.timeoutMs;
314
+ let timeout: ReturnType<typeof setTimeout> | undefined;
315
+ let timedOut = false;
316
+ const onUpstreamAbort = (): void => {
317
+ controller.abort();
318
+ };
319
+
320
+ if (options.signal?.aborted) {
321
+ controller.abort();
322
+ } else {
323
+ options.signal?.addEventListener("abort", onUpstreamAbort, { once: true });
324
+ }
325
+
326
+ if (timeoutMs !== undefined) {
327
+ timeout = setTimeout(() => {
328
+ timedOut = true;
329
+ controller.abort();
330
+ }, timeoutMs);
331
+ }
332
+
333
+ try {
334
+ const response = await fetchFn(url, {
335
+ method: options.method,
336
+ headers: options.headers,
337
+ body: options.body,
338
+ signal: controller.signal,
339
+ });
340
+ const headers = new Headers(response.headers);
341
+
342
+ if (!response.ok) {
343
+ const rawBody = redactSecrets(await response.text());
344
+ const kind: TransportErrorKind =
345
+ response.status === 429 || response.status === 418
346
+ ? "rate_limited"
347
+ : "http";
348
+ const retryAfterMs = parseRetryAfterMs(headers.get("Retry-After"));
349
+ throw buildAttemptError({
350
+ kind,
351
+ status: response.status,
352
+ statusText: response.statusText,
353
+ headers,
354
+ rawBody,
355
+ retryAfterMs,
356
+ attempts,
357
+ redactedUrl,
358
+ retryable: retryableForKind(kind, response.status, options),
359
+ messages: options.messages,
360
+ });
361
+ }
362
+
363
+ const parsed = await parseResponseBody<T>(
364
+ response,
365
+ options,
366
+ attempts,
367
+ redactedUrl,
368
+ );
369
+ return {
370
+ body: parsed.body,
371
+ status: response.status,
372
+ statusText: response.statusText,
373
+ headers,
374
+ rawBody: parsed.rawBody,
375
+ url,
376
+ redactedUrl,
377
+ attempts,
378
+ };
379
+ } catch (error) {
380
+ if (isTransportError(error)) {
381
+ throw error;
382
+ }
383
+
384
+ if (isAbortError(error)) {
385
+ throw buildAttemptError({
386
+ kind: timedOut ? "timeout" : "network",
387
+ attempts,
388
+ redactedUrl,
389
+ retryable: timedOut
390
+ ? retryableForKind("timeout", undefined, options)
391
+ : false,
392
+ aborted: !timedOut,
393
+ cause: error,
394
+ messages: options.messages,
395
+ });
396
+ }
397
+
398
+ throw buildAttemptError({
399
+ kind: "network",
400
+ attempts,
401
+ redactedUrl,
402
+ retryable: retryableForKind("network", undefined, options),
403
+ cause: error,
404
+ messages: options.messages,
405
+ });
406
+ } finally {
407
+ if (timeout) {
408
+ clearTimeout(timeout);
409
+ }
410
+ options.signal?.removeEventListener("abort", onUpstreamAbort);
411
+ }
412
+ }
413
+
414
+ async function parseResponseBody<T>(
415
+ response: Response,
416
+ options: HttpRequestOptions,
417
+ attempts: number,
418
+ redactedUrl: string,
419
+ ): Promise<{ body: T; rawBody?: string }> {
420
+ if (options.parseAs === "none") {
421
+ return { body: undefined as T };
422
+ }
423
+
424
+ if (options.parseAs === "text") {
425
+ const rawBody = await response.text();
426
+ return { body: rawBody as T, rawBody };
427
+ }
428
+
429
+ if (options.jsonParseMode === "response") {
430
+ try {
431
+ return { body: (await response.json()) as T };
432
+ } catch (error) {
433
+ throw buildAttemptError({
434
+ kind: "parse",
435
+ status: response.status,
436
+ statusText: response.statusText,
437
+ headers: new Headers(response.headers),
438
+ attempts,
439
+ redactedUrl,
440
+ retryable: false,
441
+ cause: error,
442
+ messages: options.messages,
443
+ });
444
+ }
445
+ }
446
+
447
+ const rawBody = await response.text();
448
+ if (!rawBody) {
449
+ switch (options.emptyBody ?? "undefined") {
450
+ case "empty_object":
451
+ return { body: {} as T, rawBody };
452
+ case "empty_string":
453
+ return { body: "" as T, rawBody };
454
+ case "undefined":
455
+ return { body: undefined as T, rawBody };
456
+ }
457
+ }
458
+
459
+ try {
460
+ return { body: JSON.parse(rawBody) as T, rawBody };
461
+ } catch (error) {
462
+ throw buildAttemptError({
463
+ kind: "parse",
464
+ status: response.status,
465
+ statusText: response.statusText,
466
+ headers: new Headers(response.headers),
467
+ rawBody: redactSecrets(rawBody),
468
+ attempts,
469
+ redactedUrl,
470
+ retryable: false,
471
+ cause: error,
472
+ messages: options.messages,
473
+ });
474
+ }
475
+ }
476
+
477
+ function buildAttemptError(input: AttemptErrorInput): TransportError {
478
+ const messageInput: HttpErrorMessageInput = {
479
+ kind: input.kind,
480
+ status: input.status,
481
+ statusText: input.statusText,
482
+ retryAfterMs: input.retryAfterMs,
483
+ attempts: input.attempts,
484
+ rawBody: input.rawBody,
485
+ url: input.redactedUrl,
486
+ };
487
+ const message =
488
+ messageForKind(input.messages, input.kind, input.aborted)?.(messageInput) ??
489
+ defaultMessage(messageInput);
490
+
491
+ return new TransportError(message, {
492
+ ...messageInput,
493
+ headers: input.headers,
494
+ retryable: input.retryable,
495
+ cause: input.cause,
496
+ });
497
+ }
498
+
499
+ function messageForKind(
500
+ messages: HttpClientMessages | undefined,
501
+ kind: TransportErrorKind,
502
+ aborted: boolean | undefined,
503
+ ): ((input: HttpErrorMessageInput) => string) | undefined {
504
+ if (kind === "network" && aborted) {
505
+ return messages?.aborted ?? messages?.network;
506
+ }
507
+ if (kind === "http" || kind === "rate_limited") {
508
+ return messages?.http;
509
+ }
510
+ return messages?.[kind];
511
+ }
512
+
513
+ function defaultMessage(input: HttpErrorMessageInput): string {
514
+ switch (input.kind) {
515
+ case "timeout":
516
+ return `HTTP request timeout after attempt ${input.attempts}: ${input.url}`;
517
+ case "network":
518
+ return `HTTP request failed: ${input.url}`;
519
+ case "parse":
520
+ return `HTTP response parse failed: ${input.url}`;
521
+ case "rate_limited":
522
+ case "http": {
523
+ const status = [input.status, input.statusText].filter(Boolean).join(" ");
524
+ const body = input.rawBody ? ` ${input.rawBody}` : "";
525
+ return `HTTP request failed: ${status} ${input.url}${body}`;
526
+ }
527
+ }
528
+ }
529
+
530
+ function retryableForKind(
531
+ kind: TransportErrorKind,
532
+ status: number | undefined,
533
+ options: HttpRequestOptions,
534
+ ): boolean {
535
+ if (!options.retryPolicy.idempotent || options.signal?.aborted) {
536
+ return false;
537
+ }
538
+
539
+ if (kind === "network" || kind === "timeout") {
540
+ return true;
541
+ }
542
+
543
+ if (kind === "http" && status !== undefined) {
544
+ return status >= 500 && status <= 599;
545
+ }
546
+
547
+ return false;
548
+ }
549
+
550
+ function shouldRetry(
551
+ error: TransportError,
552
+ attempt: number,
553
+ maxAttempts: number,
554
+ options: HttpRequestOptions,
555
+ ): boolean {
556
+ return error.retryable && attempt < maxAttempts && !options.signal?.aborted;
557
+ }
558
+
559
+ async function delayBeforeRetry(
560
+ attempt: number,
561
+ retryPolicy: HttpRetryPolicy,
562
+ signal?: AbortSignal,
563
+ ): Promise<void> {
564
+ const sleep = retryPolicy.sleep ?? defaultSleep;
565
+ const baseDelay = Math.min(
566
+ retryPolicy.maxDelayMs ?? DEFAULT_MAX_DELAY_MS,
567
+ (retryPolicy.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS) *
568
+ 2 ** Math.max(0, attempt - 1),
569
+ );
570
+ const jitterRatio = retryPolicy.jitterRatio ?? DEFAULT_JITTER_RATIO;
571
+ const random = retryPolicy.random ?? Math.random;
572
+ const jitter = baseDelay * jitterRatio * (random() * 2 - 1);
573
+ const delayMs = Math.max(0, Math.round(baseDelay + jitter));
574
+
575
+ if (signal === undefined) {
576
+ await sleep(delayMs);
577
+ return;
578
+ }
579
+ if (signal.aborted) {
580
+ return;
581
+ }
582
+ // Race the backoff against the upstream abort so a cancel mid-backoff
583
+ // returns immediately instead of waiting out the full delay. The retry
584
+ // loop re-checks signal.aborted on the next iteration and throws.
585
+ await new Promise<void>((resolve) => {
586
+ let settled = false;
587
+ const finish = (): void => {
588
+ if (settled) {
589
+ return;
590
+ }
591
+ settled = true;
592
+ signal.removeEventListener("abort", finish);
593
+ resolve();
594
+ };
595
+ signal.addEventListener("abort", finish, { once: true });
596
+ void Promise.resolve(sleep(delayMs)).then(finish);
597
+ });
598
+ }
599
+
600
+ function isAbortError(error: unknown): boolean {
601
+ return error instanceof Error && error.name === "AbortError";
602
+ }
603
+
604
+ function defaultSleep(ms: number): Promise<void> {
605
+ return new Promise((resolve) => {
606
+ setTimeout(resolve, ms);
607
+ });
608
+ }
@@ -24,6 +24,11 @@ export interface Logger {
24
24
  error(msg: string, context?: Record<string, unknown>): void;
25
25
  }
26
26
 
27
+ export interface TimeProvider {
28
+ /** Millisecond timestamp used for outbound request/signing timestamps. */
29
+ now(): number;
30
+ }
31
+
27
32
  export interface MarketRuntimeOptions {
28
33
  l1InitialMessageTimeoutMs?: number;
29
34
  l1StaleAfterMs?: number;
@@ -48,6 +53,8 @@ export interface AccountRuntimeOptions {
48
53
 
49
54
  export interface CreateClientOptions {
50
55
  sandbox?: boolean;
56
+ /** Request/signing clock; local receivedAt/freshness clocks stay independent. */
57
+ clock?: TimeProvider;
51
58
  logger?: Logger;
52
59
  logLevel?: LogLevel;
53
60
  market?: MarketRuntimeOptions;