From 7db4875ea4a2043dfa17daa354a8e502ab50408c Mon Sep 17 00:00:00 2001 From: Hanssen0 <0@hanssen0.com> Date: Tue, 21 Jul 2026 02:57:16 +0800 Subject: [PATCH] fix(core): Fix concurrent client requests skipping healthy RPC fallback --- .changeset/fiery-swans-end.md | 6 ++ .../src/jsonRpc/transports/fallback.test.ts | 82 +++++++++++++++++++ .../core/src/jsonRpc/transports/fallback.ts | 25 +++--- 3 files changed, 102 insertions(+), 11 deletions(-) create mode 100644 .changeset/fiery-swans-end.md create mode 100644 packages/core/src/jsonRpc/transports/fallback.test.ts diff --git a/.changeset/fiery-swans-end.md b/.changeset/fiery-swans-end.md new file mode 100644 index 000000000..c6f48d85e --- /dev/null +++ b/.changeset/fiery-swans-end.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": patch +--- + +fix(core): Fix concurrent client requests skipping healthy RPC fallback + \ No newline at end of file diff --git a/packages/core/src/jsonRpc/transports/fallback.test.ts b/packages/core/src/jsonRpc/transports/fallback.test.ts new file mode 100644 index 000000000..1f3df0b44 --- /dev/null +++ b/packages/core/src/jsonRpc/transports/fallback.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { TransportFallback } from "./fallback.js"; +import { JsonRpcPayload, Transport } from "./transport.js"; + +const payload: JsonRpcPayload = { + id: 0, + jsonrpc: "2.0", + method: "test", + params: [], +}; + +function makeTransport(handler: () => Promise): Transport { + return { request: () => handler() }; +} + +describe("TransportFallback", () => { + it("returns result from the first healthy transport", async () => { + const transport = new TransportFallback([ + makeTransport(() => Promise.resolve("ok")), + ]); + expect(await transport.request(payload)).toBe("ok"); + }); + + it("falls back to the next transport when the first fails", async () => { + const transport = new TransportFallback([ + makeTransport(() => Promise.reject(new Error("fail"))), + makeTransport(() => Promise.resolve("ok")), + ]); + expect(await transport.request(payload)).toBe("ok"); + }); + + it("throws when all transports fail", async () => { + const transport = new TransportFallback([ + makeTransport(() => Promise.reject(new Error("fail A"))), + makeTransport(() => Promise.reject(new Error("fail B"))), + ]); + await expect(transport.request(payload)).rejects.toThrow("fail B"); + }); + + it("concurrent requests both succeed when the first transport is down", async () => { + // Transport A is always unavailable; transport B always succeeds. + // Two concurrent requests should each fall back to B independently. + const transport = new TransportFallback([ + makeTransport(() => Promise.reject(new Error("A unavailable"))), + makeTransport(() => Promise.resolve("ok")), + ]); + + const results = await Promise.allSettled([ + transport.request(payload), + transport.request(payload), + ]); + + expect(results[0]).toMatchObject({ status: "fulfilled", value: "ok" }); + expect(results[1]).toMatchObject({ status: "fulfilled", value: "ok" }); + }); + + it("advances the starting transport after failures so future requests skip known-bad transports", async () => { + let callsToA = 0; + let callsToB = 0; + + const transport = new TransportFallback([ + makeTransport(() => { + callsToA += 1; + return Promise.reject(new Error("A unavailable")); + }), + makeTransport(() => { + callsToB += 1; + return Promise.resolve("ok"); + }), + ]); + + // First request: tries A (fails), then B (succeeds) + await transport.request(payload); + expect(callsToA).toBe(1); + expect(callsToB).toBe(1); + + // Second request: should start from B (since A was the last known failure) + await transport.request(payload); + expect(callsToA).toBe(1); + expect(callsToB).toBe(2); + }); +}); diff --git a/packages/core/src/jsonRpc/transports/fallback.ts b/packages/core/src/jsonRpc/transports/fallback.ts index db889c538..81531a639 100644 --- a/packages/core/src/jsonRpc/transports/fallback.ts +++ b/packages/core/src/jsonRpc/transports/fallback.ts @@ -7,21 +7,24 @@ export class TransportFallback implements Transport { constructor(private readonly transports: Transport[]) {} async request(data: JsonRpcPayload): Promise { - let triedCount = 0; + const startI = this.i; + let lastErr: unknown = new Error( + "TransportFallback requires at least one transport", + ); + + for (let tried = 0; tried < this.transports.length; tried += 1) { + const i = (startI + tried) % this.transports.length; - while (true) { try { - return await this.transports[this.i % this.transports.length].request( - data, - ); - } catch (err) { - triedCount += 1; - this.i += 1; + const res = await this.transports[i].request(data); - if (triedCount >= this.transports.length) { - throw err; - } + this.i = i; + return res; + } catch (err) { + lastErr = err; } } + + throw lastErr; } }