using System; namespace TwitchArchive.Core.Recovery { public enum RecoveryState { Monitoring, Recording, FastReconnect, SlowReconnect, NetworkFault } public enum RecoveryAction { None, StartRecording, StartProcessing, SleepOnly } public sealed class RecoveryDecision { public RecoveryAction Action { get; init; } public TimeSpan Sleep { get; init; } public static RecoveryDecision SleepFor(TimeSpan t) => new RecoveryDecision { Action = RecoveryAction.SleepOnly, Sleep = t }; public static RecoveryDecision StartRecordingNow() => new RecoveryDecision { Action = RecoveryAction.StartRecording, Sleep = TimeSpan.Zero }; public static RecoveryDecision StartProcessingAndSleep(TimeSpan sleep) => new RecoveryDecision { Action = RecoveryAction.StartProcessing, Sleep = sleep }; public static RecoveryDecision None() => new RecoveryDecision { Action = RecoveryAction.None, Sleep = TimeSpan.Zero }; } /// /// Pure, testable recovery policy state machine. /// It decides whether to start recording immediately, wait a short period (fast reconnect), /// wait longer (slow reconnect), or enter a network fault backoff. /// public class RecoveryPolicy { private RecoveryState _state = RecoveryState.Monitoring; private readonly TimeSpan _refreshInterval; private DateTime? _fastReconnectStartUtc; private int _networkFaultAttempts; // constants matching requested behavior private static readonly TimeSpan FastReconnectInterval = TimeSpan.FromSeconds(10); private static readonly TimeSpan FastReconnectWindow = TimeSpan.FromMinutes(2); private static readonly TimeSpan SlowReconnectInterval = TimeSpan.FromSeconds(60); private static readonly TimeSpan NetworkFaultBase = TimeSpan.FromSeconds(30); private static readonly TimeSpan NetworkFaultMax = TimeSpan.FromMinutes(10); public RecoveryPolicy(TimeSpan? refreshInterval = null) { _refreshInterval = refreshInterval ?? TimeSpan.FromSeconds(60); } /// /// Evaluate the policy given current time, whether the streamer is live (null = unknown), /// and whether there was a network error reaching Twitch APIs. /// Returns a RecoveryDecision describing the next action and how long to wait if sleeping. /// public RecoveryDecision Tick(DateTime nowUtc, bool? isLive, bool networkError) { // Network error handling: enter NetworkFault state with exponential backoff if (networkError) { _networkFaultAttempts++; _state = RecoveryState.NetworkFault; var backoff = NetworkFaultBase * Math.Pow(2, Math.Max(0, _networkFaultAttempts - 1)); if (backoff > NetworkFaultMax) backoff = NetworkFaultMax; return RecoveryDecision.SleepFor(backoff); } // If we recover from network fault, reset attempts if (_state == RecoveryState.NetworkFault && !networkError) { _networkFaultAttempts = 0; _state = RecoveryState.Monitoring; } // If live -> start recording immediately if (isLive == true) { _state = RecoveryState.Recording; _fastReconnectStartUtc = null; return RecoveryDecision.StartRecordingNow(); } // If unknown live status, treat conservatively: sleep a short amount (refresh) if (isLive == null) { return RecoveryDecision.SleepFor(_refreshInterval); } // isLive == false here switch (_state) { case RecoveryState.Recording: // Just transitioned from recording to not-live: start fast reconnect window _state = RecoveryState.FastReconnect; _fastReconnectStartUtc = nowUtc; return RecoveryDecision.SleepFor(FastReconnectInterval); case RecoveryState.FastReconnect: if (_fastReconnectStartUtc.HasValue && (nowUtc - _fastReconnectStartUtc.Value) < FastReconnectWindow) { // stay in fast reconnect, poll frequently return RecoveryDecision.SleepFor(FastReconnectInterval); } else { // fast reconnect window expired -> move to slow reconnect and trigger processing _state = RecoveryState.SlowReconnect; _fastReconnectStartUtc = null; return RecoveryDecision.StartProcessingAndSleep(SlowReconnectInterval); } case RecoveryState.SlowReconnect: // In slow reconnect, poll less frequently return RecoveryDecision.SleepFor(SlowReconnectInterval); case RecoveryState.Monitoring: default: // Normal monitoring cadence return RecoveryDecision.SleepFor(_refreshInterval); } } public RecoveryState CurrentState => _state; } }