diff --git a/import { Server } from 'stellar-sdk';.ts b/import { Server } from 'stellar-sdk';.ts new file mode 100644 index 00000000..e69de29b diff --git a/package-lock.json b/package-lock.json index 87c5ba0b..d85ae99b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,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", @@ -1597,6 +1598,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-network-error": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", + "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -1825,6 +1838,21 @@ "wrappy": "1" } }, + "node_modules/p-retry": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz", + "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==", + "license": "MIT", + "dependencies": { + "is-network-error": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", diff --git a/package.json b/package.json index 9f31dc9c..a9e22b17 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/rpc-retry.test.ts b/rpc-retry.test.ts new file mode 100644 index 00000000..054e2730 --- /dev/null +++ b/rpc-retry.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/rpc-retry.ts b/rpc-retry.ts new file mode 100644 index 00000000..caeb91d9 --- /dev/null +++ b/rpc-retry.ts @@ -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( + rpcFunction: () => Promise, + context: string = 'RPC Call' +): Promise { + 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}` + ); + } + }, + }); +} \ No newline at end of file