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
Empty file.
28 changes: 28 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"p-retry": "^7.1.1",
"pg": "^8.18.0",
"socket.io": "^4.8.3",
"socket.io-redis": "^5.4.0",
Expand Down
76 changes: 76 additions & 0 deletions rpc-retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { executeRpcWithRetry } from './rpc-retry';

describe('executeRpcWithRetry', () => {
let mockRpc: jest.Mock;
let consoleWarnSpy: jest.SpyInstance;

beforeEach(() => {
mockRpc = jest.fn();
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
// Use fake timers to skip waiting for exponential backoff during tests
jest.useFakeTimers();
});

afterEach(() => {
jest.clearAllMocks();
jest.useRealTimers();
});

it('should return data immediately if successful', async () => {
mockRpc.mockResolvedValue('success');
const result = await executeRpcWithRetry(mockRpc);
expect(result).toBe('success');
expect(mockRpc).toHaveBeenCalledTimes(1);
});

it('should log a warning exactly after the 3rd failed attempt', async () => {
// Fail 3 times, then succeed on the 4th
mockRpc
.mockRejectedValueOnce(new Error('Fail 1'))
.mockRejectedValueOnce(new Error('Fail 2'))
.mockRejectedValueOnce(new Error('Fail 3'))
.mockResolvedValue('success');

const promise = executeRpcWithRetry(mockRpc, 'TestContext');

// Advance timers to trigger the scheduled retries
for (let i = 0; i < 4; i++) {
jest.runAllTimers();
await Promise.resolve(); // Flush promise microtasks
}

await promise;

expect(mockRpc).toHaveBeenCalledTimes(4);
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('[WARNING] TestContext: Failed 3 times')
);
});

it('should crash (throw) after 10 retries (11 total attempts)', async () => {
mockRpc.mockRejectedValue(new Error('Persistent Error'));

const promise = executeRpcWithRetry(mockRpc);

// Exhaust all retries
for (let i = 0; i < 12; i++) {
jest.runAllTimers();
await Promise.resolve();
}

await expect(promise).rejects.toThrow('Persistent Error');
// Initial attempt + 10 retries = 11 calls
expect(mockRpc).toHaveBeenCalledTimes(11);
});

it('should abort immediately on 400 Bad Request (Client Error)', async () => {
const badRequestError: any = new Error('Bad Request');
badRequestError.response = { status: 400 };

mockRpc.mockRejectedValue(badRequestError);

await expect(executeRpcWithRetry(mockRpc)).rejects.toThrow('Bad Request');
expect(mockRpc).toHaveBeenCalledTimes(1);
});
});
42 changes: 42 additions & 0 deletions rpc-retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import pRetry, { AbortError, FailedAttemptError } from 'p-retry';

/**
* Wraps an RPC call with an exponential backoff retry mechanism.
*
* Acceptance Criteria:
* 1. Exponential backoff (factor: 2).
* 2. Log warning after 3 failed attempts.
* 3. Crash (throw) only after 10 failed attempts.
*/
export async function executeRpcWithRetry<T>(
rpcFunction: () => Promise<T>,
context: string = 'RPC Call'
): Promise<T> {
const run = async () => {
try {
return await rpcFunction();
} catch (error: any) {
// Stellar SDK / Axios errors usually have a response object.
// We should NOT retry on 4xx errors (Client Error), except for 429 (Rate Limit).
const status = error.response?.status;
if (status && status >= 400 && status < 500 && status !== 429) {
throw new AbortError(error);
}
throw error;
}
};

return pRetry(run, {
retries: 10,
factor: 2,
minTimeout: 1000,
maxTimeout: 60000,
onFailedAttempt: (error: FailedAttemptError) => {
if (error.attemptNumber === 3) {
console.warn(
`[WARNING] ${context}: Failed 3 times. Retrying... Error: ${error.message}`
);
}
},
});
}