Skip to content

Commit c8453e5

Browse files
authored
Merge pull request #91 from AdaBliss/feat/rpc-retry-backoff
feat(rpc): add exponential backoff retry for Soroban RPC calls
2 parents 1c229a8 + e11c083 commit c8453e5

6 files changed

Lines changed: 254 additions & 15 deletions

File tree

.env.example

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@ JWT_SECRET=your-super-secret-jwt-key-change-this
55
JWT_EXPIRES_IN=7d
66
BCRYPT_ROUNDS=10
77
LOG_LEVEL=debug
8+
89
# Comma-separated list of allowed frontend origins. Use "*" to allow any origin (not recommended for production).
910
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
11+
1012
RATE_LIMIT_WINDOW_MS=900000
1113
RATE_LIMIT_MAX_REQUESTS=100
1214

1315
# ─── Stellar / Soroban ─────────────────────────────────────────────────────────
1416
# Soroban RPC endpoint.
1517
# Testnet : https://soroban-testnet.stellar.org
16-
# Mainnet : https://soroban-mainnet.stellar.org (or a custom Horizon/RPC node)
18+
# Mainnet : https://soroban-mainnet.stellar.org (or a custom Horizon/RPC node)
1719
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
1820

1921
# Network passphrase — must match SOROBAN_RPC_URL.
@@ -27,22 +29,20 @@ STELLAR_NETWORK=testnet
2729
# Request timeout (ms) for Soroban RPC calls. Default: 10000
2830
SOROBAN_RPC_TIMEOUT_MS=10000
2931

30-
# Deployed SwiftChain escrow contract id (starts with "C"). Required by
31-
# POST /api/v1/transactions/escrow-lock; endpoints that need it return 503 when unset.
32-
SOROBAN_ESCROW_CONTRACT_ID=
32+
# Retry/backoff behaviour for Soroban RPC calls (rate limits, transient outages).
33+
# Maximum attempts per call, including the first. Default: 5
34+
SOROBAN_RPC_MAX_RETRIES=5
3335

34-
# Contract function invoked to lock escrow funds. Default: lock_escrow
35-
SOROBAN_ESCROW_LOCK_FUNCTION=lock_escrow
36+
# Base delay (ms) for exponential backoff between retries. Default: 250
37+
SOROBAN_RPC_RETRY_BASE_MS=250
3638

37-
# Base fee (stroops) used when building transactions. Default: 100
38-
STELLAR_BASE_FEE=100
39+
# Maximum backoff delay (ms) between retries. Default: 8000
40+
SOROBAN_RPC_RETRY_MAX_MS=8000
3941

40-
# Validity window (seconds) of generated unsigned transactions. Default: 300
41-
STELLAR_TRANSACTION_TIMEOUT_SECONDS=300
4242
# ─── Escrow indexer ─────────────────────────────────────────────────────────────
4343
# Deployed escrow Soroban contract id (the "C..." address) whose events the
4444
# escrow indexer subscribes to.
4545
ESCROW_CONTRACT_ID=
4646

4747
# Event topic emitted by the escrow contract when funds are locked. Default: escrow_funded
48-
ESCROW_FUNDED_EVENT_TOPIC=escrow_funded
48+
ESCROW_FUNDED_EVENT_TOPIC=escrow_funded

src/blockchain/soroban.service.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { rpc as StellarRpc } from '@stellar/stellar-sdk';
22
import logger from '../config/logger';
33
import { sorobanRpcClient, stellarConfig } from '../config/stellar';
4+
import env from '../config/env';
5+
import { withRetry, RetryOptions } from '../utils/rpcRetry';
46

57
/**
68
* Result returned by a successful connectivity check.
@@ -52,6 +54,27 @@ export class SorobanService {
5254
this.client = client;
5355
}
5456

57+
/**
58+
* Retry configuration derived from environment settings, shared by every
59+
* RPC call this service makes.
60+
*/
61+
private get retryOptions(): Pick<RetryOptions, 'maxAttempts' | 'baseDelayMs' | 'maxDelayMs'> {
62+
return {
63+
maxAttempts: env.SOROBAN_RPC_MAX_RETRIES,
64+
baseDelayMs: env.SOROBAN_RPC_RETRY_BASE_MS,
65+
maxDelayMs: env.SOROBAN_RPC_RETRY_MAX_MS,
66+
};
67+
}
68+
69+
/**
70+
* Wrap a Soroban RPC call with exponential-backoff retry so transient
71+
* failures (rate limiting, temporary node outages) are absorbed instead
72+
* of propagating on the first failure.
73+
*/
74+
private callWithRetry<T>(operationName: string, fn: () => Promise<T>): Promise<T> {
75+
return withRetry(fn, { ...this.retryOptions, operationName });
76+
}
77+
5578
/**
5679
* Perform a connectivity check against the Soroban RPC node.
5780
*
@@ -71,8 +94,8 @@ export class SorobanService {
7194

7295
try {
7396
const [health, ledger] = await Promise.all([
74-
this.client.getHealth(),
75-
this.client.getLatestLedger(),
97+
this.callWithRetry('getHealth', () => this.client.getHealth()),
98+
this.callWithRetry('getLatestLedger', () => this.client.getLatestLedger()),
7699
]);
77100

78101
const latencyMs = Date.now() - start;
@@ -122,7 +145,7 @@ export class SorobanService {
122145
* @throws If the RPC call fails.
123146
*/
124147
public async getLatestLedger(): Promise<number> {
125-
const ledger = await this.client.getLatestLedger();
148+
const ledger = await this.callWithRetry('getLatestLedger', () => this.client.getLatestLedger());
126149
return ledger.sequence;
127150
}
128151

@@ -132,7 +155,7 @@ export class SorobanService {
132155
* @returns The raw `getNetwork` response from the SDK.
133156
*/
134157
public async getNetworkInfo(): Promise<StellarRpc.Api.GetNetworkResponse> {
135-
return this.client.getNetwork();
158+
return this.callWithRetry('getNetwork', () => this.client.getNetwork());
136159
}
137160
}
138161

src/config/env.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ interface EnvConfig {
1414
CORS_ORIGIN: string;
1515
RATE_LIMIT_WINDOW_MS: number;
1616
RATE_LIMIT_MAX_REQUESTS: number;
17+
SOROBAN_RPC_MAX_RETRIES: number;
18+
SOROBAN_RPC_RETRY_BASE_MS: number;
19+
SOROBAN_RPC_RETRY_MAX_MS: number;
1720
}
1821

1922
const envSchema = z.object({
@@ -27,6 +30,9 @@ const envSchema = z.object({
2730
CORS_ORIGIN: z.string().default('*'),
2831
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().min(1000).default(900000),
2932
RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().min(1).default(100),
33+
SOROBAN_RPC_MAX_RETRIES: z.coerce.number().int().min(1).max(10).default(5),
34+
SOROBAN_RPC_RETRY_BASE_MS: z.coerce.number().int().min(1).default(250),
35+
SOROBAN_RPC_RETRY_MAX_MS: z.coerce.number().int().min(1).default(8000),
3036
});
3137

3238
let env: EnvConfig;

src/utils/rpcRetry.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import logger from '../config/logger';
2+
3+
/**
4+
* Options controlling retry/backoff behaviour for `withRetry`.
5+
*/
6+
export interface RetryOptions {
7+
/** Maximum number of attempts (including the first). Default: 5. */
8+
maxAttempts?: number;
9+
/** Base delay in milliseconds used for exponential backoff. Default: 250. */
10+
baseDelayMs?: number;
11+
/** Upper bound on any single backoff delay. Default: 8000. */
12+
maxDelayMs?: number;
13+
/** Multiplier applied to the delay on each retry. Default: 2. */
14+
factor?: number;
15+
/** Fraction of jitter (0-1) applied to each computed delay. Default: 0.2. */
16+
jitter?: number;
17+
/** Label used in log messages to identify the operation being retried. */
18+
operationName?: string;
19+
/** Predicate deciding whether a given error should trigger a retry. Defaults to retrying everything. */
20+
isRetryable?: (error: unknown) => boolean;
21+
}
22+
23+
const DEFAULT_OPTIONS: Required<Omit<RetryOptions, 'operationName' | 'isRetryable'>> = {
24+
maxAttempts: 5,
25+
baseDelayMs: 250,
26+
maxDelayMs: 8000,
27+
factor: 2,
28+
jitter: 0.2,
29+
};
30+
31+
function sleep(ms: number): Promise<void> {
32+
return new Promise((resolve) => setTimeout(resolve, ms));
33+
}
34+
35+
/**
36+
* Compute the delay for a given retry attempt using exponential backoff with
37+
* full jitter, capped at `maxDelayMs`.
38+
*
39+
* @param attempt Zero-based retry attempt number (0 = first retry).
40+
*/
41+
export function computeBackoffDelay(
42+
attempt: number,
43+
options: Required<Omit<RetryOptions, 'operationName' | 'isRetryable'>>,
44+
): number {
45+
const exponential = options.baseDelayMs * Math.pow(options.factor, attempt);
46+
const capped = Math.min(exponential, options.maxDelayMs);
47+
const jitterRange = capped * options.jitter;
48+
const jitterOffset = (Math.random() * 2 - 1) * jitterRange;
49+
return Math.max(0, Math.round(capped + jitterOffset));
50+
}
51+
52+
/**
53+
* Execute `fn`, retrying with exponential backoff on failure.
54+
*
55+
* Intended for wrapping Soroban RPC calls that may fail transiently due to
56+
* rate limiting (HTTP 429) or temporary node outages. Every failed attempt
57+
* is logged; once all attempts are exhausted the last error is rethrown so
58+
* callers can handle it as they would an unwrapped RPC failure.
59+
*
60+
* @param fn The async operation to execute.
61+
* @param options Retry/backoff configuration.
62+
*/
63+
export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
64+
const resolved = { ...DEFAULT_OPTIONS, ...options };
65+
const operationName = options.operationName ?? 'rpc-call';
66+
const isRetryable = options.isRetryable ?? ((): boolean => true);
67+
68+
let lastError: unknown;
69+
70+
for (let attempt = 0; attempt < resolved.maxAttempts; attempt += 1) {
71+
try {
72+
return await fn();
73+
} catch (err) {
74+
lastError = err;
75+
const attemptNumber = attempt + 1;
76+
const isLastAttempt = attemptNumber >= resolved.maxAttempts;
77+
const message = err instanceof Error ? err.message : String(err);
78+
79+
if (!isRetryable(err) || isLastAttempt) {
80+
logger.error(
81+
`[RPC Retry] ${operationName} failed permanently after ${attemptNumber} attempt(s) — error="${message}"`,
82+
);
83+
throw err;
84+
}
85+
86+
const delayMs = computeBackoffDelay(attempt, resolved);
87+
88+
logger.warn(
89+
`[RPC Retry] ${operationName} attempt ${attemptNumber}/${resolved.maxAttempts} failed ` +
90+
`— error="${message}" — retrying in ${delayMs}ms`,
91+
);
92+
93+
await sleep(delayMs);
94+
}
95+
}
96+
97+
// Unreachable in practice (loop always returns or throws), kept for type safety.
98+
throw lastError;
99+
}

tests/rpcRetry.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Unit tests for the withRetry exponential-backoff helper.
3+
*/
4+
5+
import { withRetry, computeBackoffDelay } from '../src/utils/rpcRetry';
6+
7+
jest.mock('../src/config/logger', () => ({
8+
info: jest.fn(),
9+
warn: jest.fn(),
10+
error: jest.fn(),
11+
debug: jest.fn(),
12+
}));
13+
14+
describe('withRetry', () => {
15+
it('returns the result immediately when the operation succeeds on the first try', async () => {
16+
const fn = jest.fn().mockResolvedValue('ok');
17+
18+
const result = await withRetry(fn, { maxAttempts: 3, baseDelayMs: 1, maxDelayMs: 2 });
19+
20+
expect(result).toBe('ok');
21+
expect(fn).toHaveBeenCalledTimes(1);
22+
});
23+
24+
it('retries on failure and eventually succeeds', async () => {
25+
const fn = jest
26+
.fn()
27+
.mockRejectedValueOnce(new Error('timeout'))
28+
.mockRejectedValueOnce(new Error('timeout'))
29+
.mockResolvedValue('recovered');
30+
31+
const result = await withRetry(fn, { maxAttempts: 5, baseDelayMs: 1, maxDelayMs: 2 });
32+
33+
expect(result).toBe('recovered');
34+
expect(fn).toHaveBeenCalledTimes(3);
35+
});
36+
37+
it('throws the last error once maxAttempts is exhausted', async () => {
38+
const fn = jest.fn().mockRejectedValue(new Error('persistent failure'));
39+
40+
await expect(withRetry(fn, { maxAttempts: 3, baseDelayMs: 1, maxDelayMs: 2 })).rejects.toThrow(
41+
'persistent failure',
42+
);
43+
expect(fn).toHaveBeenCalledTimes(3);
44+
});
45+
46+
it('does not retry when isRetryable returns false', async () => {
47+
const fn = jest.fn().mockRejectedValue(new Error('not retryable'));
48+
49+
await expect(
50+
withRetry(fn, {
51+
maxAttempts: 5,
52+
baseDelayMs: 1,
53+
maxDelayMs: 2,
54+
isRetryable: () => false,
55+
}),
56+
).rejects.toThrow('not retryable');
57+
expect(fn).toHaveBeenCalledTimes(1);
58+
});
59+
});
60+
61+
describe('computeBackoffDelay', () => {
62+
const options = { maxAttempts: 5, baseDelayMs: 100, maxDelayMs: 1000, factor: 2, jitter: 0 };
63+
64+
it('grows exponentially with the attempt number', () => {
65+
expect(computeBackoffDelay(0, options)).toBe(100);
66+
expect(computeBackoffDelay(1, options)).toBe(200);
67+
expect(computeBackoffDelay(2, options)).toBe(400);
68+
});
69+
70+
it('caps the delay at maxDelayMs', () => {
71+
expect(computeBackoffDelay(10, options)).toBe(1000);
72+
});
73+
});

tests/soroban.service.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ jest.mock('../src/config/stellar', () => ({
3636
createSorobanRpcClient: jest.fn(),
3737
}));
3838

39+
// Keep retry backoff delays effectively instant so retry tests run fast.
40+
jest.mock('../src/config/env', () => ({
41+
__esModule: true,
42+
default: {
43+
SOROBAN_RPC_MAX_RETRIES: 3,
44+
SOROBAN_RPC_RETRY_BASE_MS: 1,
45+
SOROBAN_RPC_RETRY_MAX_MS: 2,
46+
},
47+
}));
48+
3949
// ─── Helpers ──────────────────────────────────────────────────────────────────
4050

4151
/** Build a minimal mock of rpc.Server with controllable method responses. */
@@ -200,4 +210,32 @@ describe('SorobanService', () => {
200210
await expect(service.getNetworkInfo()).rejects.toThrow('network error');
201211
});
202212
});
213+
214+
// ── retry / backoff behaviour ──────────────────────────────────────────────
215+
216+
describe('retry on transient RPC failures', () => {
217+
it('retries getLatestLedger and succeeds once the node recovers', async () => {
218+
const getLatestLedger = jest
219+
.fn()
220+
.mockRejectedValueOnce(new Error('429 rate limited'))
221+
.mockResolvedValue({ sequence: 999, id: 'xyz', protocolVersion: 21 });
222+
const client = makeMockClient({ getLatestLedger });
223+
const service = new SorobanService(client);
224+
225+
const seq = await service.getLatestLedger();
226+
227+
expect(seq).toBe(999);
228+
expect(getLatestLedger).toHaveBeenCalledTimes(2);
229+
});
230+
231+
it('gives up and throws after exhausting configured retry attempts', async () => {
232+
const getNetwork = jest.fn().mockRejectedValue(new Error('ECONNRESET'));
233+
const client = makeMockClient({ getNetwork });
234+
const service = new SorobanService(client);
235+
236+
await expect(service.getNetworkInfo()).rejects.toThrow('ECONNRESET');
237+
// SOROBAN_RPC_MAX_RETRIES is mocked to 3 attempts total.
238+
expect(getNetwork).toHaveBeenCalledTimes(3);
239+
});
240+
});
203241
});

0 commit comments

Comments
 (0)