1- import { Injectable , Logger } from '@nestjs/common' ;
1+ import {
2+ Injectable ,
3+ Logger ,
4+ ServiceUnavailableException ,
5+ } from '@nestjs/common' ;
26import { ConfigService } from '@nestjs/config' ;
37import { Server } from 'stellar-sdk' ;
48import { Asset , AssetType , BalanceUpdate } from './domain/balance.model' ;
59import { RequestContextService } from '../common/request-context/request-context.service' ;
10+ import {
11+ CircuitBreaker ,
12+ CircuitOpenError ,
13+ } from '../common/utils/circuit-breaker' ;
614
715export interface HorizonBalance {
816 asset_type : string ;
@@ -19,6 +27,7 @@ export class StellarHorizonService {
1927 private readonly retryBackoffMs : number ;
2028 private readonly retryJitterMs : number ;
2129 private readonly server : Server ;
30+ private readonly circuitBreaker : CircuitBreaker ;
2231
2332 constructor (
2433 private readonly configService : ConfigService ,
@@ -44,29 +53,62 @@ export class StellarHorizonService {
4453
4554 this . logger . log ( `Initialized Stellar Horizon client: ${ this . horizonUrl } ` ) ;
4655 this . server = new Server ( horizonUrl , { allowHttp : false } ) ;
56+ this . circuitBreaker = new CircuitBreaker ( 'stellar-horizon' , {
57+ failureThreshold : this . configService . get < number > (
58+ 'HORIZON_CIRCUIT_FAILURE_THRESHOLD' ,
59+ 5 ,
60+ ) ,
61+ resetTimeoutMs : this . configService . get < number > (
62+ 'HORIZON_CIRCUIT_RESET_TIMEOUT_MS' ,
63+ 30000 ,
64+ ) ,
65+ } ) ;
4766 this . logger . log ( `Initialized Stellar Horizon client: ${ horizonUrl } ` ) ;
4867 }
4968
5069 /**
51- * Helper to execute server actions with retry & backoff
70+ * Helper to execute server actions with retry & backoff, guarded by a
71+ * circuit breaker so a degraded Horizon backend fails fast instead of
72+ * queuing up retries (and their backoff delays) on every caller.
5273 */
5374 private async executeWithRetry < T > (
5475 operation : ( ) => Promise < T > ,
5576 opName : string ,
5677 ) : Promise < T > {
78+ const requestId = this . requestContext . getRequestId ( ) ;
79+ const logPrefix = requestId ? `[${ requestId } ] ` : '' ;
80+
81+ try {
82+ this . circuitBreaker . assertClosed ( ) ;
83+ } catch ( error ) {
84+ if ( error instanceof CircuitOpenError ) {
85+ this . logger . warn (
86+ `${ logPrefix } Horizon API ${ opName } short-circuited: ${ error . message } ` ,
87+ ) ;
88+ throw new ServiceUnavailableException (
89+ 'Stellar Horizon is currently unavailable. Please try again shortly.' ,
90+ ) ;
91+ }
92+ throw error ;
93+ }
94+
5795 const maxRetries = this . configService . get < number > ( 'HORIZON_MAX_RETRIES' , 3 ) ;
5896 let attempt = 0 ;
5997 while ( true ) {
6098 try {
61- return await operation ( ) ;
99+ const result = await operation ( ) ;
100+ this . circuitBreaker . recordSuccess ( ) ;
101+ return result ;
62102 } catch ( error ) {
63103 attempt ++ ;
64104 if ( attempt > maxRetries ) {
105+ this . circuitBreaker . recordFailure ( ) ;
65106 throw error ;
66107 }
67- const delay = Math . min ( 1000 * Math . pow ( 2 , attempt ) + Math . random ( ) * 1000 , 15000 ) ;
68- const requestId = this . requestContext . getRequestId ( ) ;
69- const logPrefix = requestId ? `[${ requestId } ] ` : '' ;
108+ const delay = Math . min (
109+ 1000 * Math . pow ( 2 , attempt ) + Math . random ( ) * 1000 ,
110+ 15000 ,
111+ ) ;
70112 this . logger . warn (
71113 `${ logPrefix } Horizon API ${ opName } failed (attempt ${ attempt } /${ maxRetries } ). Retrying in ${ Math . round ( delay ) } ms. Error: ${ error . message } ` ,
72114 ) ;
0 commit comments