Skip to content

Commit 01e71ed

Browse files
authored
Merge pull request #1425 from ponmileleke54-dev/#1250
fix: cache Soroban RPC health checks
2 parents be83767 + 10a1dbc commit 01e71ed

2 files changed

Lines changed: 70 additions & 5 deletions

File tree

backend/src/services/sorobanService.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ function getTxPollIntervalMs(): number {
4444
return Number(process.env.SOROBAN_TX_POLL_INTERVAL_MS ?? 1_000);
4545
}
4646

47+
const DEFAULT_RPC_HEALTH_CACHE_TTL_MS = 10_000;
48+
49+
let rpcHealthCache: { ok: boolean; expiresAt: number } | null = null;
50+
let rpcHealthPromise: Promise<boolean> | null = null;
51+
52+
function getRpcHealthCacheTtlMs(): number {
53+
return Number(process.env.SOROBAN_RPC_HEALTH_CACHE_TTL_MS ?? DEFAULT_RPC_HEALTH_CACHE_TTL_MS);
54+
}
55+
56+
export function resetRpcHealthCache(): void {
57+
rpcHealthCache = null;
58+
rpcHealthPromise = null;
59+
}
60+
4761
export class RpcTimeoutError extends Error {
4862
constructor(label: string, timeoutMs: number) {
4963
super(`${label} timed out after ${timeoutMs}ms`);
@@ -139,12 +153,36 @@ async function executeRpc<T>(label: string, operation: (server: rpc.Server) => P
139153
* unreachable Soroban RPC endpoint can't hang the health check.
140154
*/
141155
export async function checkRpcHealth(timeoutMs = 3_000): Promise<boolean> {
142-
try {
143-
await withRpcTimeout('soroban rpc health check', () => executeRpc('soroban rpc health check', (server) => server.getHealth()), timeoutMs);
144-
return true;
145-
} catch {
146-
return false;
156+
const now = Date.now();
157+
const ttlMs = getRpcHealthCacheTtlMs();
158+
159+
if (rpcHealthCache && now < rpcHealthCache.expiresAt) {
160+
return rpcHealthCache.ok;
147161
}
162+
163+
if (rpcHealthPromise) {
164+
return rpcHealthPromise;
165+
}
166+
167+
rpcHealthPromise = (async () => {
168+
try {
169+
const ok = await withRpcTimeout(
170+
'soroban rpc health check',
171+
() => executeRpc('soroban rpc health check', (server) => server.getHealth()),
172+
timeoutMs,
173+
);
174+
const result = Boolean(ok);
175+
rpcHealthCache = { ok: result, expiresAt: Date.now() + ttlMs };
176+
return result;
177+
} catch {
178+
rpcHealthCache = { ok: false, expiresAt: Date.now() + ttlMs };
179+
return false;
180+
} finally {
181+
rpcHealthPromise = null;
182+
}
183+
})();
184+
185+
return rpcHealthPromise;
148186
}
149187

150188
export function setServer(server: rpc.Server): void {

backend/tests/soroban.service.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
const mocks = vi.hoisted(() => {
1212
const server = {
1313
getAccount: vi.fn(),
14+
getHealth: vi.fn(),
1415
simulateTransaction: vi.fn(),
1516
sendTransaction: vi.fn(),
1617
getTransaction: vi.fn(),
@@ -99,6 +100,7 @@ describe('Soroban Service', () => {
99100
beforeEach(() => {
100101
vi.clearAllMocks();
101102
mocks.isSimulationError.mockReturnValue(false);
103+
mocks.server.getHealth.mockResolvedValue({ status: 'healthy' });
102104
});
103105

104106
afterEach(() => {
@@ -107,6 +109,31 @@ describe('Soroban Service', () => {
107109
delete process.env.SOROBAN_RPC_URL;
108110
});
109111

112+
describe('checkRpcHealth', () => {
113+
it('caches successful RPC health checks for the TTL window', async () => {
114+
const { checkRpcHealth, resetRpcHealthCache } = await importService();
115+
resetRpcHealthCache();
116+
117+
await expect(checkRpcHealth(25)).resolves.toBe(true);
118+
await expect(checkRpcHealth(25)).resolves.toBe(true);
119+
expect(mocks.server.getHealth).toHaveBeenCalledTimes(1);
120+
});
121+
122+
it('deduplicates concurrent health checks while a refresh is in flight', async () => {
123+
const { checkRpcHealth, resetRpcHealthCache } = await importService();
124+
resetRpcHealthCache();
125+
126+
const [first, second] = await Promise.all([
127+
checkRpcHealth(25),
128+
checkRpcHealth(25),
129+
]);
130+
131+
expect(first).toBe(true);
132+
expect(second).toBe(true);
133+
expect(mocks.server.getHealth).toHaveBeenCalledTimes(1);
134+
});
135+
});
136+
110137
describe('isStale', () => {
111138
it('should return true if updated more than 30s ago', async () => {
112139
const { isStale } = await importService();

0 commit comments

Comments
 (0)