@w3lib/solana-batch-call 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +120 -18
- package/dist/address.d.ts +7 -1
- package/dist/address.d.ts.map +1 -1
- package/dist/address.js +56 -8
- package/dist/address.js.map +1 -1
- package/dist/client.d.ts +117 -23
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +756 -247
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +31 -2
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +68 -8
- package/dist/layout.js.map +1 -1
- package/dist/rpc.d.ts +65 -4
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +320 -42
- package/dist/rpc.js.map +1 -1
- package/dist/shortcuts.d.ts +38 -5
- package/dist/shortcuts.d.ts.map +1 -1
- package/dist/shortcuts.js +169 -12
- package/dist/shortcuts.js.map +1 -1
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +2 -4
- package/dist/source.js.map +1 -1
- package/dist/types.d.ts +54 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +13 -10
- package/src/address.ts +60 -8
- package/src/client.ts +974 -270
- package/src/index.ts +47 -3
- package/src/layout.ts +90 -8
- package/src/rpc.ts +368 -42
- package/src/shortcuts.ts +223 -16
- package/src/source.ts +2 -4
- package/src/types.ts +59 -3
package/src/rpc.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** 最小的 JSON-RPC 传输接口:可以传入自己的实现(如包一层 @solana/kit 的 rpc) */
|
|
2
2
|
export interface RpcTransport {
|
|
3
3
|
request<T = unknown>(method: string, params?: readonly unknown[]): Promise<T>
|
|
4
|
+
/** 节点名称,用于 AllNodesFailedError / onRequest 里标识节点;不传时自定义传输显示为 'custom' */
|
|
5
|
+
readonly label?: string
|
|
4
6
|
}
|
|
5
7
|
|
|
6
8
|
/** 节点返回的 JSON-RPC 错误 */
|
|
@@ -19,22 +21,66 @@ export class RpcError extends Error {
|
|
|
19
21
|
code === 403 ||
|
|
20
22
|
code === 429 ||
|
|
21
23
|
code === -32005 ||
|
|
22
|
-
|
|
24
|
+
// 节点高度没到 minContextSlot:换节点可能成功
|
|
25
|
+
code === MIN_CONTEXT_SLOT_NOT_REACHED ||
|
|
26
|
+
/minimum context slot/i.test(message) ||
|
|
27
|
+
(/forbidden|blocked|personal token|api.?key|rate.?limit|too many|not allowed|unauthori[sz]ed|limit exceeded|disabled|not available|not supported/i.test(message) &&
|
|
28
|
+
!TOO_MANY_ACCOUNTS.test(message))
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
/** 节点限制单次 getMultipleAccounts 的账户数(如 “Too many accounts requested”):缩小每次的账户数后重试,不是节点故障 */
|
|
33
|
+
const TOO_MANY_ACCOUNTS = /too many accounts/i
|
|
34
|
+
|
|
26
35
|
/** HTTP 层错误(非 2xx、网络错误、超时) */
|
|
27
36
|
export class HttpError extends Error {
|
|
28
37
|
readonly status: number | undefined
|
|
38
|
+
/** 请求超时(HttpRpcOptions.timeout) */
|
|
39
|
+
readonly timeout: boolean
|
|
29
40
|
|
|
30
|
-
constructor(message: string, status?: number) {
|
|
41
|
+
constructor(message: string, status?: number, timeout = false) {
|
|
31
42
|
super(message)
|
|
32
43
|
this.name = 'HttpError'
|
|
33
44
|
this.status = status
|
|
45
|
+
this.timeout = timeout
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** JSON-RPC 错误码:节点高度还没到请求的 minContextSlot */
|
|
50
|
+
export const MIN_CONTEXT_SLOT_NOT_REACHED = -32016
|
|
51
|
+
|
|
52
|
+
/** 是否是请求超时(HttpRpc 的超时,或自定义传输抛出的 TimeoutError) */
|
|
53
|
+
export function isTimeout(err: unknown): boolean {
|
|
54
|
+
return (err instanceof HttpError && err.timeout) || (err as { name?: unknown } | null)?.name === 'TimeoutError'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 是否是“节点高度没到 minContextSlot”(所有节点都落后时也算) */
|
|
58
|
+
export function isBehind(err: unknown): boolean {
|
|
59
|
+
if (err instanceof AllNodesFailedError) {
|
|
60
|
+
return err.errors.length > 0 && err.errors.every((e) => isBehind(e.error))
|
|
61
|
+
}
|
|
62
|
+
return err instanceof RpcError && (err.code === MIN_CONTEXT_SLOT_NOT_REACHED || /minimum context slot/i.test(err.message))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 多节点时所有节点都失败(都是节点问题)。errors 是各节点的错误,按尝试顺序;
|
|
67
|
+
* node 是节点名称(URL 只保留 host,不会带出 path / query 里的 API Key)
|
|
68
|
+
*/
|
|
69
|
+
export class AllNodesFailedError extends Error {
|
|
70
|
+
readonly errors: ReadonlyArray<{ node: string; error: unknown }>
|
|
71
|
+
|
|
72
|
+
constructor(errors: ReadonlyArray<{ node: string; error: unknown }>) {
|
|
73
|
+
super(`All RPC nodes failed: ${errors.map((e) => `${e.node}: ${errorMessage(e.error)}`).join(' | ')}`, { cause: errors[errors.length - 1]?.error })
|
|
74
|
+
this.name = 'AllNodesFailedError'
|
|
75
|
+
this.errors = errors
|
|
34
76
|
}
|
|
35
77
|
}
|
|
36
78
|
|
|
37
|
-
|
|
79
|
+
function errorMessage(err: unknown): string {
|
|
80
|
+
return err instanceof Error ? err.message : String(err)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 是否是节点问题(应换节点重试):除了确定性的 JSON-RPC 错误(如参数错误),其余都算(包括 AllNodesFailedError) */
|
|
38
84
|
export function isNodeFault(err: unknown): boolean {
|
|
39
85
|
if (err instanceof RpcError) {
|
|
40
86
|
return err.nodeFault
|
|
@@ -76,6 +122,9 @@ let nextId = 1
|
|
|
76
122
|
/** 批量请求被拒绝、可以降级为逐条请求的 HTTP 状态 */
|
|
77
123
|
const BATCH_REJECTED_STATUS = new Set([400, 404, 405, 415, 422])
|
|
78
124
|
|
|
125
|
+
/** 节点不支持批量时,多久后重新尝试批量 */
|
|
126
|
+
const BATCH_RETRY_AFTER = 10 * 60 * 1000
|
|
127
|
+
|
|
79
128
|
function isBatchLimitError(message: string | undefined): boolean {
|
|
80
129
|
return !!message && /batch/i.test(message) && /more than \d+|too (large|many|big)|not (allowed|supported)|exceed/i.test(message)
|
|
81
130
|
}
|
|
@@ -86,28 +135,84 @@ function isBatchLimitError(message: string | undefined): boolean {
|
|
|
86
135
|
*/
|
|
87
136
|
export class HttpRpc implements RpcTransport {
|
|
88
137
|
readonly url: string
|
|
138
|
+
/** 报错信息里用的地址:只保留 origin,不带 path / query(常含 API Key) */
|
|
139
|
+
readonly #safeUrl: string
|
|
89
140
|
readonly #timeout: number
|
|
90
141
|
readonly #headers: Record<string, string>
|
|
91
142
|
readonly #batchWait: number
|
|
92
143
|
readonly #retries: number
|
|
93
144
|
readonly #fetch: typeof fetch
|
|
94
145
|
#maxBatchSize: number
|
|
95
|
-
|
|
146
|
+
/** 节点对单个方法在一个批量里的数量限制(如 publicnode:getMultipleAccounts 最多 1 个),从报错里学到 */
|
|
147
|
+
readonly #methodLimits = new Map<string, number>()
|
|
148
|
+
/** options.batch:调用方是否允许批量 */
|
|
149
|
+
readonly #batchOption: boolean
|
|
150
|
+
/** 节点明确表示不支持批量时,在这之前逐条请求(之后重新尝试批量) */
|
|
151
|
+
#batchDisabledUntil = 0
|
|
152
|
+
/** 节点限制单次 getMultipleAccounts 的账户数时学到的上限 */
|
|
153
|
+
#maxAccounts: number | undefined
|
|
96
154
|
#queue: Pending[] = []
|
|
97
155
|
#timer: ReturnType<typeof setTimeout> | null = null
|
|
98
156
|
|
|
99
157
|
constructor(url: string, options: HttpRpcOptions = {}) {
|
|
100
158
|
this.url = url
|
|
159
|
+
this.#safeUrl = safeOrigin(url)
|
|
101
160
|
this.#timeout = options.timeout ?? 10_000
|
|
102
161
|
this.#headers = { 'content-type': 'application/json', ...options.headers }
|
|
103
162
|
this.#maxBatchSize = Math.max(1, options.maxBatchSize ?? 20)
|
|
104
|
-
this.#
|
|
163
|
+
this.#batchOption = options.batch ?? true
|
|
105
164
|
this.#batchWait = options.batchWait ?? 0
|
|
106
165
|
this.#retries = Math.max(0, options.retries ?? 2)
|
|
107
166
|
this.#fetch = options.fetch ?? ((...args) => fetch(...args))
|
|
108
167
|
}
|
|
109
168
|
|
|
110
169
|
request<T = unknown>(method: string, params: readonly unknown[] = []): Promise<T> {
|
|
170
|
+
if (method === 'getMultipleAccounts' && Array.isArray(params[0])) {
|
|
171
|
+
return this.#getMultipleAccounts(params) as Promise<T>
|
|
172
|
+
}
|
|
173
|
+
return this.#enqueue<T>(method, params)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* getMultipleAccounts:节点限制单次账户数时(“Too many accounts requested”),减半后分多次请求、合并结果,
|
|
178
|
+
* 并记下上限,之后直接按上限拆分。合并后 context.slot 取最小值
|
|
179
|
+
*/
|
|
180
|
+
async #getMultipleAccounts(params: readonly unknown[]): Promise<unknown> {
|
|
181
|
+
const [addresses, ...rest] = params as [unknown[], ...unknown[]]
|
|
182
|
+
const max = this.#maxAccounts
|
|
183
|
+
if (max !== undefined && addresses.length > max) {
|
|
184
|
+
const parts: unknown[][] = []
|
|
185
|
+
for (let i = 0; i < addresses.length; i += max) {
|
|
186
|
+
parts.push(addresses.slice(i, i + max))
|
|
187
|
+
}
|
|
188
|
+
const results = (await Promise.all(parts.map((part) => this.#getMultipleAccounts([part, ...rest])))) as Array<{
|
|
189
|
+
context?: { slot?: number }
|
|
190
|
+
value?: unknown[]
|
|
191
|
+
}>
|
|
192
|
+
const slots = results.map((r) => r?.context?.slot).filter((slot): slot is number => typeof slot === 'number')
|
|
193
|
+
return {
|
|
194
|
+
...results[0],
|
|
195
|
+
context: { ...results[0]?.context, ...(slots.length ? { slot: Math.min(...slots) } : {}) },
|
|
196
|
+
value: results.flatMap((r) => r?.value ?? []),
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
return await this.#enqueue('getMultipleAccounts', params)
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err instanceof RpcError && TOO_MANY_ACCOUNTS.test(err.message) && addresses.length > 1) {
|
|
203
|
+
this.#maxAccounts = Math.max(1, Math.min(this.#maxAccounts ?? Number.POSITIVE_INFINITY, Math.floor(addresses.length / 2)))
|
|
204
|
+
return this.#getMultipleAccounts(params)
|
|
205
|
+
}
|
|
206
|
+
throw err
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 是否合并成批量请求:调用方允许,且不在 “节点不支持批量” 的冷却期内 */
|
|
211
|
+
get #batch(): boolean {
|
|
212
|
+
return this.#batchOption && Date.now() >= this.#batchDisabledUntil
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#enqueue<T>(method: string, params: readonly unknown[]): Promise<T> {
|
|
111
216
|
return new Promise<T>((resolve, reject) => {
|
|
112
217
|
this.#queue.push({ method, params, resolve: resolve as (value: unknown) => void, reject })
|
|
113
218
|
if (!this.#batch || this.#queue.length >= this.#maxBatchSize) {
|
|
@@ -125,9 +230,47 @@ export class HttpRpc implements RpcTransport {
|
|
|
125
230
|
}
|
|
126
231
|
const queue = this.#queue
|
|
127
232
|
this.#queue = []
|
|
128
|
-
for (
|
|
129
|
-
void this.#dispatch(
|
|
233
|
+
for (const chunk of this.#split(queue)) {
|
|
234
|
+
void this.#dispatch(chunk)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 按 maxBatchSize 和各方法的数量限制分批(保持顺序;不受限的方法照常合并) */
|
|
239
|
+
#split(items: readonly Pending[]): Pending[][] {
|
|
240
|
+
const chunks: Pending[][] = []
|
|
241
|
+
let current: Pending[] = []
|
|
242
|
+
let counts = new Map<string, number>()
|
|
243
|
+
for (const item of items) {
|
|
244
|
+
const limit = this.#methodLimits.get(item.method)
|
|
245
|
+
const count = counts.get(item.method) ?? 0
|
|
246
|
+
if (current.length >= this.#maxBatchSize || (limit !== undefined && count >= limit)) {
|
|
247
|
+
chunks.push(current)
|
|
248
|
+
current = []
|
|
249
|
+
counts = new Map()
|
|
250
|
+
}
|
|
251
|
+
current.push(item)
|
|
252
|
+
counts.set(item.method, (counts.get(item.method) ?? 0) + 1)
|
|
253
|
+
}
|
|
254
|
+
if (current.length) {
|
|
255
|
+
chunks.push(current)
|
|
256
|
+
}
|
|
257
|
+
return chunks
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 报错是 “某方法在批量里的数量超限” 时(如 “Maximum number of 'getMultipleAccounts' calls in a batch request is 1”),
|
|
262
|
+
* 记下限制并按限制重新分批,返回 true;限制解析不出或这批本来就没超限(避免无限重发)时返回 false
|
|
263
|
+
*/
|
|
264
|
+
async #retryWithMethodLimit(items: Pending[], message: string | undefined): Promise<boolean> {
|
|
265
|
+
const match = /maximum number of '([^']+)' calls in a batch request is (\d+)/i.exec(message ?? '')
|
|
266
|
+
const method = match?.[1]
|
|
267
|
+
const limit = Number(match?.[2])
|
|
268
|
+
if (!method || !Number.isFinite(limit) || limit < 1 || items.filter((item) => item.method === method).length <= limit) {
|
|
269
|
+
return false
|
|
130
270
|
}
|
|
271
|
+
this.#methodLimits.set(method, limit)
|
|
272
|
+
await Promise.all(this.#split(items).map((chunk) => this.#dispatch(chunk)))
|
|
273
|
+
return true
|
|
131
274
|
}
|
|
132
275
|
|
|
133
276
|
async #dispatch(items: Pending[]): Promise<void> {
|
|
@@ -158,18 +301,31 @@ export class HttpRpc implements RpcTransport {
|
|
|
158
301
|
}
|
|
159
302
|
if (!Array.isArray(body)) {
|
|
160
303
|
const error = (body as RpcResponse)?.error
|
|
304
|
+
// 单个方法在批量里的数量超限(整批被拒):按限制重新分批,不当成节点问题
|
|
305
|
+
if (await this.#retryWithMethodLimit(items, error?.message)) {
|
|
306
|
+
return
|
|
307
|
+
}
|
|
161
308
|
const rpcError = new RpcError(error?.message ?? 'Invalid JSON-RPC batch response', error?.code, error?.data)
|
|
162
309
|
// 限频 / 鉴权等节点问题:原样报错,不降级(降级成逐条请求只会让限频更严重)
|
|
163
310
|
if (rpcError.nodeFault) {
|
|
164
311
|
items.forEach((item) => item.reject(rpcError))
|
|
165
312
|
return
|
|
166
313
|
}
|
|
167
|
-
//
|
|
168
|
-
|
|
314
|
+
// 整批被拒(不是账户数超限):节点多半不支持批量(各家错误码不同:-32600 / -32601 / -32700 …),
|
|
315
|
+
// 一段时间内改为逐条请求(10 分钟后重新尝试批量,偶发的错误不会永久关闭);不关的话每批都要先失败一次再逐条重发。
|
|
316
|
+
// 账户数超限只把这一批逐条重发(由 HTTP 层按节点缩小单次读取数量)
|
|
317
|
+
if (!TOO_MANY_ACCOUNTS.test(error?.message ?? '')) {
|
|
318
|
+
this.#disableBatch()
|
|
319
|
+
}
|
|
169
320
|
await Promise.all(items.map((item) => this.#single(item)))
|
|
170
321
|
return
|
|
171
322
|
}
|
|
172
323
|
const byId = new Map((body as RpcResponse[]).map((res) => [res.id, res]))
|
|
324
|
+
// 有的节点把 “方法数量超限” 放在批量结果的某一项里
|
|
325
|
+
const methodLimited = (body as RpcResponse[]).find((res) => /maximum number of '[^']+' calls in a batch/i.test(res.error?.message ?? ''))
|
|
326
|
+
if (methodLimited && (await this.#retryWithMethodLimit(items, methodLimited.error?.message))) {
|
|
327
|
+
return
|
|
328
|
+
}
|
|
173
329
|
const limited = (body as RpcResponse[]).find((res) => isBatchLimitError(res.error?.message))
|
|
174
330
|
if (limited) {
|
|
175
331
|
// 如 “Batch of more than 3 requests are not allowed”:按限制缩小批量后重发;
|
|
@@ -184,11 +340,13 @@ export class HttpRpc implements RpcTransport {
|
|
|
184
340
|
}
|
|
185
341
|
return
|
|
186
342
|
}
|
|
187
|
-
// 批量里个别调用被限频(JSON-RPC 429
|
|
343
|
+
// 批量里个别调用被限频(JSON-RPC 429):只把这些调用退避后单独重试一次(不再叠加 #post 的重试);
|
|
344
|
+
// 整批都是 429 时 #post 已经退避重试过整批,不再逐条重试——否则请求数会成倍放大
|
|
345
|
+
const allLimited = isRateLimited(body)
|
|
188
346
|
const limitedItems: Pending[] = []
|
|
189
347
|
items.forEach((item, i) => {
|
|
190
348
|
const response = byId.get(ids[i])
|
|
191
|
-
if (response?.error?.code === 429 && this.#retries > 0) {
|
|
349
|
+
if (response?.error?.code === 429 && this.#retries > 0 && !allLimited) {
|
|
192
350
|
limitedItems.push(item)
|
|
193
351
|
} else {
|
|
194
352
|
settle(item, response)
|
|
@@ -196,34 +354,30 @@ export class HttpRpc implements RpcTransport {
|
|
|
196
354
|
})
|
|
197
355
|
if (limitedItems.length) {
|
|
198
356
|
await new Promise((r) => setTimeout(r, 500))
|
|
199
|
-
await Promise.all(limitedItems.map((item) => this.#single(item)))
|
|
357
|
+
await Promise.all(limitedItems.map((item) => this.#single(item, 0)))
|
|
200
358
|
}
|
|
201
359
|
}
|
|
202
360
|
|
|
203
361
|
/** 按当前的 maxBatchSize 重新分批发送(每批都比原来小,不会无限循环) */
|
|
204
362
|
async #redispatch(items: Pending[]): Promise<void> {
|
|
205
|
-
|
|
206
|
-
const chunks: Pending[][] = []
|
|
207
|
-
for (let i = 0; i < items.length; i += size) {
|
|
208
|
-
chunks.push(items.slice(i, i + size))
|
|
209
|
-
}
|
|
210
|
-
await Promise.all(chunks.map((chunk) => this.#dispatch(chunk)))
|
|
363
|
+
await Promise.all(this.#split(items).map((chunk) => this.#dispatch(chunk)))
|
|
211
364
|
}
|
|
212
365
|
|
|
213
|
-
async #single(item: Pending): Promise<void> {
|
|
366
|
+
async #single(item: Pending, retries = this.#retries): Promise<void> {
|
|
214
367
|
try {
|
|
215
|
-
const body = (await this.#post({ jsonrpc: '2.0', id: nextId++, method: item.method, params: item.params })) as RpcResponse
|
|
368
|
+
const body = (await this.#post({ jsonrpc: '2.0', id: nextId++, method: item.method, params: item.params }, retries)) as RpcResponse
|
|
216
369
|
settle(item, body)
|
|
217
370
|
} catch (err) {
|
|
218
371
|
item.reject(err)
|
|
219
372
|
}
|
|
220
373
|
}
|
|
221
374
|
|
|
375
|
+
/** 节点明确表示不支持批量:10 分钟内逐条请求,之后重新尝试批量(节点可能只是临时出错或已经升级) */
|
|
222
376
|
#disableBatch(): void {
|
|
223
|
-
this.#
|
|
377
|
+
this.#batchDisabledUntil = Date.now() + BATCH_RETRY_AFTER
|
|
224
378
|
}
|
|
225
379
|
|
|
226
|
-
async #post(payload: unknown): Promise<unknown> {
|
|
380
|
+
async #post(payload: unknown, retries = this.#retries): Promise<unknown> {
|
|
227
381
|
for (let attempt = 0; ; attempt++) {
|
|
228
382
|
let response: Response
|
|
229
383
|
try {
|
|
@@ -234,25 +388,34 @@ export class HttpRpc implements RpcTransport {
|
|
|
234
388
|
signal: this.#timeout > 0 && Number.isFinite(this.#timeout) ? AbortSignal.timeout(this.#timeout) : undefined,
|
|
235
389
|
})
|
|
236
390
|
} catch (err) {
|
|
237
|
-
|
|
391
|
+
// AbortSignal.timeout 触发时是 TimeoutError(DOMException)
|
|
392
|
+
const timeout = (err as { name?: unknown } | null)?.name === 'TimeoutError'
|
|
393
|
+
throw new HttpError(`Request to ${this.#safeUrl} failed: ${(err as Error)?.message ?? err}`, undefined, timeout)
|
|
238
394
|
}
|
|
239
|
-
if (response.status === 429 && attempt <
|
|
395
|
+
if (response.status === 429 && attempt < retries) {
|
|
240
396
|
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt))
|
|
241
397
|
continue
|
|
242
398
|
}
|
|
243
|
-
|
|
399
|
+
let text: string
|
|
400
|
+
try {
|
|
401
|
+
text = await response.text()
|
|
402
|
+
} catch (err) {
|
|
403
|
+
// 读响应体时超时或连接中断:同样包装成 HttpError(原生 DOMException / TypeError 不会被当成可恢复的节点问题)
|
|
404
|
+
const timeout = (err as { name?: unknown } | null)?.name === 'TimeoutError'
|
|
405
|
+
throw new HttpError(`Reading response from ${this.#safeUrl} failed: ${(err as Error)?.message ?? err}`, response.status, timeout)
|
|
406
|
+
}
|
|
244
407
|
let body: unknown
|
|
245
408
|
try {
|
|
246
409
|
body = parseJson(text)
|
|
247
410
|
} catch {
|
|
248
|
-
throw new HttpError(`Invalid JSON from ${this
|
|
411
|
+
throw new HttpError(`Invalid JSON from ${this.#safeUrl} (HTTP ${response.status})`, response.status)
|
|
249
412
|
}
|
|
250
413
|
// 非 2xx 但返回了 JSON-RPC 结构(如 403 Access forbidden、drpc 的 500 + 批量错误数组):交给上层解析
|
|
251
414
|
if (!response.ok && !Array.isArray(body) && !(body as RpcResponse)?.error) {
|
|
252
|
-
throw new HttpError(`HTTP ${response.status} from ${this
|
|
415
|
+
throw new HttpError(`HTTP ${response.status} from ${this.#safeUrl}`, response.status)
|
|
253
416
|
}
|
|
254
417
|
// 有的节点限频时返回 HTTP 200 + JSON-RPC 错误码 429:同样退避重试
|
|
255
|
-
if (attempt <
|
|
418
|
+
if (attempt < retries && isRateLimited(body)) {
|
|
256
419
|
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt))
|
|
257
420
|
continue
|
|
258
421
|
}
|
|
@@ -279,7 +442,25 @@ function parseJson(text: string): unknown {
|
|
|
279
442
|
typeof value === 'number' && !Number.isSafeInteger(value) && Number.isInteger(value) && context?.source ? context.source : value,
|
|
280
443
|
)
|
|
281
444
|
}
|
|
282
|
-
return JSON.parse(text
|
|
445
|
+
return JSON.parse(quoteBigIntegers(text))
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* 把 JSON 文本里数值位置上 16 位以上的整数改成字符串(不支持 source text access 的环境用)。
|
|
450
|
+
* 跳过字符串字面量:错误信息等字符串里的长数字不会被改动
|
|
451
|
+
*/
|
|
452
|
+
export function quoteBigIntegers(text: string): string {
|
|
453
|
+
return text.replace(/"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, (token) =>
|
|
454
|
+
token[0] !== '"' && /^-?\d{16,}$/.test(token) ? `"${token}"` : token,
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function safeOrigin(url: string): string {
|
|
459
|
+
try {
|
|
460
|
+
return new URL(url).origin
|
|
461
|
+
} catch {
|
|
462
|
+
return 'invalid URL'
|
|
463
|
+
}
|
|
283
464
|
}
|
|
284
465
|
|
|
285
466
|
function isRateLimited(body: unknown): boolean {
|
|
@@ -297,19 +478,143 @@ function settle(item: Pending, response: RpcResponse | undefined): void {
|
|
|
297
478
|
}
|
|
298
479
|
}
|
|
299
480
|
|
|
481
|
+
/**
|
|
482
|
+
* onRequest 的事件:每个 JSON-RPC 调用对每个节点的每次尝试一条(合并成批量 HTTP 请求时,批里每个调用各一条,
|
|
483
|
+
* ms 是该调用从发出到返回的时间;HttpRpc 内部的 429 退避重试算在同一条里)
|
|
484
|
+
*/
|
|
485
|
+
export interface RequestEvent {
|
|
486
|
+
/** 节点名称:URL 的 host,自定义传输的 label(没有时为 'custom') */
|
|
487
|
+
node: string
|
|
488
|
+
method: string
|
|
489
|
+
ms: number
|
|
490
|
+
ok: boolean
|
|
491
|
+
/** 失败时的错误 */
|
|
492
|
+
error?: unknown
|
|
493
|
+
/** 本次调用按故障切换顺序尝试的第几个节点(0 起);大于 0 说明换过节点 */
|
|
494
|
+
attempt: number
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const requestListeners = new Set<(event: RequestEvent) => void>()
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* 监听所有客户端发出的 JSON-RPC 调用(节点、方法、耗时、成功与否、第几次尝试),用于统计和排查。返回取消监听的函数。
|
|
501
|
+
*
|
|
502
|
+
* ```ts
|
|
503
|
+
* const stop = onRequest((e) => console.log(e.node, e.method, e.ms, e.ok, e.attempt))
|
|
504
|
+
* ```
|
|
505
|
+
*/
|
|
506
|
+
export function onRequest(listener: (event: RequestEvent) => void): () => void {
|
|
507
|
+
requestListeners.add(listener)
|
|
508
|
+
return () => {
|
|
509
|
+
requestListeners.delete(listener)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function emit(event: RequestEvent): void {
|
|
514
|
+
for (const listener of requestListeners) {
|
|
515
|
+
try {
|
|
516
|
+
listener(event)
|
|
517
|
+
} catch {
|
|
518
|
+
// 监听函数出错不影响请求
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** 节点名称:优先用传输自带的 label;HttpRpc 取 URL 的 host(不带 path / query,避免泄露 API Key) */
|
|
524
|
+
export function nodeLabel(node: RpcTransport): string {
|
|
525
|
+
if (typeof node.label === 'string') {
|
|
526
|
+
return node.label
|
|
527
|
+
}
|
|
528
|
+
const base = unwrap(node)
|
|
529
|
+
if (base !== node && typeof base.label === 'string') {
|
|
530
|
+
return base.label
|
|
531
|
+
}
|
|
532
|
+
if (base instanceof HttpRpc) {
|
|
533
|
+
try {
|
|
534
|
+
return new URL(base.url).host || 'custom'
|
|
535
|
+
} catch {
|
|
536
|
+
return 'custom'
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return 'custom'
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** 去掉校验层等包装(有 inner 属性的传输),取底层节点 */
|
|
543
|
+
function unwrap(node: RpcTransport): RpcTransport {
|
|
544
|
+
let current = node
|
|
545
|
+
for (let inner = (current as { inner?: RpcTransport }).inner; inner && typeof inner.request === 'function'; inner = (current as { inner?: RpcTransport }).inner) {
|
|
546
|
+
current = inner
|
|
547
|
+
}
|
|
548
|
+
return current
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
interface NodeHealth {
|
|
552
|
+
/** 上次出错(进入冷却)的时间,0 表示健康 */
|
|
553
|
+
failedAt: number
|
|
554
|
+
/** 连续超时次数 */
|
|
555
|
+
timeouts: number
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// 节点健康状态按节点共享(模块级):客户端重建后,刚出过错的节点仍排在后面。URL 节点按完整 URL,其他按对象
|
|
559
|
+
const healthByUrl = new Map<string, NodeHealth>()
|
|
560
|
+
let healthByObject = new WeakMap<object, NodeHealth>()
|
|
561
|
+
const MAX_TRACKED_URLS = 1000
|
|
562
|
+
/** 连续超时这么多次才冷却(单次超时多半只是这次请求慢) */
|
|
563
|
+
const TIMEOUTS_BEFORE_COOLDOWN = 3
|
|
564
|
+
|
|
565
|
+
/** 健康状态的 key:HttpRpc 按完整 URL(不同连接对象共享),其他节点按对象(去掉校验层) */
|
|
566
|
+
function healthKeyOf(node: RpcTransport): string | object {
|
|
567
|
+
const base = unwrap(node)
|
|
568
|
+
return base instanceof HttpRpc ? base.url : base
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** 每次用时按 key 从全局表里取:表淘汰条目后,各实例仍取到同一个对象 */
|
|
572
|
+
function healthOf(key: string | object): NodeHealth {
|
|
573
|
+
if (typeof key === 'string') {
|
|
574
|
+
let health = healthByUrl.get(key)
|
|
575
|
+
if (!health) {
|
|
576
|
+
health = { failedAt: 0, timeouts: 0 }
|
|
577
|
+
healthByUrl.set(key, health)
|
|
578
|
+
if (healthByUrl.size > MAX_TRACKED_URLS) {
|
|
579
|
+
healthByUrl.delete(healthByUrl.keys().next().value as string)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return health
|
|
583
|
+
}
|
|
584
|
+
let health = healthByObject.get(key)
|
|
585
|
+
if (!health) {
|
|
586
|
+
health = { failedAt: 0, timeouts: 0 }
|
|
587
|
+
healthByObject.set(key, health)
|
|
588
|
+
}
|
|
589
|
+
return health
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** 测试用:清空节点健康状态 */
|
|
593
|
+
export function resetNodeHealth(): void {
|
|
594
|
+
healthByUrl.clear()
|
|
595
|
+
healthByObject = new WeakMap()
|
|
596
|
+
}
|
|
597
|
+
|
|
300
598
|
export interface FallbackOptions {
|
|
301
599
|
/** 出错节点的冷却时间(毫秒),期间排到最后。默认 30000 */
|
|
302
600
|
cooldown?: number
|
|
303
601
|
}
|
|
304
602
|
|
|
305
603
|
/**
|
|
306
|
-
* 多节点故障切换:按顺序使用,节点问题(网络、超时、限频、需要 Key、403
|
|
307
|
-
*
|
|
604
|
+
* 多节点故障切换:按顺序使用,节点问题(网络、超时、限频、需要 Key、403、高度不够等)换下一个;
|
|
605
|
+
* 参数错误等确定性错误直接抛出。单个节点也用它包一层(统一 onRequest 事件),此时失败抛原始错误。
|
|
606
|
+
*
|
|
607
|
+
* - 出错的节点在冷却期内排到最后;健康状态按节点全局共享(URL 相同即同一节点),客户端重建后仍然有效
|
|
608
|
+
* - 超时只换节点、不冷却(大批量查询慢一点很正常),同一节点连续 3 次超时才冷却
|
|
609
|
+
* - 节点高度没到 minContextSlot 只换节点、不冷却(只是这次请求要求的 slot 太新)
|
|
610
|
+
* - 多个节点都失败时抛 AllNodesFailedError,带上每个节点的错误
|
|
308
611
|
*/
|
|
309
612
|
export class FallbackRpc implements RpcTransport {
|
|
310
613
|
readonly nodes: readonly RpcTransport[]
|
|
311
614
|
readonly #cooldown: number
|
|
312
|
-
|
|
615
|
+
/** 节点标识和健康状态的 key 不会变,构造时算好(每次请求只做一次 Map 查找) */
|
|
616
|
+
readonly #labels: readonly string[]
|
|
617
|
+
readonly #healthKeys: ReadonlyArray<string | object>
|
|
313
618
|
|
|
314
619
|
constructor(nodes: readonly RpcTransport[], options: FallbackOptions = {}) {
|
|
315
620
|
if (!nodes.length) {
|
|
@@ -317,33 +622,54 @@ export class FallbackRpc implements RpcTransport {
|
|
|
317
622
|
}
|
|
318
623
|
this.nodes = nodes
|
|
319
624
|
this.#cooldown = options.cooldown ?? 30_000
|
|
320
|
-
this.#
|
|
625
|
+
this.#labels = nodes.map(nodeLabel)
|
|
626
|
+
this.#healthKeys = nodes.map(healthKeyOf)
|
|
321
627
|
}
|
|
322
628
|
|
|
323
629
|
async request<T = unknown>(method: string, params?: readonly unknown[]): Promise<T> {
|
|
324
|
-
|
|
325
|
-
|
|
630
|
+
const errors: Array<{ node: string; error: unknown }> = []
|
|
631
|
+
const order = this.#order()
|
|
632
|
+
for (let attempt = 0; attempt < order.length; attempt++) {
|
|
633
|
+
const index = order[attempt] as number
|
|
634
|
+
const node = this.nodes[index] as RpcTransport
|
|
635
|
+
const label = this.#labels[index] as string
|
|
636
|
+
const health = healthOf(this.#healthKeys[index] as string | object)
|
|
637
|
+
const started = Date.now()
|
|
326
638
|
try {
|
|
327
|
-
const result = await
|
|
328
|
-
|
|
639
|
+
const result = await node.request<T>(method, params)
|
|
640
|
+
health.failedAt = 0
|
|
641
|
+
health.timeouts = 0
|
|
642
|
+
emit({ node: label, method, ms: Date.now() - started, ok: true, attempt })
|
|
329
643
|
return result
|
|
330
644
|
} catch (err) {
|
|
645
|
+
emit({ node: label, method, ms: Date.now() - started, ok: false, error: err, attempt })
|
|
331
646
|
if (!isNodeFault(err)) {
|
|
332
647
|
throw err
|
|
333
648
|
}
|
|
334
|
-
|
|
335
|
-
|
|
649
|
+
if (isTimeout(err)) {
|
|
650
|
+
if (++health.timeouts >= TIMEOUTS_BEFORE_COOLDOWN) {
|
|
651
|
+
health.failedAt = Date.now()
|
|
652
|
+
health.timeouts = 0
|
|
653
|
+
}
|
|
654
|
+
} else if (!isBehind(err)) {
|
|
655
|
+
health.failedAt = Date.now()
|
|
656
|
+
}
|
|
657
|
+
errors.push({ node: label, error: err })
|
|
336
658
|
}
|
|
337
659
|
}
|
|
338
|
-
|
|
660
|
+
if (this.nodes.length === 1) {
|
|
661
|
+
throw errors[0]?.error
|
|
662
|
+
}
|
|
663
|
+
throw new AllNodesFailedError(errors)
|
|
339
664
|
}
|
|
340
665
|
|
|
341
666
|
#order(): number[] {
|
|
342
667
|
const now = Date.now()
|
|
668
|
+
const failedAt = this.#healthKeys.map((key) => healthOf(key).failedAt)
|
|
343
669
|
const healthy: number[] = []
|
|
344
670
|
const cooling: number[] = []
|
|
345
|
-
|
|
346
|
-
cooling.sort((a, b) => (
|
|
671
|
+
failedAt.forEach((at, i) => (at && now - at < this.#cooldown ? cooling : healthy).push(i))
|
|
672
|
+
cooling.sort((a, b) => (failedAt[a] as number) - (failedAt[b] as number))
|
|
347
673
|
return [...healthy, ...cooling]
|
|
348
674
|
}
|
|
349
675
|
}
|