diff --git a/src/lib/__tests__/mock-client.test.ts b/src/lib/__tests__/mock-client.test.ts index 36e229b5..d56bfa13 100644 --- a/src/lib/__tests__/mock-client.test.ts +++ b/src/lib/__tests__/mock-client.test.ts @@ -1,6 +1,11 @@ -import { describe, expect,it } from 'vitest'; - -import type { SorokitClient } from '../client'; +import { describe, expect, it, vi } from 'vitest'; + +import { + decodeHorizonError, + SorobanClient, + type SorokitClient, + withRpcRetry, +} from '../client'; import { deterministicMock,DeterministicMockData } from '../deterministic-mock'; import { createMockClient,MOCK_ADDRESS } from '../mock-client'; @@ -147,5 +152,68 @@ describe('Mock Client - Issue #30 Fixes', () => { expect(page1Ids).not.toEqual(page2Ids); }); }); + + describe('Fix 5: Exponential backoff retry and Horizon error decoder (Issue #974)', () => { + it('retries transient HTTP 429 and 503 RPC errors up to 3 times with exponential backoff', async () => { + const delays: number[] = []; + let calls = 0; + + const result = await withRpcRetry( + async () => { + calls += 1; + if (calls === 1) throw { status: 429, detail: 'Rate limit exceeded' }; + if (calls === 2) throw { status: 503, detail: 'Service unavailable' }; + if (calls === 3) throw { status: 502, detail: 'Bad gateway' }; + return { ledger: 999 }; + }, + { + maxRetries: 3, + baseDelayMs: 100, + jitterRatio: 0, + onRetry: (_attempt, delayMs) => { + delays.push(delayMs); + }, + }, + ); + + expect(result).toEqual({ ledger: 999 }); + expect(calls).toBe(4); + expect(delays).toEqual([100, 200, 400]); + }); + + it('decodes Horizon extras.result_codes including op_underfunded and op_bad_auth', () => { + const decoded = decodeHorizonError({ + status: 400, + detail: 'The transaction failed when submitted to the stellar network.', + extras: { + result_codes: { + transaction: 'tx_failed', + operations: ['op_success', 'op_underfunded', 'op_bad_auth'], + }, + }, + }); + + expect(decoded.transactionCode).toBe('tx_failed'); + expect(decoded.operationCodes).toEqual(['op_success', 'op_underfunded', 'op_bad_auth']); + expect(decoded.message).toContain('op_underfunded'); + expect(decoded.message).toContain('op_bad_auth'); + }); + + it('SorobanClient retries transient network dropouts and formats non-retryable Horizon errors', async () => { + const requestMock = vi + .fn() + .mockRejectedValueOnce(new Error('503 Service Unavailable')) + .mockResolvedValueOnce({ status: 'SUCCESS' }); + + const rpc = new SorobanClient( + { request: requestMock }, + { maxRetries: 3, baseDelayMs: 10, jitterRatio: 0, onRetry: () => {} }, + ); + + const ok = await rpc.call('getHealth'); + expect(ok).toEqual({ status: 'SUCCESS' }); + expect(requestMock).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/src/lib/client.ts b/src/lib/client.ts index 56127412..ef859ba7 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -501,3 +501,166 @@ export function getClient(): SorokitClient { ); return _client; } + +export const RETRYABLE_HTTP_STATUSES = new Set([429, 502, 503, 504]); +export const DEFAULT_RPC_MAX_RETRIES = 3; +export const DEFAULT_RPC_BASE_DELAY_MS = 300; + +const HORIZON_OP_CODE_MESSAGES: Record = { + op_underfunded: "Insufficient balance to fund this operation (op_underfunded).", + op_bad_auth: "Invalid signature or missing signer weight for this operation (op_bad_auth).", + op_no_destination: "Destination account does not exist on the network (op_no_destination).", + op_no_trust: "Account is missing a trustline for the specified asset (op_no_trust).", + op_line_full: "Destination trustline limit would be exceeded (op_line_full).", + tx_bad_seq: "Transaction sequence number does not match the account (tx_bad_seq).", + tx_bad_auth: "Transaction authentication failed (tx_bad_auth).", + tx_insufficient_fee: "Transaction fee is below the network minimum (tx_insufficient_fee).", + tx_insufficient_balance: "Account balance cannot cover the fee and reserve (tx_insufficient_balance).", +}; + +export interface HorizonErrorPayload { + status?: number; + title?: string; + detail?: string; + extras?: { + result_codes?: { + transaction?: string; + operations?: string[]; + }; + }; +} + +export interface DecodedHorizonError { + message: string; + transactionCode: string | null; + operationCodes: string[]; +} + +/** + * Decode a Horizon error response, extracting both top-level `detail` and + * `extras.result_codes` (`transaction` and `operations` codes) into an + * actionable message. + */ +export function decodeHorizonError(error: unknown): DecodedHorizonError { + if (error && typeof error === "object") { + const payload = error as HorizonErrorPayload; + const txCode = payload.extras?.result_codes?.transaction ?? null; + const opCodes = Array.isArray(payload.extras?.result_codes?.operations) + ? payload.extras!.result_codes!.operations! + : []; + + const descriptions: string[] = []; + if (txCode) { + descriptions.push(HORIZON_OP_CODE_MESSAGES[txCode] ?? `Transaction error: ${txCode}`); + } + for (const op of opCodes) { + if (op && op !== "op_success") { + descriptions.push(HORIZON_OP_CODE_MESSAGES[op] ?? `Operation error: ${op}`); + } + } + + const baseDetail = payload.detail || payload.title || "Horizon request failed"; + const message = + descriptions.length > 0 + ? `${baseDetail} — ${descriptions.join(" ")}` + : baseDetail; + + return { + message, + transactionCode: txCode, + operationCodes: opCodes, + }; + } + + const fallback = error instanceof Error ? error.message : String(error ?? "Unknown error"); + return { + message: fallback, + transactionCode: null, + operationCodes: [], + }; +} + +export interface RpcRetryOptions { + maxRetries?: number; + baseDelayMs?: number; + jitterRatio?: number; + randomFn?: () => number; + onRetry?: (attempt: number, delayMs: number, error: unknown) => Promise | void; +} + +function isTransientRpcError(error: unknown): boolean { + if (!error) return false; + if (typeof error === "object") { + const status = (error as { status?: number; statusCode?: number }).status ?? + (error as { statusCode?: number }).statusCode; + if (typeof status === "number" && RETRYABLE_HTTP_STATUSES.has(status)) { + return true; + } + } + const msg = error instanceof Error ? error.message : String(error); + return /\b(429|502|503|504|rate limit|service unavailable|bad gateway|gateway timeout|network dropout|econnreset|etimedout)\b/i.test( + msg, + ); +} + +/** + * Execute an async RPC operation with automatic retries, exponential backoff, + * and jitter for transient HTTP 429, 502, 503, and 504 errors. + */ +export async function withRpcRetry( + operation: () => Promise, + options: RpcRetryOptions = {}, +): Promise { + const { + maxRetries = DEFAULT_RPC_MAX_RETRIES, + baseDelayMs = DEFAULT_RPC_BASE_DELAY_MS, + jitterRatio = 0.2, + randomFn = Math.random, + onRetry, + } = options; + + let attempt = 0; + while (true) { + try { + return await operation(); + } catch (err) { + if (attempt >= maxRetries || !isTransientRpcError(err)) { + throw err; + } + attempt += 1; + const expDelay = baseDelayMs * 2 ** (attempt - 1); + const jitter = Math.round(expDelay * jitterRatio * randomFn()); + const delayMs = expDelay + jitter; + if (onRetry) { + await onRetry(attempt, delayMs, err); + } else { + await new Promise((r) => setTimeout(r, delayMs)); + } + } + } +} + +/** + * Soroban RPC wrapper that applies exponential-backoff retries on transient + * HTTP 429/502/503/504 failures and decodes Horizon operation error codes. + */ +export class SorobanClient { + constructor( + private readonly transport: { + request: (method: string, params?: unknown) => Promise; + }, + private readonly retryOptions: RpcRetryOptions = {}, + ) {} + + async call(method: string, params?: unknown): Promise { + try { + return await withRpcRetry( + () => this.transport.request(method, params), + this.retryOptions, + ); + } catch (err) { + const decoded = decodeHorizonError(err); + throw new Error(decoded.message); + } + } +}