Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fiery-swans-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ckb-ccc/core": patch
---

fix(core): Fix concurrent client requests skipping healthy RPC fallback

82 changes: 82 additions & 0 deletions packages/core/src/jsonRpc/transports/fallback.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>): 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);
});
});
25 changes: 14 additions & 11 deletions packages/core/src/jsonRpc/transports/fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,24 @@ export class TransportFallback implements Transport {
constructor(private readonly transports: Transport[]) {}

async request(data: JsonRpcPayload): Promise<unknown> {
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) {
Comment thread
Hanssen0 marked this conversation as resolved.
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}