@@ -3,6 +3,7 @@ import { supabase } from '../config/database';
33import { reorgHandler } from './reorg-handler' ;
44import { generateCycleId } from '../utils/cycle-id' ;
55import { renewalCooldownService } from './renewal-cooldown-service' ;
6+ import { calculateBackoffDelay , NonRetryableError } from '../utils/retry' ;
67
78interface ContractEvent {
89 type : string ;
@@ -31,6 +32,15 @@ export interface EventListenerHealth {
3132
3233const ALERT_THRESHOLD = 10 ;
3334const MAX_BACKOFF_MS = 300_000 ; // 5 minutes
35+ export type EventListenerStatus = 'running' | 'stopped' | 'disabled' | 'retrying' | 'failed' ;
36+ status: EventListenerStatus ;
37+ reason?: string ;
38+ lastProcessedLedger: number | null ;
39+ retryCount?: number ;
40+ nextRetryAt?: string | null ;
41+ const MAX_RETRY_ATTEMPTS = 10 ;
42+ const RETRY_INITIAL_DELAY_MS = 5000 ;
43+ const RETRY_MAX_DELAY_MS = 5 * 60 * 1000 ; // 5 minutes
3444
3545export class EventListener {
3646 private contractId : string ;
@@ -49,20 +59,49 @@ export class EventListener {
4959 private consecutiveErrors : number = 0 ;
5060 private lastSuccessfulPoll : Date | null = null ;
5161
62+ // Health tracking
63+ private _status : EventListenerStatus = 'stopped' ;
64+ private _disabledReason ?: string ;
65+ private _retryCount : number = 0 ;
66+ private _nextRetryAt : Date | null = null ;
67+
5268 constructor ( ) {
5369 this . contractId = process . env . SOROBAN_CONTRACT_ADDRESS || '' ;
5470 this . rpcUrl =
5571 process . env . STELLAR_NETWORK_URL || 'https://soroban-testnet.stellar.org' ;
5672
5773 if ( ! this . contractId ) {
58- throw new Error ( 'SOROBAN_CONTRACT_ADDRESS not configured' ) ;
74+ // Don't throw — mark as disabled so the process can still start
75+ this . _status = 'disabled' ;
76+ this . _disabledReason = 'SOROBAN_CONTRACT_ADDRESS not configured' ;
77+ logger . warn ( 'EventListener disabled: SOROBAN_CONTRACT_ADDRESS not configured' ) ;
5978 }
6079 }
6180
81+ getHealth ( ) : EventListenerHealth {
82+ return {
83+ status : this . _status ,
84+ reason : this . _disabledReason ,
85+ lastProcessedLedger : this . lastProcessedLedger || null ,
86+ retryCount : this . _retryCount ,
87+ nextRetryAt : this . _nextRetryAt ?. toISOString ( ) ?? null ,
88+ } ;
89+ }
90+
6291 async start ( ) {
92+ if ( this . _status === 'disabled' ) {
93+ logger . warn ( 'EventListener.start() called but listener is disabled' , {
94+ reason : this . _disabledReason ,
95+ } ) ;
96+ return ;
97+ }
98+
6399 if ( this . isRunning ) return ;
64100
65101 this . isRunning = true ;
102+ this . _status = 'running' ;
103+ this . _retryCount = 0 ;
104+ this . _nextRetryAt = null ;
66105 this . lastProcessedLedger = await this . getLastProcessedLedger ( ) ;
67106 logger . info ( 'Event listener started' , { lastLedger : this . lastProcessedLedger } ) ;
68107
@@ -71,6 +110,9 @@ export class EventListener {
71110
72111 stop ( ) {
73112 this . isRunning = false ;
113+ if ( this . _status !== 'disabled' ) {
114+ this . _status = 'stopped' ;
115+ }
74116 logger . info ( 'Event listener stopped' ) ;
75117 }
76118
@@ -141,6 +183,15 @@ export class EventListener {
141183 } finally {
142184 // Always release the mutex, even if fetchAndProcessEvents throws
143185 this . isProcessing = false ;
186+ // Reset retry count on success
187+ if ( this . _retryCount > 0 ) {
188+ logger . info ( 'EventListener recovered after retries' , { retryCount : this . _retryCount } ) ;
189+ this . _retryCount = 0 ;
190+ this . _nextRetryAt = null ;
191+ this . _status = 'running' ;
192+ logger . error ( 'Event polling error:' , error ) ;
193+ await this . handlePollError ( error ) ;
194+ if ( ! this . isRunning ) break ;
144195 }
145196
146197 await this . sleep ( backoffMs ) ;
@@ -150,6 +201,40 @@ export class EventListener {
150201private async fetchAndProcessEvents ( ) {
151202 logger . info ( 'Polling for events...' ) ;
152203 const currentLedger = await this . getCurrentLedger ( ) ;
204+ private async handlePollError ( error : unknown) {
205+ this . _retryCount ++ ;
206+
207+ if ( this . _retryCount >= MAX_RETRY_ATTEMPTS ) {
208+ this . _status = 'failed' ;
209+ this . _disabledReason = `Exceeded max retry attempts (${ MAX_RETRY_ATTEMPTS } ). Last error: ${ error instanceof Error ? error . message : String ( error ) } ` ;
210+ logger . error ( 'EventListener permanently failed after max retries' , {
211+ retryCount : this . _retryCount ,
212+ error : this . _disabledReason ,
213+ } ) ;
214+ this . isRunning = false ;
215+ return ;
216+ }
217+
218+ const delay = calculateBackoffDelay ( this . _retryCount , {
219+ initialDelay : RETRY_INITIAL_DELAY_MS ,
220+ maxDelay : RETRY_MAX_DELAY_MS ,
221+ multiplier : 2 ,
222+ jitter : true ,
223+ } ) ;
224+
225+ this . _status = 'retrying' ;
226+ this . _nextRetryAt = new Date ( Date . now ( ) + delay ) ;
227+ logger . warn ( 'EventListener will retry' , {
228+ attempt : this . _retryCount ,
229+ delayMs : delay ,
230+ nextRetryAt : this . _nextRetryAt . toISOString ( ) ,
231+ } ) ;
232+
233+ await this . sleep ( delay ) ;
234+ }
235+
236+ private async fetchAndProcessEvents ( ) {
237+ const currentLedger = await this . getCurrentLedger ( ) ;
153238
154239 // Check for reorg
155240 if ( currentLedger < this . lastProcessedLedger ) {
0 commit comments