@w3lib/solana-batch-call 0.2.0 → 0.2.2

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/src/rpc.ts CHANGED
@@ -17,28 +17,67 @@ export class RpcError extends Error {
17
17
  this.name = 'RpcError'
18
18
  this.code = code
19
19
  this.data = data
20
+ const kind = classifyRpcError(code, message)
20
21
  this.nodeFault =
21
22
  code === 403 ||
22
- code === 429 ||
23
23
  code === -32005 ||
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)
24
+ // 节点高度没到 minContextSlot、限频、服务端临时故障(繁忙、过载、暂不可用、超时):换节点可能成功;
25
+ // 临时故障也不应据此关闭批量(降级只会加重负载)
26
+ kind === 'min-slot' ||
27
+ kind === 'rate-limited' ||
28
+ kind === 'transient' ||
29
+ (/forbidden|blocked|personal token|api.?key|too many|not allowed|unauthori[sz]ed|limit exceeded|disabled|not available|not supported/i.test(message) &&
30
+ kind !== 'too-many-accounts')
28
31
  }
29
32
  }
30
33
 
34
+ /**
35
+ * JSON-RPC 错误的分类(RpcError.nodeFault、HTTP 5xx 的处理共用,避免两份清单各自维护):
36
+ * - method-limit / too-many-accounts:节点对批量 / 单次账户数的限制,按限制重新分批或缩小
37
+ * - invalid-params:参数错误(-32602),确定性的
38
+ * - min-slot / rate-limited / transient:节点落后、限频、服务端临时故障,换节点
39
+ * - internal:-32603 internal error,含义不明确(可能是临时故障,也可能是节点拒绝批量)
40
+ */
41
+ type RpcErrorKind = 'method-limit' | 'too-many-accounts' | 'invalid-params' | 'min-slot' | 'rate-limited' | 'transient' | 'internal' | 'other'
42
+
43
+ function classifyRpcError(code: number | undefined, message: string): RpcErrorKind {
44
+ if (METHOD_LIMIT.test(message)) return 'method-limit'
45
+ if (TOO_MANY_ACCOUNTS.test(message)) return 'too-many-accounts'
46
+ if (code === -32602) return 'invalid-params'
47
+ if (code === MIN_CONTEXT_SLOT_NOT_REACHED || /minimum context slot/i.test(message)) return 'min-slot'
48
+ if (code === 429 || RATE_LIMITED.test(message)) return 'rate-limited'
49
+ if (TRANSIENT.test(message)) return 'transient'
50
+ if (code === -32603) return 'internal'
51
+ return 'other'
52
+ }
53
+
54
+ /** 节点限制单个方法在批量里的数量(如 publicnode:“Maximum number of 'getMultipleAccounts' calls in a batch request is 1”) */
55
+ const METHOD_LIMIT = /maximum number of '([^']+)' calls in a batch request is (\d+)/i
56
+
57
+ /** 限频的报错 */
58
+ const RATE_LIMITED = /rate.?limit|too many requests/i
59
+
60
+ /** 服务端临时故障的报错(如 -32000 server is busy、-32603 + 503 overloaded) */
61
+ const TRANSIENT = /\bbusy\b|overload|temporar(il)?y|unavailable|try again|timed? ?out|timeout/i
62
+
63
+ /** 节点限制单次 getMultipleAccounts 的账户数(如 “Too many accounts requested”):缩小每次的账户数后重试,不是节点故障 */
64
+ const TOO_MANY_ACCOUNTS = /too many accounts/i
65
+
31
66
  /** HTTP 层错误(非 2xx、网络错误、超时) */
32
67
  export class HttpError extends Error {
33
68
  readonly status: number | undefined
34
69
  /** 请求超时(HttpRpcOptions.timeout) */
35
70
  readonly timeout: boolean
36
71
 
37
- constructor(message: string, status?: number, timeout = false) {
72
+ /** 5xx 的响应体是 JSON-RPC 错误(服务端故障,或节点用 5xx 拒绝批量请求) */
73
+ readonly rpcError: boolean
74
+
75
+ constructor(message: string, status?: number, timeout = false, rpcError = false) {
38
76
  super(message)
39
77
  this.name = 'HttpError'
40
78
  this.status = status
41
79
  this.timeout = timeout
80
+ this.rpcError = rpcError
42
81
  }
43
82
  }
44
83
 
@@ -118,6 +157,9 @@ let nextId = 1
118
157
  /** 批量请求被拒绝、可以降级为逐条请求的 HTTP 状态 */
119
158
  const BATCH_REJECTED_STATUS = new Set([400, 404, 405, 415, 422])
120
159
 
160
+ /** 节点不支持批量时,多久后重新尝试批量 */
161
+ const BATCH_RETRY_AFTER = 10 * 60 * 1000
162
+
121
163
  function isBatchLimitError(message: string | undefined): boolean {
122
164
  return !!message && /batch/i.test(message) && /more than \d+|too (large|many|big)|not (allowed|supported)|exceed/i.test(message)
123
165
  }
@@ -136,7 +178,14 @@ export class HttpRpc implements RpcTransport {
136
178
  readonly #retries: number
137
179
  readonly #fetch: typeof fetch
138
180
  #maxBatchSize: number
139
- #batch: boolean
181
+ /** 节点对单个方法在一个批量里的数量限制(如 publicnode:getMultipleAccounts 最多 1 个),从报错里学到 */
182
+ readonly #methodLimits = new Map<string, number>()
183
+ /** options.batch:调用方是否允许批量 */
184
+ readonly #batchOption: boolean
185
+ /** 节点明确表示不支持批量时,在这之前逐条请求(之后重新尝试批量) */
186
+ #batchDisabledUntil = 0
187
+ /** 节点限制单次 getMultipleAccounts 的账户数时学到的上限 */
188
+ #maxAccounts: number | undefined
140
189
  #queue: Pending[] = []
141
190
  #timer: ReturnType<typeof setTimeout> | null = null
142
191
 
@@ -146,13 +195,59 @@ export class HttpRpc implements RpcTransport {
146
195
  this.#timeout = options.timeout ?? 10_000
147
196
  this.#headers = { 'content-type': 'application/json', ...options.headers }
148
197
  this.#maxBatchSize = Math.max(1, options.maxBatchSize ?? 20)
149
- this.#batch = options.batch ?? true
198
+ this.#batchOption = options.batch ?? true
150
199
  this.#batchWait = options.batchWait ?? 0
151
200
  this.#retries = Math.max(0, options.retries ?? 2)
152
201
  this.#fetch = options.fetch ?? ((...args) => fetch(...args))
153
202
  }
154
203
 
155
204
  request<T = unknown>(method: string, params: readonly unknown[] = []): Promise<T> {
205
+ if (method === 'getMultipleAccounts' && Array.isArray(params[0])) {
206
+ return this.#getMultipleAccounts(params) as Promise<T>
207
+ }
208
+ return this.#enqueue<T>(method, params)
209
+ }
210
+
211
+ /**
212
+ * getMultipleAccounts:节点限制单次账户数时(“Too many accounts requested”),减半后分多次请求、合并结果,
213
+ * 并记下上限,之后直接按上限拆分。合并后 context.slot 取最小值
214
+ */
215
+ async #getMultipleAccounts(params: readonly unknown[]): Promise<unknown> {
216
+ const [addresses, ...rest] = params as [unknown[], ...unknown[]]
217
+ const max = this.#maxAccounts
218
+ if (max !== undefined && addresses.length > max) {
219
+ const parts: unknown[][] = []
220
+ for (let i = 0; i < addresses.length; i += max) {
221
+ parts.push(addresses.slice(i, i + max))
222
+ }
223
+ const results = (await Promise.all(parts.map((part) => this.#getMultipleAccounts([part, ...rest])))) as Array<{
224
+ context?: { slot?: number }
225
+ value?: unknown[]
226
+ }>
227
+ const slots = results.map((r) => r?.context?.slot).filter((slot): slot is number => typeof slot === 'number')
228
+ return {
229
+ ...results[0],
230
+ context: { ...results[0]?.context, ...(slots.length ? { slot: Math.min(...slots) } : {}) },
231
+ value: results.flatMap((r) => r?.value ?? []),
232
+ }
233
+ }
234
+ try {
235
+ return await this.#enqueue('getMultipleAccounts', params)
236
+ } catch (err) {
237
+ if (err instanceof RpcError && TOO_MANY_ACCOUNTS.test(err.message) && addresses.length > 1) {
238
+ this.#maxAccounts = Math.max(1, Math.min(this.#maxAccounts ?? Number.POSITIVE_INFINITY, Math.floor(addresses.length / 2)))
239
+ return this.#getMultipleAccounts(params)
240
+ }
241
+ throw err
242
+ }
243
+ }
244
+
245
+ /** 是否合并成批量请求:调用方允许,且不在 “节点不支持批量” 的冷却期内 */
246
+ get #batch(): boolean {
247
+ return this.#batchOption && Date.now() >= this.#batchDisabledUntil
248
+ }
249
+
250
+ #enqueue<T>(method: string, params: readonly unknown[]): Promise<T> {
156
251
  return new Promise<T>((resolve, reject) => {
157
252
  this.#queue.push({ method, params, resolve: resolve as (value: unknown) => void, reject })
158
253
  if (!this.#batch || this.#queue.length >= this.#maxBatchSize) {
@@ -170,12 +265,51 @@ export class HttpRpc implements RpcTransport {
170
265
  }
171
266
  const queue = this.#queue
172
267
  this.#queue = []
173
- for (let i = 0; i < queue.length; i += this.#maxBatchSize) {
174
- void this.#dispatch(queue.slice(i, i + this.#maxBatchSize))
268
+ for (const chunk of this.#split(queue)) {
269
+ void this.#dispatch(chunk)
270
+ }
271
+ }
272
+
273
+ /** 按 maxBatchSize 和各方法的数量限制分批(保持顺序;不受限的方法照常合并) */
274
+ #split(items: readonly Pending[]): Pending[][] {
275
+ const chunks: Pending[][] = []
276
+ let current: Pending[] = []
277
+ let counts = new Map<string, number>()
278
+ for (const item of items) {
279
+ const limit = this.#methodLimits.get(item.method)
280
+ const count = counts.get(item.method) ?? 0
281
+ if (current.length >= this.#maxBatchSize || (limit !== undefined && count >= limit)) {
282
+ chunks.push(current)
283
+ current = []
284
+ counts = new Map()
285
+ }
286
+ current.push(item)
287
+ counts.set(item.method, (counts.get(item.method) ?? 0) + 1)
175
288
  }
289
+ if (current.length) {
290
+ chunks.push(current)
291
+ }
292
+ return chunks
176
293
  }
177
294
 
178
- async #dispatch(items: Pending[]): Promise<void> {
295
+ /**
296
+ * 报错是 “某方法在批量里的数量超限” 时(如 “Maximum number of 'getMultipleAccounts' calls in a batch request is 1”),
297
+ * 记下限制并按限制重新分批,返回 true;限制解析不出或这批本来就没超限(避免无限重发)时返回 false
298
+ */
299
+ async #retryWithMethodLimit(items: Pending[], message: string | undefined): Promise<boolean> {
300
+ const match = METHOD_LIMIT.exec(message ?? '')
301
+ const method = match?.[1]
302
+ const limit = Number(match?.[2])
303
+ if (!method || !Number.isFinite(limit) || limit < 1 || items.filter((item) => item.method === method).length <= limit) {
304
+ return false
305
+ }
306
+ this.#methodLimits.set(method, limit)
307
+ await Promise.all(this.#split(items).map((chunk) => this.#dispatch(chunk)))
308
+ return true
309
+ }
310
+
311
+ /** retriedServerError:这一批已经因 5xx + JSON-RPC 错误重试过一次 */
312
+ async #dispatch(items: Pending[], retriedServerError = false): Promise<void> {
179
313
  if (items.length === 1 || !this.#batch) {
180
314
  await Promise.all(items.map((item) => this.#single(item)))
181
315
  return
@@ -198,23 +332,53 @@ export class HttpRpc implements RpcTransport {
198
332
  await Promise.all(items.map((item) => this.#single(item)))
199
333
  return
200
334
  }
335
+ // 5xx + -32603(含义不明确:临时故障,或节点用 5xx 拒绝批量):先原样重试一次批量(偶发故障这次就成功了,不降级);
336
+ // 仍然失败时只用第一条单独试探——成功说明是批量本身被拒,暂停批量(10 分钟后重试)并逐条发送其余的;
337
+ // 失败则是临时故障,其余的直接报错(换节点),不给出问题的节点加量
338
+ if (err instanceof HttpError && err.rpcError && items.length > 1) {
339
+ if (!retriedServerError) {
340
+ await this.#dispatch(items, true)
341
+ return
342
+ }
343
+ const [first, ...rest] = items as [Pending, ...Pending[]]
344
+ if (await this.#single(first)) {
345
+ this.#disableBatch()
346
+ await Promise.all(rest.map((item) => this.#single(item)))
347
+ } else {
348
+ rest.forEach((item) => item.reject(err))
349
+ }
350
+ return
351
+ }
201
352
  items.forEach((item) => item.reject(err))
202
353
  return
203
354
  }
204
355
  if (!Array.isArray(body)) {
205
356
  const error = (body as RpcResponse)?.error
357
+ // 单个方法在批量里的数量超限(整批被拒):按限制重新分批,不当成节点问题
358
+ if (await this.#retryWithMethodLimit(items, error?.message)) {
359
+ return
360
+ }
206
361
  const rpcError = new RpcError(error?.message ?? 'Invalid JSON-RPC batch response', error?.code, error?.data)
207
362
  // 限频 / 鉴权等节点问题:原样报错,不降级(降级成逐条请求只会让限频更严重)
208
363
  if (rpcError.nodeFault) {
209
364
  items.forEach((item) => item.reject(rpcError))
210
365
  return
211
366
  }
212
- // 其他(如 -32600 invalid request):节点不支持批量,降级为逐条请求
213
- this.#disableBatch()
367
+ // 整批被拒(不是账户数超限):节点多半不支持批量(各家错误码不同:-32600 / -32601 / -32700 …),
368
+ // 一段时间内改为逐条请求(10 分钟后重新尝试批量,偶发的错误不会永久关闭);不关的话每批都要先失败一次再逐条重发。
369
+ // 账户数超限只把这一批逐条重发(由 HTTP 层按节点缩小单次读取数量)
370
+ if (!TOO_MANY_ACCOUNTS.test(error?.message ?? '')) {
371
+ this.#disableBatch()
372
+ }
214
373
  await Promise.all(items.map((item) => this.#single(item)))
215
374
  return
216
375
  }
217
376
  const byId = new Map((body as RpcResponse[]).map((res) => [res.id, res]))
377
+ // 有的节点把 “方法数量超限” 放在批量结果的某一项里
378
+ const methodLimited = (body as RpcResponse[]).find((res) => METHOD_LIMIT.test(res.error?.message ?? ''))
379
+ if (methodLimited && (await this.#retryWithMethodLimit(items, methodLimited.error?.message))) {
380
+ return
381
+ }
218
382
  const limited = (body as RpcResponse[]).find((res) => isBatchLimitError(res.error?.message))
219
383
  if (limited) {
220
384
  // 如 “Batch of more than 3 requests are not allowed”:按限制缩小批量后重发;
@@ -229,11 +393,13 @@ export class HttpRpc implements RpcTransport {
229
393
  }
230
394
  return
231
395
  }
232
- // 批量里个别调用被限频(JSON-RPC 429):只把这些调用退避后单独重试
396
+ // 批量里个别调用被限频(JSON-RPC 429):只把这些调用退避后单独重试一次(不再叠加 #post 的重试);
397
+ // 整批都是 429 时 #post 已经退避重试过整批,不再逐条重试——否则请求数会成倍放大
398
+ const allLimited = isRateLimited(body)
233
399
  const limitedItems: Pending[] = []
234
400
  items.forEach((item, i) => {
235
401
  const response = byId.get(ids[i])
236
- if (response?.error?.code === 429 && this.#retries > 0) {
402
+ if (response?.error?.code === 429 && this.#retries > 0 && !allLimited) {
237
403
  limitedItems.push(item)
238
404
  } else {
239
405
  settle(item, response)
@@ -241,34 +407,33 @@ export class HttpRpc implements RpcTransport {
241
407
  })
242
408
  if (limitedItems.length) {
243
409
  await new Promise((r) => setTimeout(r, 500))
244
- await Promise.all(limitedItems.map((item) => this.#single(item)))
410
+ await Promise.all(limitedItems.map((item) => this.#single(item, 0)))
245
411
  }
246
412
  }
247
413
 
248
414
  /** 按当前的 maxBatchSize 重新分批发送(每批都比原来小,不会无限循环) */
249
415
  async #redispatch(items: Pending[]): Promise<void> {
250
- const size = this.#maxBatchSize
251
- const chunks: Pending[][] = []
252
- for (let i = 0; i < items.length; i += size) {
253
- chunks.push(items.slice(i, i + size))
254
- }
255
- await Promise.all(chunks.map((chunk) => this.#dispatch(chunk)))
416
+ await Promise.all(this.#split(items).map((chunk) => this.#dispatch(chunk)))
256
417
  }
257
418
 
258
- async #single(item: Pending): Promise<void> {
419
+ /** 逐条发送一个调用;返回是否拿到了结果(没有出错) */
420
+ async #single(item: Pending, retries = this.#retries): Promise<boolean> {
259
421
  try {
260
- const body = (await this.#post({ jsonrpc: '2.0', id: nextId++, method: item.method, params: item.params })) as RpcResponse
422
+ const body = (await this.#post({ jsonrpc: '2.0', id: nextId++, method: item.method, params: item.params }, retries)) as RpcResponse
261
423
  settle(item, body)
424
+ return !body?.error
262
425
  } catch (err) {
263
426
  item.reject(err)
427
+ return false
264
428
  }
265
429
  }
266
430
 
431
+ /** 节点明确表示不支持批量:10 分钟内逐条请求,之后重新尝试批量(节点可能只是临时出错或已经升级) */
267
432
  #disableBatch(): void {
268
- this.#batch = false
433
+ this.#batchDisabledUntil = Date.now() + BATCH_RETRY_AFTER
269
434
  }
270
435
 
271
- async #post(payload: unknown): Promise<unknown> {
436
+ async #post(payload: unknown, retries = this.#retries): Promise<unknown> {
272
437
  for (let attempt = 0; ; attempt++) {
273
438
  let response: Response
274
439
  try {
@@ -283,11 +448,18 @@ export class HttpRpc implements RpcTransport {
283
448
  const timeout = (err as { name?: unknown } | null)?.name === 'TimeoutError'
284
449
  throw new HttpError(`Request to ${this.#safeUrl} failed: ${(err as Error)?.message ?? err}`, undefined, timeout)
285
450
  }
286
- if (response.status === 429 && attempt < this.#retries) {
451
+ if (response.status === 429 && attempt < retries) {
287
452
  await new Promise((r) => setTimeout(r, 500 * 2 ** attempt))
288
453
  continue
289
454
  }
290
- const text = await response.text()
455
+ let text: string
456
+ try {
457
+ text = await response.text()
458
+ } catch (err) {
459
+ // 读响应体时超时或连接中断:同样包装成 HttpError(原生 DOMException / TypeError 不会被当成可恢复的节点问题)
460
+ const timeout = (err as { name?: unknown } | null)?.name === 'TimeoutError'
461
+ throw new HttpError(`Reading response from ${this.#safeUrl} failed: ${(err as Error)?.message ?? err}`, response.status, timeout)
462
+ }
291
463
  let body: unknown
292
464
  try {
293
465
  body = parseJson(text)
@@ -298,8 +470,18 @@ export class HttpRpc implements RpcTransport {
298
470
  if (!response.ok && !Array.isArray(body) && !(body as RpcResponse)?.error) {
299
471
  throw new HttpError(`HTTP ${response.status} from ${this.#safeUrl}`, response.status)
300
472
  }
473
+ // 5xx + 单个 JSON-RPC 错误:
474
+ // - 繁忙、过载等明确的临时故障:按 HTTP 错误处理,直接换节点(不重试、不逐条,不给过载的节点加量)
475
+ // - -32603 internal error:含义不明确,标记 rpcError,由 #dispatch 判断是临时故障还是节点用 5xx 拒绝批量
476
+ // 其他错误(方法数量超限、账户数超限、参数错误、minContextSlot、限频等)照常交给上层按类型处理
477
+ const bodyError = response.status >= 500 && !Array.isArray(body) ? (body as RpcResponse)?.error : undefined
478
+ const kind = bodyError ? classifyRpcError(bodyError.code, bodyError.message ?? '') : undefined
479
+ if (kind === 'transient' || kind === 'internal') {
480
+ const message = bodyError?.message
481
+ throw new HttpError(`HTTP ${response.status} from ${this.#safeUrl}${message ? `: ${message}` : ''}`, response.status, false, kind === 'internal')
482
+ }
301
483
  // 有的节点限频时返回 HTTP 200 + JSON-RPC 错误码 429:同样退避重试
302
- if (attempt < this.#retries && isRateLimited(body)) {
484
+ if (attempt < retries && isRateLimited(body)) {
303
485
  await new Promise((r) => setTimeout(r, 500 * 2 ** attempt))
304
486
  continue
305
487
  }
@@ -326,7 +508,17 @@ function parseJson(text: string): unknown {
326
508
  typeof value === 'number' && !Number.isSafeInteger(value) && Number.isInteger(value) && context?.source ? context.source : value,
327
509
  )
328
510
  }
329
- return JSON.parse(text.replace(/([:[,]\s*)(-?\d{16,})(?=\s*[,}\]])/g, '$1"$2"'))
511
+ return JSON.parse(quoteBigIntegers(text))
512
+ }
513
+
514
+ /**
515
+ * 把 JSON 文本里数值位置上 16 位以上的整数改成字符串(不支持 source text access 的环境用)。
516
+ * 跳过字符串字面量:错误信息等字符串里的长数字不会被改动
517
+ */
518
+ export function quoteBigIntegers(text: string): string {
519
+ return text.replace(/"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, (token) =>
520
+ token[0] !== '"' && /^-?\d{16,}$/.test(token) ? `"${token}"` : token,
521
+ )
330
522
  }
331
523
 
332
524
  function safeOrigin(url: string): string {
package/src/shortcuts.ts CHANGED
@@ -38,11 +38,24 @@ function setBounded<K, V>(map: Map<K, V>, key: K, value: V): void {
38
38
  }
39
39
  }
40
40
 
41
+ // 配置里的函数(如自定义 fetch)按对象编号参与缓存键:同一个函数对象复用同一个客户端(WeakMap 不阻止回收)
42
+ const functionIds = new WeakMap<object, number>()
43
+ let nextFunctionId = 0
44
+
45
+ /** 配置的缓存键:对象的键按字母排序(写法顺序不同视为同一份配置),函数按对象编号;无法序列化时返回 null(不缓存) */
41
46
  function configKey(config: ClientConfig): string | null {
42
47
  try {
43
48
  return JSON.stringify(config, (_key, value: unknown) => {
44
49
  if (typeof value === 'function') {
45
- throw new Error('not comparable')
50
+ let id = functionIds.get(value)
51
+ if (id === undefined) {
52
+ id = ++nextFunctionId
53
+ functionIds.set(value, id)
54
+ }
55
+ return `function#${id}`
56
+ }
57
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
58
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
46
59
  }
47
60
  return value
48
61
  })