diff --git a/lib/src/spinify_impl.dart b/lib/src/spinify_impl.dart index 4ce6f2f..c658dbe 100644 --- a/lib/src/spinify_impl.dart +++ b/lib/src/spinify_impl.dart @@ -22,6 +22,7 @@ import 'transport_ws_pb_stub.dart' if (dart.library.html) 'transport_ws_pb_js.dart' // ignore: uri_does_not_exist if (dart.library.io) 'transport_ws_pb_vm.dart'; +import 'util/backoff.dart'; /// Base class for Spinify client. abstract base class SpinifyBase implements ISpinify { @@ -130,11 +131,6 @@ base mixin SpinifyStateMixin on SpinifyBase { ); } - @override - Future _onConnected() async { - await super._onConnected(); - } - @override Future _onDisconnected() async { await super._onDisconnected(); @@ -300,6 +296,8 @@ base mixin SpinifyConnectionMixin /// Used for reconnecting after connection lost. /// If null, then client is not connected or interractively disconnected. String? _reconnectUrl; + Timer? _reconnectTimer; + int? _reconnectAttempt; Completer? _readyCompleter; @protected @@ -385,6 +383,7 @@ base mixin SpinifyConnectionMixin 'stackTrace': stackTrace, }, ); + _setUpReconnectTimer(); rethrow; } } @@ -481,6 +480,74 @@ base mixin SpinifyConnectionMixin } } + @override + Future _onConnected() async { + await super._onConnected(); + _tearDownReconnectTimer(); + } + + void _setUpReconnectTimer() { + _reconnectTimer?.cancel(); + final lastUrl = _reconnectUrl; + if (lastUrl == null) return; + final attempt = _reconnectAttempt ?? 0; + final delay = Backoff.nextDelay( + attempt, + config.connectionRetryInterval.min.inMilliseconds, + config.connectionRetryInterval.max.inMilliseconds, + ); + _reconnectAttempt = attempt + 1; + if (delay <= Duration.zero) { + if (!state.isDisconnected) return; + config.logger?.call( + const SpinifyLogLevel.config(), + 'reconnect_attempt', + 'Reconnecting to $lastUrl immediately.', + { + 'url': lastUrl, + 'delay': delay, + }, + ); + Future.sync(() => connect(lastUrl)).ignore(); + return; + } + config.logger?.call( + const SpinifyLogLevel.debug(), + 'reconnect_delayed', + 'Setting up reconnect timer to $lastUrl ' + 'after ${delay.inMilliseconds} ms.', + { + 'url': lastUrl, + 'delay': delay, + }, + ); + /* _nextReconnectionAttempt = DateTime.now().add(delay); */ + _reconnectTimer = Timer( + delay, + () { + //_nextReconnectionAttempt = null; + if (!state.isDisconnected) return; + config.logger?.call( + const SpinifyLogLevel.config(), + 'reconnect_attempt', + 'Reconnecting to $lastUrl after ${delay.inMilliseconds} ms.', + { + 'url': lastUrl, + 'delay': delay, + }, + ); + Future.sync(() => connect(lastUrl)).ignore(); + }, + ); + //connect(_reconnectUrl!); + } + + void _tearDownReconnectTimer() { + _reconnectAttempt = null; + _reconnectTimer?.cancel(); + _reconnectTimer = null; + } + @override Future ready() async { if (state.isConnected) return; @@ -490,8 +557,7 @@ base mixin SpinifyConnectionMixin @override Future disconnect() async { _reconnectUrl = null; - // TODO(plugfox): tear down reconnect timer - // tearDownReconnectTimer(); + _tearDownReconnectTimer(); if (state.isDisconnected) return Future.value(); await _transport?.disconnect(1000, 'Client disconnecting'); await _onDisconnected(); @@ -501,7 +567,8 @@ base mixin SpinifyConnectionMixin Future _onDisconnected() async { _refreshTimer?.cancel(); _transport = null; - // TODO(plugfox): setup reconnect if reconnectUrl is not null + // Reconnect if that callback called not from disconnect method. + if (_reconnectUrl != null) _setUpReconnectTimer(); await super._onDisconnected(); } diff --git a/lib/src/util/backoff.dart b/lib/src/util/backoff.dart new file mode 100644 index 0000000..108d6a9 --- /dev/null +++ b/lib/src/util/backoff.dart @@ -0,0 +1,21 @@ +// ignore_for_file: avoid_classes_with_only_static_members + +import 'dart:math' as math; + +import 'package:meta/meta.dart'; + +/// Backoff strategy for reconnection. +@internal +abstract final class Backoff { + /// Randomizer for full jitter technique. + static final math.Random _rnd = math.Random(); + + /// Full jitter technique. + /// https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ + static Duration nextDelay(int step, int minDelay, int maxDelay) { + if (minDelay >= maxDelay) return Duration(milliseconds: maxDelay); + final val = math.min(maxDelay, minDelay * math.pow(2, step.clamp(0, 31))); + final interval = _rnd.nextInt(val.toInt()); + return Duration(milliseconds: math.min(maxDelay, minDelay + interval)); + } +}