22using System . Threading ;
33using System . Threading . Tasks ;
44using Resgrid . Relay . Engine . Configuration ;
5+ using Resgrid . Relay . Engine . Telemetry ;
56using Serilog ;
67
78namespace Resgrid . Relay . Engine . Services
@@ -15,6 +16,7 @@ namespace Resgrid.Relay.Engine.Services
1516 public abstract class RelayServiceBase : IRelayService
1617 {
1718 private readonly object _sync = new object ( ) ;
19+ private readonly IRelayModeTelemetry _telemetry ;
1820 private CancellationTokenSource _cts ;
1921 private RelayServiceState _state = RelayServiceState . Stopped ;
2022
@@ -23,8 +25,18 @@ protected RelayServiceBase(string mode, RelayHostOptions options, ILogger logger
2325 Mode = mode ?? throw new ArgumentNullException ( nameof ( mode ) ) ;
2426 Options = options ?? throw new ArgumentNullException ( nameof ( options ) ) ;
2527 Logger = logger ;
28+ // Only the LiveKit voice modes wrap their run in the retry/circuit-breaker
29+ // loop and report to Sentry; everything else uses the cheap no-op telemetry.
30+ _telemetry = IsLiveKitMode ? RelayModeTelemetry . Create ( options . Telemetry , logger ) : NullRelayModeTelemetry . Instance ;
2631 }
2732
33+ /// <summary>
34+ /// Whether this mode is a long-lived LiveKit voice mode (radio / record / dispatch)
35+ /// that should be wrapped in the resilience (retry + circuit-breaker) loop and
36+ /// reported to Sentry. Non-LiveKit modes (e.g. SMTP) leave this false.
37+ /// </summary>
38+ protected virtual bool IsLiveKitMode => false ;
39+
2840 public string Mode { get ; }
2941 public RelayServiceState State => _state ;
3042 public event EventHandler < RelayStateChangedEventArgs > StateChanged ;
@@ -78,21 +90,24 @@ public async Task StartAsync(CancellationToken token)
7890 // Starting during the startup window (it sets Stopping + cancels _cts under _sync).
7991 // Atomic, so a stop that wins the race keeps Stopping/Stopped instead of reverting.
8092 TryTransition ( RelayServiceState . Starting , RelayServiceState . Running ) ;
81- await ExecuteAsync ( _cts . Token ) . ConfigureAwait ( false ) ;
93+ await RunWithResilienceAsync ( _cts . Token ) . ConfigureAwait ( false ) ;
94+ _telemetry . ModeStopped ( Mode ) ;
8295 TransitionTo ( RelayServiceState . Stopped ) ;
8396 }
8497 catch ( OperationCanceledException ) when ( _cts . IsCancellationRequested )
8598 {
8699 // Graceful stop: only when OUR shutdown token was actually requested. A cancellation
87100 // from elsewhere (e.g. a dependency/HttpClient timeout) is a real fault and flows to
88101 // the catch below so Program surfaces a failure exit code.
102+ _telemetry . ModeStopped ( Mode ) ;
89103 TransitionTo ( RelayServiceState . Stopped ) ;
90104 }
91105 catch ( Exception ex )
92106 {
93107 // IRelayService contract: StartAsync returns on fault — surface it via
94108 // State/StateChanged rather than throwing back to the caller.
95109 Logger ? . Error ( ex , "Relay mode '{Mode}' faulted" , Mode ) ;
110+ _telemetry . ModeFaulted ( Mode , ex ) ;
96111 TransitionTo ( RelayServiceState . Faulted , ex . Message ) ;
97112 }
98113 }
@@ -124,7 +139,73 @@ public virtual async ValueTask DisposeAsync()
124139 try { _cts ? . Cancel ( ) ; }
125140 catch ( ObjectDisposedException ) { }
126141 _cts ? . Dispose ( ) ;
127- await Task . CompletedTask . ConfigureAwait ( false ) ;
142+ if ( _telemetry != null )
143+ await _telemetry . DisposeAsync ( ) . ConfigureAwait ( false ) ;
144+ }
145+
146+ /// <summary>
147+ /// Runs <see cref="ExecuteAsync"/>, wrapping the LiveKit voice modes in a
148+ /// retry-with-back-off loop guarded by a simple circuit breaker. A run that faults
149+ /// is restarted after an exponential, jittered delay; the breaker opens (rethrowing
150+ /// so <see cref="StartAsync"/> faults the service) once <see cref="ResilienceOptions.MaxConsecutiveFailures"/>
151+ /// failures occur without a healthy run resetting the counter. Non-LiveKit modes,
152+ /// and any mode with resilience disabled, run <see cref="ExecuteAsync"/> once directly.
153+ /// </summary>
154+ private async Task RunWithResilienceAsync ( CancellationToken token )
155+ {
156+ var r = Options . Resilience ?? new ResilienceOptions ( ) ;
157+ if ( ! IsLiveKitMode || ! r . Enabled )
158+ {
159+ await ExecuteAsync ( token ) . ConfigureAwait ( false ) ;
160+ return ;
161+ }
162+
163+ _telemetry . ModeStarting ( Mode ) ;
164+ var consecutiveFailures = 0 ;
165+ while ( true )
166+ {
167+ token . ThrowIfCancellationRequested ( ) ;
168+ var startTs = System . Diagnostics . Stopwatch . GetTimestamp ( ) ;
169+ try
170+ {
171+ await ExecuteAsync ( token ) . ConfigureAwait ( false ) ;
172+ return ;
173+ }
174+ catch ( OperationCanceledException ) when ( token . IsCancellationRequested )
175+ {
176+ throw ;
177+ }
178+ catch ( Exception ex )
179+ {
180+ var ran = System . Diagnostics . Stopwatch . GetElapsedTime ( startTs ) . TotalSeconds ;
181+ if ( ran >= r . HealthyRunSeconds )
182+ consecutiveFailures = 0 ; // it had been healthy ⇒ fresh transient failure
183+ consecutiveFailures ++ ;
184+ MutableStatus . LiveKit = ConnectionState . Degraded ;
185+ if ( consecutiveFailures >= r . MaxConsecutiveFailures )
186+ {
187+ // Circuit open ⇒ stop retrying and let the outer catch fault the service.
188+ Logger ? . Warning ( ex , "Relay mode '{Mode}' circuit breaker open after {N} consecutive failures; faulting" , Mode , consecutiveFailures ) ;
189+ throw ;
190+ }
191+ var delay = ComputeBackoff ( r , consecutiveFailures ) ;
192+ _telemetry . ModeRetrying ( Mode , ex , consecutiveFailures , delay ) ;
193+ Logger ? . Warning ( ex , "Relay mode '{Mode}' failed (failure {N}/{Max}); reconnecting in {Delay:n1}s" , Mode , consecutiveFailures , r . MaxConsecutiveFailures , delay . TotalSeconds ) ;
194+ await Task . Delay ( delay , token ) . ConfigureAwait ( false ) ;
195+ }
196+ }
197+ }
198+
199+ /// <summary>
200+ /// Exponential back-off (doubling from <see cref="ResilienceOptions.InitialBackoffSeconds"/>,
201+ /// capped at <see cref="ResilienceOptions.MaxBackoffSeconds"/>) with ±20% jitter, floored at
202+ /// 0.5s so retries never busy-spin.
203+ /// </summary>
204+ internal static TimeSpan ComputeBackoff ( ResilienceOptions r , int failures )
205+ {
206+ var baseSecs = Math . Min ( r . InitialBackoffSeconds * Math . Pow ( 2 , failures - 1 ) , r . MaxBackoffSeconds ) ;
207+ var jitter = baseSecs * 0.2 * ( Random . Shared . NextDouble ( ) * 2 - 1 ) ;
208+ return TimeSpan . FromSeconds ( Math . Max ( 0.5 , baseSecs + jitter ) ) ;
128209 }
129210
130211 private void TransitionTo ( RelayServiceState next , string error = null )
0 commit comments