11import { rpc as StellarRpc } from '@stellar/stellar-sdk' ;
2+ import CircuitBreaker from 'opossum' ;
23import logger from '../config/logger' ;
34import { sorobanRpcClient , stellarConfig } from '../config/stellar' ;
45import env from '../config/env' ;
56import { withRetry , RetryOptions } from '../utils/rpcRetry' ;
7+ import { createCircuitBreaker , fireWithBreaker } from '../utils/circuitBreaker' ;
68
79/**
810 * Result returned by a successful connectivity check.
911 */
1012export interface ConnectivityCheckResult {
11- /** Whether the RPC node is reachable and healthy. */
1213 connected : boolean ;
13- /** Human-readable network alias. */
1414 network : string ;
15- /** Network passphrase used. */
1615 networkPassphrase : string ;
17- /** RPC endpoint that was queried. */
1816 rpcUrl : string ;
19- /** Health status string returned by the node (e.g. "healthy"). */
2017 status : string ;
21- /** Latest ledger number at time of check. */
2218 latestLedger : number ;
23- /** ISO timestamp of when the check was performed. */
2419 checkedAt : string ;
25- /** Round-trip latency in milliseconds. */
2620 latencyMs : number ;
2721}
2822
@@ -37,27 +31,63 @@ export interface ConnectivityCheckError {
3731 error : string ;
3832}
3933
34+ /**
35+ * Degraded ledger response returned when the Soroban circuit is OPEN.
36+ * Controllers should treat `degraded: true` as a signal to surface a 503.
37+ */
38+ export interface DegradedLedgerResult {
39+ degraded : true ;
40+ reason : string ;
41+ }
42+
4043/**
4144 * SorobanService provides the business-logic layer for all Stellar / Soroban
42- * RPC interactions.
45+ * RPC interactions, protected by a shared circuit breaker.
46+ *
47+ * Circuit-breaker behaviour:
48+ * - CLOSED — RPC calls are executed normally (with retry).
49+ * - OPEN — calls are short-circuited; fallback values are returned
50+ * immediately so the API stays responsive.
51+ * - HALF-OPEN — one probe call is allowed through to test recovery.
4352 *
44- * Responsibilities:
45- * - Perform a live connectivity check against the configured RPC node.
46- * - Surface health, network, and ledger data for API responses.
47- * - Abstract the raw SDK client behind a typed interface so higher layers
48- * (controllers, other services) are decoupled from the SDK.
53+ * The circuit breaker wraps individual RPC calls rather than the service
54+ * methods themselves, so that `checkConnectivity()` — which already handles
55+ * its own errors — is not double-wrapped.
4956 */
5057export class SorobanService {
5158 private readonly client : StellarRpc . Server ;
5259
60+ /**
61+ * Shared circuit breaker for all Soroban RPC operations.
62+ * Typed as `CircuitBreaker<[() => Promise<unknown>], unknown>` because we
63+ * use `fireWithBreaker` to pass a different action on each call.
64+ */
65+ private readonly breaker : CircuitBreaker < [ ( ) => Promise < unknown > ] , unknown > ;
66+
5367 constructor ( client : StellarRpc . Server = sorobanRpcClient ) {
5468 this . client = client ;
69+
70+ this . breaker = createCircuitBreaker < [ ( ) => Promise < unknown > ] , unknown > (
71+ {
72+ name : 'soroban-rpc' ,
73+ errorThresholdPercentage : env . CB_SOROBAN_ERROR_THRESHOLD_PERCENTAGE ,
74+ rollingWindowMs : env . CB_SOROBAN_ROLLING_WINDOW_MS ,
75+ resetTimeoutMs : env . CB_SOROBAN_RESET_TIMEOUT_MS ,
76+ volumeThreshold : env . CB_SOROBAN_VOLUME_THRESHOLD ,
77+ timeoutMs : env . CB_SOROBAN_TIMEOUT_MS ,
78+ } ,
79+ // Fallback: return a sentinel so callers know the result is degraded.
80+ ( ) : DegradedLedgerResult => ( {
81+ degraded : true ,
82+ reason :
83+ 'Soroban RPC circuit is OPEN — the node is temporarily unreachable. ' +
84+ 'The system will automatically retry when the circuit recovers.' ,
85+ } ) ,
86+ ) ;
5587 }
5688
57- /**
58- * Retry configuration derived from environment settings, shared by every
59- * RPC call this service makes.
60- */
89+ // ── Retry configuration ──────────────────────────────────────────────────────
90+
6191 private get retryOptions ( ) : Pick < RetryOptions , 'maxAttempts' | 'baseDelayMs' | 'maxDelayMs' > {
6292 return {
6393 maxAttempts : env . SOROBAN_RPC_MAX_RETRIES ,
@@ -67,22 +97,33 @@ export class SorobanService {
6797 }
6898
6999 /**
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 .
100+ * Wrap a Soroban RPC call with:
101+ * 1. Exponential-backoff retry (absorbs transient failures / rate limits).
102+ * 2. Circuit breaker (trips when sustained failures exceed the threshold) .
73103 */
74- private callWithRetry < T > ( operationName : string , fn : ( ) => Promise < T > ) : Promise < T > {
75- return withRetry ( fn , { ...this . retryOptions , operationName } ) ;
104+ private async callWithRetryAndBreaker < T > (
105+ operationName : string ,
106+ fn : ( ) => Promise < T > ,
107+ ) : Promise < T | DegradedLedgerResult > {
108+ const retryWrapped = ( ) : Promise < T > =>
109+ withRetry ( fn , { ...this . retryOptions , operationName } ) ;
110+
111+ return fireWithBreaker (
112+ this . breaker as CircuitBreaker < [ ( ) => Promise < T > ] , T | DegradedLedgerResult > ,
113+ ( action : ( ) => Promise < T > ) => action ( ) ,
114+ retryWrapped ,
115+ ) ;
76116 }
77117
118+ // ── Public API ───────────────────────────────────────────────────────────────
119+
78120 /**
79- * Perform a connectivity check against the Soroban RPC node.
121+ * Perform a live connectivity check against the Soroban RPC node.
80122 *
81- * Calls `getHealth()` and `getLatestLedger()` in parallel. Both must
82- * succeed for the check to be considered healthy.
83- *
84- * @returns A `ConnectivityCheckResult` on success, or a
85- * `ConnectivityCheckError` on failure.
123+ * Calls `getHealth()` and `getLatestLedger()` in parallel. The circuit
124+ * breaker wraps each call individually so a single slow call doesn't block
125+ * both. This method never throws — it returns a typed error object on
126+ * failure so callers can decide how to respond.
86127 */
87128 public async checkConnectivity ( ) : Promise < ConnectivityCheckResult | ConnectivityCheckError > {
88129 const checkedAt = new Date ( ) . toISOString ( ) ;
@@ -93,14 +134,37 @@ export class SorobanService {
93134 ) ;
94135
95136 try {
96- const [ health , ledger ] = await Promise . all ( [
97- this . callWithRetry ( 'getHealth' , ( ) => this . client . getHealth ( ) ) ,
98- this . callWithRetry ( 'getLatestLedger' , ( ) => this . client . getLatestLedger ( ) ) ,
137+ const [ healthResult , ledgerResult ] = await Promise . all ( [
138+ this . callWithRetryAndBreaker ( 'getHealth' , ( ) => this . client . getHealth ( ) ) ,
139+ this . callWithRetryAndBreaker ( 'getLatestLedger' , ( ) => this . client . getLatestLedger ( ) ) ,
99140 ] ) ;
100141
142+ // If either call returned a degraded sentinel the circuit is open.
143+ if (
144+ ( healthResult as DegradedLedgerResult ) . degraded ||
145+ ( ledgerResult as DegradedLedgerResult ) . degraded
146+ ) {
147+ const latencyMs = Date . now ( ) - start ;
148+ logger . warn ( `[Soroban] Connectivity check degraded — circuit is OPEN` ) ;
149+ return {
150+ connected : false ,
151+ network : stellarConfig . network ,
152+ rpcUrl : stellarConfig . rpcUrl ,
153+ checkedAt,
154+ error : 'Soroban RPC circuit breaker is OPEN — node temporarily unreachable' ,
155+ } ;
156+ }
157+
158+ const health = healthResult as StellarRpc . Api . GetHealthResponse ;
159+ const ledger = ledgerResult as StellarRpc . Api . GetLatestLedgerResponse ;
101160 const latencyMs = Date . now ( ) - start ;
102161
103- const result : ConnectivityCheckResult = {
162+ logger . info (
163+ `[Soroban] Connectivity OK — network=${ stellarConfig . network } ` +
164+ `ledger=${ ledger . sequence } latency=${ latencyMs } ms` ,
165+ ) ;
166+
167+ return {
104168 connected : true ,
105169 network : stellarConfig . network ,
106170 networkPassphrase : stellarConfig . networkPassphrase ,
@@ -110,13 +174,6 @@ export class SorobanService {
110174 checkedAt,
111175 latencyMs,
112176 } ;
113-
114- logger . info (
115- `[Soroban] Connectivity OK — network=${ stellarConfig . network } ` +
116- `ledger=${ ledger . sequence } latency=${ latencyMs } ms` ,
117- ) ;
118-
119- return result ;
120177 } catch ( err ) {
121178 const latencyMs = Date . now ( ) - start ;
122179 const message = err instanceof Error ? err . message : 'Unknown error' ;
@@ -126,36 +183,52 @@ export class SorobanService {
126183 `latency=${ latencyMs } ms error="${ message } "` ,
127184 ) ;
128185
129- const errorResult : ConnectivityCheckError = {
186+ return {
130187 connected : false ,
131188 network : stellarConfig . network ,
132189 rpcUrl : stellarConfig . rpcUrl ,
133190 checkedAt,
134191 error : message ,
135192 } ;
136-
137- return errorResult ;
138193 }
139194 }
140195
141196 /**
142197 * Fetch the latest ledger sequence number from the RPC node.
143198 *
144- * @returns The ledger sequence number.
145- * @throws If the RPC call fails.
199+ * @returns The ledger sequence number, or a {@link DegradedLedgerResult}
200+ * when the circuit is OPEN.
201+ * @throws When the RPC call fails and the circuit breaker's fallback itself
202+ * throws (should not happen in practice).
146203 */
147- public async getLatestLedger ( ) : Promise < number > {
148- const ledger = await this . callWithRetry ( 'getLatestLedger' , ( ) => this . client . getLatestLedger ( ) ) ;
149- return ledger . sequence ;
204+ public async getLatestLedger ( ) : Promise < number | DegradedLedgerResult > {
205+ const result = await this . callWithRetryAndBreaker (
206+ 'getLatestLedger' ,
207+ ( ) => this . client . getLatestLedger ( ) ,
208+ ) ;
209+
210+ if ( ( result as DegradedLedgerResult ) . degraded ) {
211+ return result as DegradedLedgerResult ;
212+ }
213+
214+ return ( result as StellarRpc . Api . GetLatestLedgerResponse ) . sequence ;
150215 }
151216
152217 /**
153218 * Fetch network information (passphrase, protocol version) from the RPC node.
154219 *
155- * @returns The raw `getNetwork` response from the SDK.
220+ * @returns The raw `getNetwork` response, or a {@link DegradedLedgerResult}
221+ * when the circuit is OPEN.
156222 */
157- public async getNetworkInfo ( ) : Promise < StellarRpc . Api . GetNetworkResponse > {
158- return this . callWithRetry ( 'getNetwork' , ( ) => this . client . getNetwork ( ) ) ;
223+ public async getNetworkInfo ( ) : Promise <
224+ StellarRpc . Api . GetNetworkResponse | DegradedLedgerResult
225+ > {
226+ const result = await this . callWithRetryAndBreaker (
227+ 'getNetwork' ,
228+ ( ) => this . client . getNetwork ( ) ,
229+ ) ;
230+
231+ return result as StellarRpc . Api . GetNetworkResponse | DegradedLedgerResult ;
159232 }
160233}
161234
0 commit comments