@w3lib/solana-batch-call 0.6.0 → 0.7.0
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 +33 -0
- package/README.md +16 -8
- package/dist/accountReader.d.ts +57 -0
- package/dist/accountReader.d.ts.map +1 -0
- package/dist/accountReader.js +149 -0
- package/dist/accountReader.js.map +1 -0
- package/dist/address.d.ts +9 -0
- package/dist/address.d.ts.map +1 -1
- package/dist/address.js +36 -6
- package/dist/address.js.map +1 -1
- package/dist/approvals.d.ts +5 -3
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +25 -36
- package/dist/approvals.js.map +1 -1
- package/dist/balances.d.ts +178 -0
- package/dist/balances.d.ts.map +1 -0
- package/dist/balances.js +524 -0
- package/dist/balances.js.map +1 -0
- package/dist/batchPlan.d.ts +2 -2
- package/dist/batchPlan.d.ts.map +1 -1
- package/dist/batchPlan.js +5 -26
- package/dist/batchPlan.js.map +1 -1
- package/dist/bytes.d.ts +31 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +74 -0
- package/dist/bytes.js.map +1 -0
- package/dist/claim.d.ts +5 -3
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +10 -10
- package/dist/claim.js.map +1 -1
- package/dist/client.d.ts +26 -41
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +41 -986
- package/dist/client.js.map +1 -1
- package/dist/clientConfig.d.ts +11 -0
- package/dist/clientConfig.d.ts.map +1 -0
- package/dist/clientConfig.js +3 -0
- package/dist/clientConfig.js.map +1 -0
- package/dist/decode.d.ts.map +1 -1
- package/dist/decode.js +2 -29
- package/dist/decode.js.map +1 -1
- package/dist/fees.d.ts.map +1 -1
- package/dist/fees.js +2 -12
- package/dist/fees.js.map +1 -1
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +4 -15
- package/dist/history.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +21 -0
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +50 -69
- package/dist/layout.js.map +1 -1
- package/dist/liquidity/common.js +2 -2
- package/dist/liquidity/common.js.map +1 -1
- package/dist/lp.d.ts +9 -13
- package/dist/lp.d.ts.map +1 -1
- package/dist/lp.js +67 -55
- package/dist/lp.js.map +1 -1
- package/dist/portfolio.d.ts.map +1 -1
- package/dist/portfolio.js +3 -8
- package/dist/portfolio.js.map +1 -1
- package/dist/reclaim.d.ts +4 -2
- package/dist/reclaim.d.ts.map +1 -1
- package/dist/reclaim.js +16 -16
- package/dist/reclaim.js.map +1 -1
- package/dist/risk.js +2 -2
- package/dist/risk.js.map +1 -1
- package/dist/rpc.d.ts +30 -7
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +400 -109
- package/dist/rpc.js.map +1 -1
- package/dist/simulate.d.ts.map +1 -1
- package/dist/simulate.js +3 -26
- package/dist/simulate.js.map +1 -1
- package/dist/source.d.ts +8 -4
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +11 -4
- package/dist/source.js.map +1 -1
- package/dist/subpaths/approvals.d.ts +3 -3
- package/dist/subpaths/approvals.d.ts.map +1 -1
- package/dist/subpaths/approvals.js.map +1 -1
- package/dist/subpaths/claim.d.ts +3 -3
- package/dist/subpaths/claim.d.ts.map +1 -1
- package/dist/subpaths/claim.js.map +1 -1
- package/dist/subpaths/reclaim.d.ts +3 -3
- package/dist/subpaths/reclaim.d.ts.map +1 -1
- package/dist/subpaths/reclaim.js.map +1 -1
- package/dist/swap/index.js +3 -3
- package/dist/swap/index.js.map +1 -1
- package/dist/tokenDetails.d.ts +12 -0
- package/dist/tokenDetails.d.ts.map +1 -0
- package/dist/tokenDetails.js +196 -0
- package/dist/tokenDetails.js.map +1 -0
- package/dist/tokenMeta.d.ts +63 -0
- package/dist/tokenMeta.d.ts.map +1 -0
- package/dist/tokenMeta.js +226 -0
- package/dist/tokenMeta.js.map +1 -0
- package/dist/tradeCheck.js +3 -3
- package/dist/tradeCheck.js.map +1 -1
- package/dist/util.d.ts +22 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +107 -0
- package/dist/util.js.map +1 -0
- package/dist/write/chain.d.ts +51 -0
- package/dist/write/chain.d.ts.map +1 -0
- package/dist/write/chain.js +184 -0
- package/dist/write/chain.js.map +1 -0
- package/dist/write/collect.js +2 -2
- package/dist/write/collect.js.map +1 -1
- package/dist/write/errors.d.ts +10 -0
- package/dist/write/errors.d.ts.map +1 -0
- package/dist/write/errors.js +77 -0
- package/dist/write/errors.js.map +1 -0
- package/dist/write/pack.d.ts +28 -0
- package/dist/write/pack.d.ts.map +1 -0
- package/dist/write/pack.js +125 -0
- package/dist/write/pack.js.map +1 -0
- package/dist/write/send.d.ts +23 -9
- package/dist/write/send.d.ts.map +1 -1
- package/dist/write/send.js +53 -351
- package/dist/write/send.js.map +1 -1
- package/dist/write/tracker.d.ts +24 -0
- package/dist/write/tracker.d.ts.map +1 -0
- package/dist/write/tracker.js +129 -0
- package/dist/write/tracker.js.map +1 -0
- package/package.json +1 -1
- package/src/accountReader.ts +187 -0
- package/src/address.ts +41 -6
- package/src/approvals.ts +36 -44
- package/src/balances.ts +678 -0
- package/src/batchPlan.ts +6 -26
- package/src/bytes.ts +89 -0
- package/src/claim.ts +15 -11
- package/src/client.ts +59 -1239
- package/src/clientConfig.ts +12 -0
- package/src/decode.ts +4 -30
- package/src/fees.ts +2 -9
- package/src/history.ts +4 -15
- package/src/index.ts +13 -1
- package/src/layout.ts +63 -82
- package/src/liquidity/common.ts +2 -2
- package/src/lp.ts +86 -60
- package/src/portfolio.ts +8 -11
- package/src/reclaim.ts +20 -17
- package/src/risk.ts +2 -2
- package/src/rpc.ts +472 -116
- package/src/simulate.ts +3 -21
- package/src/source.ts +14 -5
- package/src/subpaths/approvals.ts +3 -2
- package/src/subpaths/claim.ts +3 -2
- package/src/subpaths/reclaim.ts +3 -2
- package/src/swap/index.ts +3 -3
- package/src/tokenDetails.ts +240 -0
- package/src/tokenMeta.ts +270 -0
- package/src/tradeCheck.ts +3 -3
- package/src/util.ts +110 -0
- package/src/write/chain.ts +218 -0
- package/src/write/collect.ts +2 -2
- package/src/write/errors.ts +82 -0
- package/src/write/pack.ts +142 -0
- package/src/write/send.ts +74 -407
- package/src/write/tracker.ts +152 -0
package/src/rpc.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
import { mapPool, sleep } from './util.js'
|
|
2
|
+
|
|
3
|
+
/** RpcTransport.request 的第三个参数(可选,自定义传输可以忽略) */
|
|
4
|
+
export interface RpcRequestOptions {
|
|
5
|
+
/**
|
|
6
|
+
* 取消:已取消时不发请求,中途取消时 reject(reason 为 signal.reason)。
|
|
7
|
+
* HttpRpc 会把它传到 fetch:还在排队的调用直接移出队列,已发出的批量请求在所有成员都取消后中止;FallbackRpc 取消后不再换节点
|
|
8
|
+
*/
|
|
9
|
+
signal?: AbortSignal
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 最小的 JSON-RPC 传输接口:可以传入自己的实现(如包一层 @solana/kit 的 rpc);options 是 0.6.1 新增的可选参数,不处理也能照常工作 */
|
|
2
13
|
export interface RpcTransport {
|
|
3
|
-
request<T = unknown>(method: string, params?: readonly unknown[]): Promise<T>
|
|
14
|
+
request<T = unknown>(method: string, params?: readonly unknown[], options?: RpcRequestOptions): Promise<T>
|
|
4
15
|
/** 节点名称,用于 AllNodesFailedError / onRequest 里标识节点;不传时自定义传输显示为 'custom' */
|
|
5
16
|
readonly label?: string
|
|
6
17
|
}
|
|
@@ -71,19 +82,54 @@ const REQUEST_BLOCKED = /request blocked/i
|
|
|
71
82
|
const BLOCKED_LIMIT = 10
|
|
72
83
|
const BLOCKED_FLOOR = 8
|
|
73
84
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
85
|
+
/** 节点的限制:单个方法在一个批量里的数量、单次 getMultipleAccounts 的账户数 */
|
|
86
|
+
interface NodeLimits {
|
|
87
|
+
methods: Map<string, number>
|
|
88
|
+
maxAccounts?: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 按节点地址记的表(健康状态、学到的限制、在途请求数)的条目上限,超出时淘汰最早加入的 */
|
|
92
|
+
const MAX_TRACKED_URLS = 1000
|
|
76
93
|
|
|
77
|
-
|
|
78
|
-
|
|
94
|
+
/** 按节点地址取条目,没有时创建;表的条目数有上限(淘汰最早加入的) */
|
|
95
|
+
function trackedEntry<V>(map: Map<string, V>, url: string, create: () => V): V {
|
|
96
|
+
let entry = map.get(url)
|
|
79
97
|
if (!entry) {
|
|
80
|
-
entry =
|
|
81
|
-
|
|
98
|
+
entry = create()
|
|
99
|
+
map.set(url, entry)
|
|
100
|
+
if (map.size > MAX_TRACKED_URLS) {
|
|
101
|
+
map.delete(map.keys().next().value as string)
|
|
102
|
+
}
|
|
82
103
|
}
|
|
83
104
|
return entry
|
|
84
105
|
}
|
|
85
106
|
|
|
86
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* 已知节点的限制(2026-10 实测),首次请求就按它拆分,不必先撞一次报错:
|
|
109
|
+
* publicnode 单次 getMultipleAccounts 最多 10 个账户、一个批量里最多 1 个 getMultipleAccounts,
|
|
110
|
+
* 超过账户数要等 3 秒多才返回 403
|
|
111
|
+
*/
|
|
112
|
+
function presetLimits(url: string): NodeLimits {
|
|
113
|
+
let host = ''
|
|
114
|
+
try {
|
|
115
|
+
host = new URL(url).hostname
|
|
116
|
+
} catch {
|
|
117
|
+
// 地址不合法:没有预置限制
|
|
118
|
+
}
|
|
119
|
+
if (/^solana[\w-]*\.publicnode\.com$/i.test(host)) {
|
|
120
|
+
return { methods: new Map([['getMultipleAccounts', 1]]), maxAccounts: BLOCKED_LIMIT }
|
|
121
|
+
}
|
|
122
|
+
return { methods: new Map() }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 从报错里学到的节点限制(含预置的已知限制),按节点地址共享(同一个节点的多个客户端不用各自再试一遍) */
|
|
126
|
+
const learnedLimits = new Map<string, NodeLimits>()
|
|
127
|
+
|
|
128
|
+
function learnedFor(url: string): NodeLimits {
|
|
129
|
+
return trackedEntry(learnedLimits, url, () => presetLimits(url))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 测试用:清空学到的节点限制(预置的已知限制在下次创建连接时重新带上) */
|
|
87
133
|
export function resetLearnedLimits(): void {
|
|
88
134
|
learnedLimits.clear()
|
|
89
135
|
}
|
|
@@ -149,7 +195,10 @@ export function isNodeFault(err: unknown): boolean {
|
|
|
149
195
|
}
|
|
150
196
|
|
|
151
197
|
export interface HttpRpcOptions {
|
|
152
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* 单次 HTTP 请求超时(毫秒),<= 0 不限制。默认 10000。排队等并发名额(maxConcurrency)最多也等这么久,
|
|
200
|
+
* 等不到按超时处理(多节点时换节点);拿到名额、真正发出后重新计时
|
|
201
|
+
*/
|
|
153
202
|
timeout?: number
|
|
154
203
|
headers?: Record<string, string>
|
|
155
204
|
/** 一个 HTTP 批量请求最多包含的调用数。默认 20 */
|
|
@@ -158,17 +207,43 @@ export interface HttpRpcOptions {
|
|
|
158
207
|
batch?: boolean
|
|
159
208
|
/** 收集窗口(毫秒),窗口内的调用合并成一个 HTTP 请求。默认 0(同一 tick) */
|
|
160
209
|
batchWait?: number
|
|
161
|
-
/** 遇到 HTTP 429 时的重试次数(指数退避,从 500ms
|
|
210
|
+
/** 遇到 HTTP 429 时的重试次数(指数退避,从 500ms 开始,带随机抖动;有 Retry-After 头时按它等)。默认 2 */
|
|
162
211
|
retries?: number
|
|
163
212
|
/** 自定义 fetch(如需要代理时) */
|
|
164
213
|
fetch?: typeof fetch
|
|
214
|
+
/**
|
|
215
|
+
* 同一节点地址同时在途的 HTTP 请求数上限(按 URL 在所有 HttpRpc 实例间共享,超出的排队)。默认 4;<= 0 或 Infinity 不限制
|
|
216
|
+
*/
|
|
217
|
+
maxConcurrency?: number
|
|
218
|
+
/** 同一个批量请求里 method 和 params 完全相同的只读调用只发一次、结果分给各调用方。默认 true */
|
|
219
|
+
dedupe?: boolean
|
|
165
220
|
}
|
|
166
221
|
|
|
222
|
+
/** 一个调用方(同一调用去重后可能有多个) */
|
|
223
|
+
interface Waiter {
|
|
224
|
+
resolve: (value: unknown) => void
|
|
225
|
+
reject: (reason: unknown) => void
|
|
226
|
+
signal?: AbortSignal
|
|
227
|
+
cleanup?: () => void
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 队列里的一个调用(去重后:method + params 相同的调用方共用一个) */
|
|
167
231
|
interface Pending {
|
|
168
232
|
method: string
|
|
169
233
|
params: readonly unknown[]
|
|
170
|
-
|
|
171
|
-
|
|
234
|
+
/** 去重用的 key;不参与去重时为 undefined */
|
|
235
|
+
key: string | undefined
|
|
236
|
+
waiters: Waiter[]
|
|
237
|
+
/** 已经有结果(或已移出队列) */
|
|
238
|
+
done: boolean
|
|
239
|
+
/** 包含它、正在进行的 HTTP 请求 */
|
|
240
|
+
posts: Set<Post>
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 一次 HTTP 请求(单个或批量):成员都取消后中止 */
|
|
244
|
+
interface Post {
|
|
245
|
+
items: readonly Pending[]
|
|
246
|
+
controller: AbortController
|
|
172
247
|
}
|
|
173
248
|
|
|
174
249
|
interface RpcResponse {
|
|
@@ -182,16 +257,145 @@ let nextId = 1
|
|
|
182
257
|
/** 批量请求被拒绝、可以降级为逐条请求的 HTTP 状态 */
|
|
183
258
|
const BATCH_REJECTED_STATUS = new Set([400, 404, 405, 415, 422])
|
|
184
259
|
|
|
185
|
-
/**
|
|
260
|
+
/** 节点不支持批量 / 限制批量大小时,多久后重新尝试(批量、原来的批量大小) */
|
|
186
261
|
const BATCH_RETRY_AFTER = 10 * 60 * 1000
|
|
187
262
|
|
|
263
|
+
/** 有副作用、不能去重的方法 */
|
|
264
|
+
const WRITE_METHOD = /^(send|requestAirdrop)/
|
|
265
|
+
|
|
266
|
+
/** 429 时 Retry-After 超过这个时间(毫秒)就不等了,直接报错(交给上层换节点) */
|
|
267
|
+
const MAX_RETRY_AFTER = 10_000
|
|
268
|
+
|
|
188
269
|
function isBatchLimitError(message: string | undefined): boolean {
|
|
189
270
|
return !!message && /batch/i.test(message) && /more than \d+|too (large|many|big)|not (allowed|supported)|exceed/i.test(message)
|
|
190
271
|
}
|
|
191
272
|
|
|
273
|
+
/**
|
|
274
|
+
* 429 后的等待时间:有 Retry-After(秒数或 HTTP 日期)时按它,超过 MAX_RETRY_AFTER 时返回 null(不等,直接报错);
|
|
275
|
+
* 否则指数退避(500ms 起)加 ±25% 随机抖动,避免同时被限频的请求又同时重试
|
|
276
|
+
*/
|
|
277
|
+
export function retryDelay(attempt: number, retryAfter: string | null | undefined): number | null {
|
|
278
|
+
const header = retryAfter?.trim()
|
|
279
|
+
if (header) {
|
|
280
|
+
const seconds = Number(header)
|
|
281
|
+
const ms = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(header) - Date.now()
|
|
282
|
+
if (Number.isFinite(ms)) {
|
|
283
|
+
return ms > MAX_RETRY_AFTER ? null : Math.max(0, ms)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return Math.round(500 * 2 ** attempt * (0.75 + Math.random() * 0.5))
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** 合并多个 AbortSignal(AbortSignal.any 不可用的旧环境手动合并) */
|
|
290
|
+
function anySignal(signals: readonly AbortSignal[]): AbortSignal {
|
|
291
|
+
if (signals.length === 1) {
|
|
292
|
+
return signals[0] as AbortSignal
|
|
293
|
+
}
|
|
294
|
+
if (typeof AbortSignal.any === 'function') {
|
|
295
|
+
return AbortSignal.any(signals as AbortSignal[])
|
|
296
|
+
}
|
|
297
|
+
const controller = new AbortController()
|
|
298
|
+
for (const signal of signals) {
|
|
299
|
+
if (signal.aborted) {
|
|
300
|
+
controller.abort(signal.reason)
|
|
301
|
+
break
|
|
302
|
+
}
|
|
303
|
+
signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true, signal: controller.signal })
|
|
304
|
+
}
|
|
305
|
+
return controller.signal
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 同一节点地址的在途 HTTP 请求数(所有 HttpRpc 实例共享);waiting 里每项带自己的上限 */
|
|
309
|
+
interface Slots {
|
|
310
|
+
active: number
|
|
311
|
+
waiting: Array<{ limit: number; start: () => void }>
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const slotsByUrl = new Map<string, Slots>()
|
|
315
|
+
|
|
316
|
+
/** 拿一个并发名额(先来先得);signal 取消时不再等 */
|
|
317
|
+
function acquireSlot(slots: Slots, limit: number, signal: AbortSignal): Promise<void> {
|
|
318
|
+
if (signal.aborted) {
|
|
319
|
+
return Promise.reject(signal.reason)
|
|
320
|
+
}
|
|
321
|
+
if (slots.active < limit && !slots.waiting.length) {
|
|
322
|
+
slots.active++
|
|
323
|
+
return Promise.resolve()
|
|
324
|
+
}
|
|
325
|
+
return new Promise((resolve, reject) => {
|
|
326
|
+
const entry = {
|
|
327
|
+
limit,
|
|
328
|
+
start: () => {
|
|
329
|
+
signal.removeEventListener('abort', onAbort)
|
|
330
|
+
resolve()
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
const onAbort = () => {
|
|
334
|
+
const i = slots.waiting.indexOf(entry)
|
|
335
|
+
if (i !== -1) {
|
|
336
|
+
slots.waiting.splice(i, 1)
|
|
337
|
+
drainSlots(slots)
|
|
338
|
+
}
|
|
339
|
+
reject(signal.reason)
|
|
340
|
+
}
|
|
341
|
+
slots.waiting.push(entry)
|
|
342
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function releaseSlot(slots: Slots): void {
|
|
347
|
+
slots.active--
|
|
348
|
+
drainSlots(slots)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function drainSlots(slots: Slots): void {
|
|
352
|
+
for (let head = slots.waiting[0]; head && slots.active < head.limit; head = slots.waiting[0]) {
|
|
353
|
+
slots.waiting.shift()
|
|
354
|
+
slots.active++
|
|
355
|
+
head.start()
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** 调用方是否都已取消(没有调用方传 signal 时不会) */
|
|
360
|
+
function abandoned(item: Pending): boolean {
|
|
361
|
+
return item.waiters.every((w) => w.signal?.aborted)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** 还需要结果:没有结果、也没被所有调用方放弃 */
|
|
365
|
+
function live(item: Pending): boolean {
|
|
366
|
+
return !item.done && !abandoned(item)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** 去重后的结果按调用方各给一份:第一个调用方拿原对象,其余的拿副本(调用方修改结果不会影响别人) */
|
|
370
|
+
function copyResult(value: unknown): unknown {
|
|
371
|
+
if (value === null || typeof value !== 'object') {
|
|
372
|
+
return value
|
|
373
|
+
}
|
|
374
|
+
return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value))
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function resolveItem(item: Pending, value: unknown): void {
|
|
378
|
+
if (item.done) return
|
|
379
|
+
item.done = true
|
|
380
|
+
item.waiters.forEach((waiter, i) => {
|
|
381
|
+
waiter.cleanup?.()
|
|
382
|
+
waiter.resolve(i === 0 ? value : copyResult(value))
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function rejectItem(item: Pending, reason: unknown): void {
|
|
387
|
+
if (item.done) return
|
|
388
|
+
item.done = true
|
|
389
|
+
for (const waiter of item.waiters) {
|
|
390
|
+
waiter.cleanup?.()
|
|
391
|
+
waiter.reject(reason)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
192
395
|
/**
|
|
193
396
|
* 基于 fetch 的 JSON-RPC 传输:同一收集窗口内的调用合并成一个 JSON-RPC 批量请求。
|
|
194
397
|
* 节点不支持批量(返回非数组)或限制批量大小(如 drpc 免费档最多 3 条)时自动降级。
|
|
398
|
+
* 同一节点地址同时在途的 HTTP 请求数有上限(maxConcurrency,默认 4);request 的 signal 会传到 fetch
|
|
195
399
|
*/
|
|
196
400
|
export class HttpRpc implements RpcTransport {
|
|
197
401
|
readonly url: string
|
|
@@ -202,14 +406,20 @@ export class HttpRpc implements RpcTransport {
|
|
|
202
406
|
readonly #batchWait: number
|
|
203
407
|
readonly #retries: number
|
|
204
408
|
readonly #fetch: typeof fetch
|
|
205
|
-
#maxBatchSize: number
|
|
206
|
-
|
|
207
|
-
readonly #
|
|
409
|
+
readonly #maxBatchSize: number
|
|
410
|
+
readonly #maxConcurrency: number
|
|
411
|
+
readonly #dedupe: boolean
|
|
412
|
+
/** 节点限制批量大小(413、“more than N”)时临时调小的批量大小,到期恢复 maxBatchSize */
|
|
413
|
+
#batchLimit: { size: number; until: number } | null = null
|
|
414
|
+
/** 从报错里学到的限制(按节点地址共享,含预置的已知限制):单个方法在一个批量里的数量(如 publicnode:getMultipleAccounts 最多 1 个)、单次的账户数 */
|
|
415
|
+
readonly #learned: NodeLimits
|
|
208
416
|
/** options.batch:调用方是否允许批量 */
|
|
209
417
|
readonly #batchOption: boolean
|
|
210
418
|
/** 节点明确表示不支持批量时,在这之前逐条请求(之后重新尝试批量) */
|
|
211
419
|
#batchDisabledUntil = 0
|
|
212
420
|
#queue: Pending[] = []
|
|
421
|
+
/** 队列里可去重的调用(key → 调用),flush 时清空 */
|
|
422
|
+
#queuedByKey = new Map<string, Pending>()
|
|
213
423
|
#timer: ReturnType<typeof setTimeout> | null = null
|
|
214
424
|
|
|
215
425
|
constructor(url: string, options: HttpRpcOptions = {}) {
|
|
@@ -223,20 +433,25 @@ export class HttpRpc implements RpcTransport {
|
|
|
223
433
|
this.#batchWait = options.batchWait ?? 0
|
|
224
434
|
this.#retries = Math.max(0, options.retries ?? 2)
|
|
225
435
|
this.#fetch = options.fetch ?? ((...args) => fetch(...args))
|
|
436
|
+
const concurrency = options.maxConcurrency ?? 4
|
|
437
|
+
this.#maxConcurrency = concurrency > 0 ? concurrency : Number.POSITIVE_INFINITY
|
|
438
|
+
this.#dedupe = options.dedupe ?? true
|
|
226
439
|
}
|
|
227
440
|
|
|
228
|
-
request<T = unknown>(method: string, params: readonly unknown[] = []): Promise<T> {
|
|
441
|
+
request<T = unknown>(method: string, params: readonly unknown[] = [], options?: RpcRequestOptions): Promise<T> {
|
|
442
|
+
const signal = options?.signal
|
|
229
443
|
if (method === 'getMultipleAccounts' && Array.isArray(params[0])) {
|
|
230
|
-
return this.#getMultipleAccounts(params) as Promise<T>
|
|
444
|
+
return this.#getMultipleAccounts(params, signal) as Promise<T>
|
|
231
445
|
}
|
|
232
|
-
return this.#enqueue<T>(method, params)
|
|
446
|
+
return this.#enqueue<T>(method, params, signal)
|
|
233
447
|
}
|
|
234
448
|
|
|
235
449
|
/**
|
|
236
450
|
* getMultipleAccounts:节点限制单次账户数时(“Too many accounts requested”),减半后分多次请求、合并结果,
|
|
237
|
-
* 并记下上限,之后直接按上限拆分。合并后 context.slot
|
|
451
|
+
* 并记下上限,之后直接按上限拆分。合并后 context.slot 取最小值。
|
|
452
|
+
* 拆分后的子请求用并发池发出:一个失败后不再发后面的(节点多半在限频或拒绝),取消后也不再发
|
|
238
453
|
*/
|
|
239
|
-
async #getMultipleAccounts(params: readonly unknown[]): Promise<unknown> {
|
|
454
|
+
async #getMultipleAccounts(params: readonly unknown[], signal: AbortSignal | undefined): Promise<unknown> {
|
|
240
455
|
const [addresses, ...rest] = params as [unknown[], ...unknown[]]
|
|
241
456
|
const max = this.#learned.maxAccounts
|
|
242
457
|
if (max !== undefined && addresses.length > max) {
|
|
@@ -244,7 +459,10 @@ export class HttpRpc implements RpcTransport {
|
|
|
244
459
|
for (let i = 0; i < addresses.length; i += max) {
|
|
245
460
|
parts.push(addresses.slice(i, i + max))
|
|
246
461
|
}
|
|
247
|
-
|
|
462
|
+
// 同时排队的子请求数:够把所有并发名额用满即可(每个 HTTP 请求能带几个 getMultipleAccounts × 并发上限)
|
|
463
|
+
const perPost = this.#batch ? (this.#learned.methods.get('getMultipleAccounts') ?? this.#batchSize) : 1
|
|
464
|
+
const limit = Number.isFinite(this.#maxConcurrency) ? this.#maxConcurrency * perPost : parts.length
|
|
465
|
+
const results = (await mapPool(parts, limit, (part) => this.#getMultipleAccounts([part, ...rest], signal))) as Array<{
|
|
248
466
|
context?: { slot?: number }
|
|
249
467
|
value?: unknown[]
|
|
250
468
|
}>
|
|
@@ -256,7 +474,7 @@ export class HttpRpc implements RpcTransport {
|
|
|
256
474
|
}
|
|
257
475
|
}
|
|
258
476
|
try {
|
|
259
|
-
return await this.#enqueue('getMultipleAccounts', params)
|
|
477
|
+
return await this.#enqueue('getMultipleAccounts', params, signal)
|
|
260
478
|
} catch (err) {
|
|
261
479
|
const tooMany = err instanceof RpcError && TOO_MANY_ACCOUNTS.test(err.message) && addresses.length > 1
|
|
262
480
|
const blocked = err instanceof RpcError && REQUEST_BLOCKED.test(err.message) && addresses.length > BLOCKED_FLOOR
|
|
@@ -265,7 +483,7 @@ export class HttpRpc implements RpcTransport {
|
|
|
265
483
|
const stated = Number(/maximum of (\d+) accounts/i.exec(err.message)?.[1])
|
|
266
484
|
const next = stated >= 1 ? stated : blocked && addresses.length > BLOCKED_LIMIT ? BLOCKED_LIMIT : Math.floor(addresses.length / 2)
|
|
267
485
|
this.#learned.maxAccounts = Math.max(1, Math.min(this.#learned.maxAccounts ?? Number.POSITIVE_INFINITY, next))
|
|
268
|
-
return this.#getMultipleAccounts(params)
|
|
486
|
+
return this.#getMultipleAccounts(params, signal)
|
|
269
487
|
}
|
|
270
488
|
throw err
|
|
271
489
|
}
|
|
@@ -276,10 +494,51 @@ export class HttpRpc implements RpcTransport {
|
|
|
276
494
|
return this.#batchOption && Date.now() >= this.#batchDisabledUntil
|
|
277
495
|
}
|
|
278
496
|
|
|
279
|
-
|
|
497
|
+
/** 当前的批量大小:节点限制过批量大小时,冷却期内用调小后的值,到期恢复 */
|
|
498
|
+
get #batchSize(): number {
|
|
499
|
+
const limit = this.#batchLimit
|
|
500
|
+
if (limit && Date.now() < limit.until) {
|
|
501
|
+
return limit.size
|
|
502
|
+
}
|
|
503
|
+
this.#batchLimit = null
|
|
504
|
+
return this.#maxBatchSize
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** 节点限制批量大小:10 分钟内按 size 分批,之后恢复(偶发的 413 不会永久缩小批量) */
|
|
508
|
+
#limitBatchSize(size: number): void {
|
|
509
|
+
this.#batchLimit = { size: Math.max(1, Math.min(size, this.#batchSize)), until: Date.now() + BATCH_RETRY_AFTER }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#enqueue<T>(method: string, params: readonly unknown[], signal: AbortSignal | undefined): Promise<T> {
|
|
513
|
+
if (signal?.aborted) {
|
|
514
|
+
return Promise.reject(signal.reason)
|
|
515
|
+
}
|
|
280
516
|
return new Promise<T>((resolve, reject) => {
|
|
281
|
-
|
|
282
|
-
|
|
517
|
+
const waiter: Waiter = { resolve: resolve as (value: unknown) => void, reject, signal }
|
|
518
|
+
let key: string | undefined
|
|
519
|
+
if (this.#dedupe && !WRITE_METHOD.test(method)) {
|
|
520
|
+
try {
|
|
521
|
+
key = `${method}\u0000${JSON.stringify(params)}`
|
|
522
|
+
} catch {
|
|
523
|
+
key = undefined // 参数不能序列化(如 bigint):不去重,发送时照常报错
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const existing = key === undefined ? undefined : this.#queuedByKey.get(key)
|
|
527
|
+
const item: Pending = existing ?? { method, params, key, waiters: [], done: false, posts: new Set() }
|
|
528
|
+
item.waiters.push(waiter)
|
|
529
|
+
if (signal) {
|
|
530
|
+
const onAbort = () => this.#abortWaiter(item, waiter)
|
|
531
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
532
|
+
waiter.cleanup = () => signal.removeEventListener('abort', onAbort)
|
|
533
|
+
}
|
|
534
|
+
if (existing) {
|
|
535
|
+
return // 同一批里相同的调用:共用一个结果
|
|
536
|
+
}
|
|
537
|
+
this.#queue.push(item)
|
|
538
|
+
if (key !== undefined) {
|
|
539
|
+
this.#queuedByKey.set(key, item)
|
|
540
|
+
}
|
|
541
|
+
if (!this.#batch || this.#queue.length >= this.#batchSize) {
|
|
283
542
|
this.#flush()
|
|
284
543
|
} else if (!this.#timer) {
|
|
285
544
|
this.#timer = setTimeout(() => this.#flush(), this.#batchWait)
|
|
@@ -287,6 +546,32 @@ export class HttpRpc implements RpcTransport {
|
|
|
287
546
|
})
|
|
288
547
|
}
|
|
289
548
|
|
|
549
|
+
/**
|
|
550
|
+
* 调用方取消:立即 reject 这个调用方;同一调用的其他调用方都取消时,还在排队的移出队列,
|
|
551
|
+
* 已发出的 HTTP 请求在所有成员都被取消后中止
|
|
552
|
+
*/
|
|
553
|
+
#abortWaiter(item: Pending, waiter: Waiter): void {
|
|
554
|
+
waiter.cleanup?.()
|
|
555
|
+
waiter.reject(waiter.signal?.reason)
|
|
556
|
+
if (item.done || !abandoned(item)) {
|
|
557
|
+
return
|
|
558
|
+
}
|
|
559
|
+
const index = this.#queue.indexOf(item)
|
|
560
|
+
if (index !== -1) {
|
|
561
|
+
this.#queue.splice(index, 1)
|
|
562
|
+
if (item.key !== undefined && this.#queuedByKey.get(item.key) === item) {
|
|
563
|
+
this.#queuedByKey.delete(item.key)
|
|
564
|
+
}
|
|
565
|
+
item.done = true
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
for (const post of item.posts) {
|
|
569
|
+
if (post.items.every(abandoned)) {
|
|
570
|
+
post.controller.abort(waiter.signal?.reason)
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
290
575
|
#flush(): void {
|
|
291
576
|
if (this.#timer) {
|
|
292
577
|
clearTimeout(this.#timer)
|
|
@@ -294,20 +579,22 @@ export class HttpRpc implements RpcTransport {
|
|
|
294
579
|
}
|
|
295
580
|
const queue = this.#queue
|
|
296
581
|
this.#queue = []
|
|
582
|
+
this.#queuedByKey = new Map()
|
|
297
583
|
for (const chunk of this.#split(queue)) {
|
|
298
584
|
void this.#dispatch(chunk)
|
|
299
585
|
}
|
|
300
586
|
}
|
|
301
587
|
|
|
302
|
-
/**
|
|
588
|
+
/** 按批量大小和各方法的数量限制分批(保持顺序;不受限的方法照常合并) */
|
|
303
589
|
#split(items: readonly Pending[]): Pending[][] {
|
|
304
590
|
const chunks: Pending[][] = []
|
|
591
|
+
const batchSize = this.#batchSize
|
|
305
592
|
let current: Pending[] = []
|
|
306
593
|
let counts = new Map<string, number>()
|
|
307
594
|
for (const item of items) {
|
|
308
595
|
const limit = this.#learned.methods.get(item.method)
|
|
309
596
|
const count = counts.get(item.method) ?? 0
|
|
310
|
-
if (current.length >=
|
|
597
|
+
if (current.length >= batchSize || (limit !== undefined && count >= limit)) {
|
|
311
598
|
chunks.push(current)
|
|
312
599
|
current = []
|
|
313
600
|
counts = new Map()
|
|
@@ -333,24 +620,35 @@ export class HttpRpc implements RpcTransport {
|
|
|
333
620
|
return false
|
|
334
621
|
}
|
|
335
622
|
this.#learned.methods.set(method, limit)
|
|
336
|
-
await
|
|
623
|
+
await this.#redispatch(items)
|
|
337
624
|
return true
|
|
338
625
|
}
|
|
339
626
|
|
|
340
627
|
/** retriedServerError:这一批已经因 5xx + JSON-RPC 错误重试过一次 */
|
|
341
|
-
async #dispatch(
|
|
628
|
+
async #dispatch(chunk: Pending[], retriedServerError = false): Promise<void> {
|
|
629
|
+
// 已被所有调用方取消的不再发
|
|
630
|
+
const items = chunk.filter(live)
|
|
631
|
+
if (!items.length) {
|
|
632
|
+
return
|
|
633
|
+
}
|
|
342
634
|
if (items.length === 1 || !this.#batch) {
|
|
343
635
|
await Promise.all(items.map((item) => this.#single(item)))
|
|
344
636
|
return
|
|
345
637
|
}
|
|
346
638
|
const ids = items.map(() => nextId++)
|
|
347
|
-
let body: unknown
|
|
639
|
+
let posted: { body: unknown; retryAfter: string | null }
|
|
348
640
|
try {
|
|
349
|
-
|
|
641
|
+
posted = await this.#post(
|
|
642
|
+
items.map((item, i) => ({ jsonrpc: '2.0', id: ids[i], method: item.method, params: item.params })),
|
|
643
|
+
items,
|
|
644
|
+
)
|
|
350
645
|
} catch (err) {
|
|
646
|
+
if (items.every((item) => !live(item))) {
|
|
647
|
+
return // 调用方都取消了(各自已经 reject)
|
|
648
|
+
}
|
|
351
649
|
if (err instanceof HttpError && err.status === 413 && items.length > 1) {
|
|
352
|
-
//
|
|
353
|
-
this.#
|
|
650
|
+
// 请求体太大:批量减半后重发(一段时间后恢复原来的批量大小)
|
|
651
|
+
this.#limitBatchSize(Math.floor(items.length / 2))
|
|
354
652
|
await this.#redispatch(items)
|
|
355
653
|
return
|
|
356
654
|
}
|
|
@@ -369,18 +667,24 @@ export class HttpRpc implements RpcTransport {
|
|
|
369
667
|
await this.#dispatch(items, true)
|
|
370
668
|
return
|
|
371
669
|
}
|
|
372
|
-
|
|
670
|
+
// 试探用还需要结果的第一条(期间被取消的不再发)
|
|
671
|
+
const remaining = items.filter(live)
|
|
672
|
+
if (!remaining.length) {
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
const [first, ...rest] = remaining as [Pending, ...Pending[]]
|
|
373
676
|
if (await this.#single(first)) {
|
|
374
677
|
this.#disableBatch()
|
|
375
678
|
await Promise.all(rest.map((item) => this.#single(item)))
|
|
376
679
|
} else {
|
|
377
|
-
rest.forEach((item) => item
|
|
680
|
+
rest.forEach((item) => rejectItem(item, err))
|
|
378
681
|
}
|
|
379
682
|
return
|
|
380
683
|
}
|
|
381
|
-
items.forEach((item) => item
|
|
684
|
+
items.forEach((item) => rejectItem(item, err))
|
|
382
685
|
return
|
|
383
686
|
}
|
|
687
|
+
const { body } = posted
|
|
384
688
|
if (!Array.isArray(body)) {
|
|
385
689
|
const error = (body as RpcResponse)?.error
|
|
386
690
|
// 单个方法在批量里的数量超限(整批被拒):按限制重新分批,不当成节点问题
|
|
@@ -390,7 +694,7 @@ export class HttpRpc implements RpcTransport {
|
|
|
390
694
|
const rpcError = new RpcError(error?.message ?? 'Invalid JSON-RPC batch response', error?.code, error?.data)
|
|
391
695
|
// 限频 / 鉴权等节点问题:原样报错,不降级(降级成逐条请求只会让限频更严重)
|
|
392
696
|
if (rpcError.nodeFault) {
|
|
393
|
-
items.forEach((item) => item
|
|
697
|
+
items.forEach((item) => rejectItem(item, rpcError))
|
|
394
698
|
return
|
|
395
699
|
}
|
|
396
700
|
// 整批被拒(不是账户数超限):节点多半不支持批量(各家错误码不同:-32600 / -32601 / -32700 …),
|
|
@@ -410,11 +714,11 @@ export class HttpRpc implements RpcTransport {
|
|
|
410
714
|
}
|
|
411
715
|
const limited = (body as RpcResponse[]).find((res) => isBatchLimitError(res.error?.message))
|
|
412
716
|
if (limited) {
|
|
413
|
-
// 如 “Batch of more than 3 requests are not allowed
|
|
717
|
+
// 如 “Batch of more than 3 requests are not allowed”:按限制缩小批量后重发(一段时间后恢复);
|
|
414
718
|
// 限制不小于当前批量(节点的上限判断有偏差)或解析不出数字时,降级为逐条请求,避免无限重发
|
|
415
719
|
const max = Number(/more than (\d+)/i.exec(limited.error?.message ?? '')?.[1])
|
|
416
720
|
if (Number.isFinite(max) && max > 1 && max < items.length) {
|
|
417
|
-
this.#
|
|
721
|
+
this.#limitBatchSize(max)
|
|
418
722
|
await this.#redispatch(items)
|
|
419
723
|
} else {
|
|
420
724
|
this.#disableBatch()
|
|
@@ -435,24 +739,32 @@ export class HttpRpc implements RpcTransport {
|
|
|
435
739
|
}
|
|
436
740
|
})
|
|
437
741
|
if (limitedItems.length) {
|
|
438
|
-
|
|
742
|
+
const wait = retryDelay(0, posted.retryAfter)
|
|
743
|
+
if (wait === null) {
|
|
744
|
+
limitedItems.forEach((item) => settle(item, byId.get(ids[items.indexOf(item)])))
|
|
745
|
+
return
|
|
746
|
+
}
|
|
747
|
+
await sleep(wait)
|
|
439
748
|
await Promise.all(limitedItems.map((item) => this.#single(item, 0)))
|
|
440
749
|
}
|
|
441
750
|
}
|
|
442
751
|
|
|
443
|
-
/**
|
|
752
|
+
/** 按当前的批量大小重新分批发送(每批都比原来小,不会无限循环) */
|
|
444
753
|
async #redispatch(items: Pending[]): Promise<void> {
|
|
445
|
-
await Promise.all(this.#split(items).map((chunk) => this.#dispatch(chunk)))
|
|
754
|
+
await Promise.all(this.#split(items.filter(live)).map((chunk) => this.#dispatch(chunk)))
|
|
446
755
|
}
|
|
447
756
|
|
|
448
757
|
/** 逐条发送一个调用;返回是否拿到了结果(没有出错) */
|
|
449
758
|
async #single(item: Pending, retries = this.#retries): Promise<boolean> {
|
|
759
|
+
if (!live(item)) {
|
|
760
|
+
return false
|
|
761
|
+
}
|
|
450
762
|
try {
|
|
451
|
-
const body =
|
|
452
|
-
settle(item, body)
|
|
453
|
-
return !body?.error
|
|
763
|
+
const { body } = await this.#post({ jsonrpc: '2.0', id: nextId++, method: item.method, params: item.params }, [item], retries)
|
|
764
|
+
settle(item, body as RpcResponse)
|
|
765
|
+
return !(body as RpcResponse)?.error
|
|
454
766
|
} catch (err) {
|
|
455
|
-
item
|
|
767
|
+
rejectItem(item, err)
|
|
456
768
|
return false
|
|
457
769
|
}
|
|
458
770
|
}
|
|
@@ -462,59 +774,102 @@ export class HttpRpc implements RpcTransport {
|
|
|
462
774
|
this.#batchDisabledUntil = Date.now() + BATCH_RETRY_AFTER
|
|
463
775
|
}
|
|
464
776
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
777
|
+
/**
|
|
778
|
+
* 发一次 HTTP 请求(含 429 退避重试)。items 是这次请求的成员:都被调用方取消时中止 fetch / 退避等待,抛出取消的 reason。
|
|
779
|
+
* 同一节点地址同时在途的请求数受 maxConcurrency 限制:排队最多等 timeout 毫秒(超时抛 HttpError timeout,交给上层换节点),发出后重新计时
|
|
780
|
+
*/
|
|
781
|
+
async #post(payload: unknown, items: readonly Pending[], retries = this.#retries): Promise<{ body: unknown; retryAfter: string | null }> {
|
|
782
|
+
const post: Post = { items, controller: new AbortController() }
|
|
783
|
+
const cancel = post.controller.signal
|
|
784
|
+
items.forEach((item) => item.posts.add(post))
|
|
785
|
+
const slots = trackedEntry(slotsByUrl, this.url, () => ({ active: 0, waiting: [] }))
|
|
786
|
+
try {
|
|
787
|
+
for (let attempt = 0; ; attempt++) {
|
|
788
|
+
const limited = this.#timeout > 0 && Number.isFinite(this.#timeout)
|
|
789
|
+
// 排队等名额也受 timeout 限制:同一地址的其他连接把名额占住不放(如 timeout: 0 的请求挂住)时不会无限等下去
|
|
790
|
+
const queueTimeout = limited ? AbortSignal.timeout(this.#timeout) : null
|
|
791
|
+
try {
|
|
792
|
+
await acquireSlot(slots, this.#maxConcurrency, queueTimeout ? anySignal([cancel, queueTimeout]) : cancel)
|
|
793
|
+
} catch (err) {
|
|
794
|
+
if (cancel.aborted) {
|
|
795
|
+
throw cancel.reason
|
|
796
|
+
}
|
|
797
|
+
throw new HttpError(`Request to ${this.#safeUrl} timed out waiting for a free connection slot (maxConcurrency ${this.#maxConcurrency})`, undefined, true)
|
|
798
|
+
}
|
|
799
|
+
let response: Response
|
|
800
|
+
let text = ''
|
|
801
|
+
/** 429 后要等多久再重试;null 表示不重试 */
|
|
802
|
+
let wait: number | null = null
|
|
803
|
+
try {
|
|
804
|
+
const signal = limited ? anySignal([cancel, AbortSignal.timeout(this.#timeout)]) : cancel
|
|
805
|
+
try {
|
|
806
|
+
response = await this.#fetch(this.url, { method: 'POST', headers: this.#headers, body: JSON.stringify(payload), signal })
|
|
807
|
+
} catch (err) {
|
|
808
|
+
if (cancel.aborted) {
|
|
809
|
+
throw cancel.reason
|
|
810
|
+
}
|
|
811
|
+
// AbortSignal.timeout 触发时是 TimeoutError(DOMException)
|
|
812
|
+
const timeout = (err as { name?: unknown } | null)?.name === 'TimeoutError'
|
|
813
|
+
throw new HttpError(`Request to ${this.#safeUrl} failed: ${(err as Error)?.message ?? err}`, undefined, timeout)
|
|
814
|
+
}
|
|
815
|
+
if (response.status === 429 && attempt < retries) {
|
|
816
|
+
wait = retryDelay(attempt, response.headers?.get?.('retry-after'))
|
|
817
|
+
}
|
|
818
|
+
if (wait !== null) {
|
|
819
|
+
void response.body?.cancel().catch(() => {})
|
|
820
|
+
} else {
|
|
821
|
+
try {
|
|
822
|
+
text = await response.text()
|
|
823
|
+
} catch (err) {
|
|
824
|
+
if (cancel.aborted) {
|
|
825
|
+
throw cancel.reason
|
|
826
|
+
}
|
|
827
|
+
// 读响应体时超时或连接中断:同样包装成 HttpError(原生 DOMException / TypeError 不会被当成可恢复的节点问题)
|
|
828
|
+
const timeout = (err as { name?: unknown } | null)?.name === 'TimeoutError'
|
|
829
|
+
throw new HttpError(`Reading response from ${this.#safeUrl} failed: ${(err as Error)?.message ?? err}`, response.status, timeout)
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
} finally {
|
|
833
|
+
releaseSlot(slots)
|
|
834
|
+
}
|
|
835
|
+
if (wait !== null) {
|
|
836
|
+
// 退避期间不占并发名额
|
|
837
|
+
await sleep(wait, cancel)
|
|
838
|
+
continue
|
|
839
|
+
}
|
|
840
|
+
let body: unknown
|
|
841
|
+
try {
|
|
842
|
+
body = parseJson(text)
|
|
843
|
+
} catch {
|
|
844
|
+
throw new HttpError(`Invalid JSON from ${this.#safeUrl} (HTTP ${response.status})`, response.status)
|
|
845
|
+
}
|
|
846
|
+
// 非 2xx 但返回了 JSON-RPC 结构(如 403 Access forbidden、drpc 的 500 + 批量错误数组):交给上层解析
|
|
847
|
+
if (!response.ok && !Array.isArray(body) && !(body as RpcResponse)?.error) {
|
|
848
|
+
throw new HttpError(`HTTP ${response.status} from ${this.#safeUrl}`, response.status)
|
|
849
|
+
}
|
|
850
|
+
// 5xx + 单个 JSON-RPC 错误:
|
|
851
|
+
// - 繁忙、过载等明确的临时故障:按 HTTP 错误处理,直接换节点(不重试、不逐条,不给过载的节点加量)
|
|
852
|
+
// - -32603 internal error:含义不明确,标记 rpcError,由 #dispatch 判断是临时故障还是节点用 5xx 拒绝批量
|
|
853
|
+
// 其他错误(方法数量超限、账户数超限、参数错误、minContextSlot、限频等)照常交给上层按类型处理
|
|
854
|
+
const bodyError = response.status >= 500 && !Array.isArray(body) ? (body as RpcResponse)?.error : undefined
|
|
855
|
+
const kind = bodyError ? classifyRpcError(bodyError.code, bodyError.message ?? '') : undefined
|
|
856
|
+
if (kind === 'transient' || kind === 'internal') {
|
|
857
|
+
const message = bodyError?.message
|
|
858
|
+
throw new HttpError(`HTTP ${response.status} from ${this.#safeUrl}${message ? `: ${message}` : ''}`, response.status, false, kind === 'internal')
|
|
859
|
+
}
|
|
860
|
+
const retryAfter = response.headers?.get?.('retry-after') ?? null
|
|
861
|
+
// 有的节点限频时返回 HTTP 200 + JSON-RPC 错误码 429:同样退避重试
|
|
862
|
+
if (attempt < retries && isRateLimited(body)) {
|
|
863
|
+
const wait = retryDelay(attempt, retryAfter)
|
|
864
|
+
if (wait !== null) {
|
|
865
|
+
await sleep(wait, cancel)
|
|
866
|
+
continue
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return { body, retryAfter }
|
|
516
870
|
}
|
|
517
|
-
|
|
871
|
+
} finally {
|
|
872
|
+
items.forEach((item) => item.posts.delete(post))
|
|
518
873
|
}
|
|
519
874
|
}
|
|
520
875
|
}
|
|
@@ -565,11 +920,11 @@ function isRateLimited(body: unknown): boolean {
|
|
|
565
920
|
|
|
566
921
|
function settle(item: Pending, response: RpcResponse | undefined): void {
|
|
567
922
|
if (!response) {
|
|
568
|
-
item
|
|
923
|
+
rejectItem(item, new HttpError('Missing response in JSON-RPC batch'))
|
|
569
924
|
} else if (response.error) {
|
|
570
|
-
item
|
|
925
|
+
rejectItem(item, new RpcError(response.error.message ?? 'JSON-RPC error', response.error.code, response.error.data))
|
|
571
926
|
} else {
|
|
572
|
-
item
|
|
927
|
+
resolveItem(item, response.result)
|
|
573
928
|
}
|
|
574
929
|
}
|
|
575
930
|
|
|
@@ -653,7 +1008,6 @@ interface NodeHealth {
|
|
|
653
1008
|
// 节点健康状态按节点共享(模块级):客户端重建后,刚出过错的节点仍排在后面。URL 节点按完整 URL,其他按对象
|
|
654
1009
|
const healthByUrl = new Map<string, NodeHealth>()
|
|
655
1010
|
let healthByObject = new WeakMap<object, NodeHealth>()
|
|
656
|
-
const MAX_TRACKED_URLS = 1000
|
|
657
1011
|
/** 连续超时这么多次才冷却(单次超时多半只是这次请求慢) */
|
|
658
1012
|
const TIMEOUTS_BEFORE_COOLDOWN = 3
|
|
659
1013
|
|
|
@@ -666,15 +1020,7 @@ function healthKeyOf(node: RpcTransport): string | object {
|
|
|
666
1020
|
/** 每次用时按 key 从全局表里取:表淘汰条目后,各实例仍取到同一个对象 */
|
|
667
1021
|
function healthOf(key: string | object): NodeHealth {
|
|
668
1022
|
if (typeof key === 'string') {
|
|
669
|
-
|
|
670
|
-
if (!health) {
|
|
671
|
-
health = { failedAt: 0, timeouts: 0 }
|
|
672
|
-
healthByUrl.set(key, health)
|
|
673
|
-
if (healthByUrl.size > MAX_TRACKED_URLS) {
|
|
674
|
-
healthByUrl.delete(healthByUrl.keys().next().value as string)
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
return health
|
|
1023
|
+
return trackedEntry(healthByUrl, key, () => ({ failedAt: 0, timeouts: 0 }))
|
|
678
1024
|
}
|
|
679
1025
|
let health = healthByObject.get(key)
|
|
680
1026
|
if (!health) {
|
|
@@ -721,23 +1067,33 @@ export class FallbackRpc implements RpcTransport {
|
|
|
721
1067
|
this.#healthKeys = nodes.map(healthKeyOf)
|
|
722
1068
|
}
|
|
723
1069
|
|
|
724
|
-
async request<T = unknown>(method: string, params?: readonly unknown[]): Promise<T> {
|
|
1070
|
+
async request<T = unknown>(method: string, params?: readonly unknown[], options?: RpcRequestOptions): Promise<T> {
|
|
1071
|
+
const signal = options?.signal
|
|
725
1072
|
const errors: Array<{ node: string; error: unknown }> = []
|
|
726
1073
|
const order = this.#order()
|
|
727
1074
|
for (let attempt = 0; attempt < order.length; attempt++) {
|
|
1075
|
+
// 已取消:不再发、不再换节点
|
|
1076
|
+
if (signal?.aborted) {
|
|
1077
|
+
throw signal.reason
|
|
1078
|
+
}
|
|
728
1079
|
const index = order[attempt] as number
|
|
729
1080
|
const node = this.nodes[index] as RpcTransport
|
|
730
1081
|
const label = this.#labels[index] as string
|
|
731
1082
|
const health = healthOf(this.#healthKeys[index] as string | object)
|
|
732
1083
|
const started = Date.now()
|
|
733
1084
|
try {
|
|
734
|
-
|
|
1085
|
+
// 没有 signal 时按两个参数调用(自定义传输不受影响)
|
|
1086
|
+
const result = await (signal ? node.request<T>(method, params, { signal }) : node.request<T>(method, params))
|
|
735
1087
|
health.failedAt = 0
|
|
736
1088
|
health.timeouts = 0
|
|
737
1089
|
emit({ node: label, method, ms: Date.now() - started, ok: true, attempt })
|
|
738
1090
|
return result
|
|
739
1091
|
} catch (err) {
|
|
740
1092
|
emit({ node: label, method, ms: Date.now() - started, ok: false, error: err, attempt })
|
|
1093
|
+
// 调用方取消导致的失败:不算节点故障,也不换节点
|
|
1094
|
+
if (signal?.aborted) {
|
|
1095
|
+
throw signal.reason
|
|
1096
|
+
}
|
|
741
1097
|
if (!isNodeFault(err)) {
|
|
742
1098
|
throw err
|
|
743
1099
|
}
|