@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 +1 -1
- package/src/adapters/binance/market-catalog.ts +32 -11
- package/src/adapters/binance/private-adapter.ts +85 -20
- package/src/adapters/juplend/private-adapter.ts +91 -55
- package/src/client/private-subscription-coordinator.ts +31 -7
- package/src/client/runtime.ts +3 -1
- package/src/internal/http-client.ts +608 -0
- package/src/types/shared.ts +7 -0
package/package.json
CHANGED
|
@@ -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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
`${
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
|
323
|
+
const response = await requestJuplendJson<
|
|
331
324
|
JuplendVaultMetadata[] | { data?: JuplendVaultMetadata[] }
|
|
332
|
-
>(
|
|
333
|
-
|
|
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
|
|
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
|
|
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(
|
|
454
|
-
|
|
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(
|
|
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
|
|
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:
|
|
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(
|
package/src/client/runtime.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/types/shared.ts
CHANGED
|
@@ -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;
|