Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions lib/features/relays/relay_health_monitor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,25 @@ class RelayHealthMonitor {
Timer? _timer;
bool _recovering = false;

/// Exponential backoff between recovery attempts while an outage persists:
/// each attempt is a full CLOSE+REQ fan-out, and re-running it on every
/// 6-second tick against relays that stay down is a resubscription storm.
static const Duration initialBackoff = Config.relayDiscoveryTimeout;
static const Duration maxBackoff = Duration(minutes: 5);
Duration _backoff = initialBackoff;
DateTime? _nextAttemptAt;

RelayHealthMonitor(this.ref) {
_timer = Timer.periodic(Config.relayDiscoveryTimeout, (_) => _check());
ref.onDispose(() => _timer?.cancel());
}

/// Runs a single health check synchronously. Exposed for tests so the
/// periodic timer does not need to be awaited.
/// periodic timer does not need to be awaited; [now] injects the clock.
@visibleForTesting
Future<void> checkNow() => _check();
Future<void> checkNow({DateTime? now}) => _check(now: now);

Future<void> _check() async {
Future<void> _check({DateTime? now}) async {
if (_recovering) return;

final nostrService = ref.read(nostrServiceProvider);
Expand All @@ -46,7 +54,18 @@ class RelayHealthMonitor {
final operatingRelays = ref.read(settingsProvider).relays.toSet();
final hasLiveOperatingRelay =
nostrService.connectedRelays.any(operatingRelays.contains);
if (hasLiveOperatingRelay) return;
if (hasLiveOperatingRelay) {
// Healthy: arm the next outage for an immediate first attempt.
_backoff = initialBackoff;
_nextAttemptAt = null;
return;
}

final tick = now ?? DateTime.now();
if (_nextAttemptAt != null && tick.isBefore(_nextAttemptAt!)) return;
Comment thread
grunch marked this conversation as resolved.
Outdated
_nextAttemptAt = tick.add(_backoff);
final doubled = _backoff * 2;
_backoff = doubled > maxBackoff ? maxBackoff : doubled;

_recovering = true;
try {
Expand Down
5 changes: 4 additions & 1 deletion lib/services/nostr_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,10 @@ class NostrService {
await _nostr.services.relays.init(
relaysUrl: effectiveSettings.relays,
connectionTimeout: Config.relayConnectionTimeout,
shouldReconnectToRelayOnNotice: true,
// NOTICE frames are informational (rate limits, policy hints);
// reconnecting on them cycled the socket without re-sending REQs and
// handed the recovery cost to the health monitor.
shouldReconnectToRelayOnNotice: false,
retryOnClose: true,
retryOnError: true,
onRelayListening: (relayUrl, receivedData, channel) {
Expand Down
61 changes: 61 additions & 0 deletions test/features/relays/relay_health_monitor_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,67 @@ void main() {
verify(subscriptionManager.subscribeToMostroRelayList('test')).called(1);
});

test('holds an exponential backoff while the outage persists', () async {
when(nostrService.connectedRelays).thenReturn(<String>{});
final monitor = buildContainer(relays: ['wss://discovered.example.com'])
.read(relayHealthMonitorProvider);
final t0 = DateTime.now();

await monitor.checkNow(now: t0);
verify(subscriptionManager.subscribeAll()).called(1);
clearInteractions(subscriptionManager);
clearInteractions(nostrService);
when(nostrService.isInitialized).thenReturn(true);
when(nostrService.connectedRelays).thenReturn(<String>{});
when(nostrService.ensureBootstrapConnectivity())
.thenAnswer((_) async {});

// Next periodic tick, still down: within the backoff window, no re-run.
await monitor.checkNow(now: t0.add(const Duration(seconds: 1)));
verifyNever(subscriptionManager.subscribeAll());

// After the first backoff interval elapses, it retries once...
await monitor.checkNow(
now: t0.add(RelayHealthMonitor.initialBackoff +
const Duration(seconds: 1)));
verify(subscriptionManager.subscribeAll()).called(1);
clearInteractions(subscriptionManager);
clearInteractions(nostrService);
when(nostrService.isInitialized).thenReturn(true);
when(nostrService.connectedRelays).thenReturn(<String>{});
when(nostrService.ensureBootstrapConnectivity())
.thenAnswer((_) async {});

// ...and the second window is wider than the first.
await monitor.checkNow(
now: t0.add(RelayHealthMonitor.initialBackoff * 2 +
const Duration(seconds: 2)));
verifyNever(subscriptionManager.subscribeAll());
});

test('a healthy check resets the backoff', () async {
when(nostrService.connectedRelays).thenReturn(<String>{});
final monitor = buildContainer(relays: ['wss://discovered.example.com'])
.read(relayHealthMonitorProvider);
final t0 = DateTime.now();
await monitor.checkNow(now: t0);
clearInteractions(subscriptionManager);
clearInteractions(nostrService);
when(nostrService.isInitialized).thenReturn(true);
when(nostrService.ensureBootstrapConnectivity())
.thenAnswer((_) async {});

// Relay comes back: healthy tick resets the backoff...
when(nostrService.connectedRelays)
.thenReturn({'wss://discovered.example.com'});
await monitor.checkNow(now: t0.add(const Duration(seconds: 1)));

// ...so a new outage right after recovers immediately.
when(nostrService.connectedRelays).thenReturn(<String>{});
await monitor.checkNow(now: t0.add(const Duration(seconds: 2)));
verify(subscriptionManager.subscribeAll()).called(1);
});

test('stays idle while an operating relay is alive', () async {
when(nostrService.connectedRelays)
.thenReturn({'wss://discovered.example.com'});
Expand Down
Loading