Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
22579c5
feat: add optional exponential backoff for polling interval
mem-5514-tahara Jun 5, 2026
59ce2d0
fix: ensure backoff correctly resets on interval change during outages
mem-5514-tahara Jun 8, 2026
8db4023
fix: prevent shrinking backoff intervals
mem-5514-tahara Jun 8, 2026
5e67340
fix: validate backoffInitialDelay bounds
mem-5514-tahara Jun 8, 2026
4c01cbc
test(exponential-backoff): add constructor validation tests
mem-5514-tahara Jun 8, 2026
dc88a70
fix(exponential-backoff): cap initial backoff delay to backoffMaxDela…
mem-5514-tahara Jun 8, 2026
e4b6d30
test(exponential-backoff): strengthen fixed-interval assertion
mem-5514-tahara Jun 8, 2026
ea96ec3
fix(polling): guard timer reschedule against cancelled listeners
mem-5514-tahara Jun 9, 2026
f820496
style: apply dart format to lib and test
mem-5514-tahara Jun 9, 2026
6c655ae
fix(exponential-backoff): enforce backoff param validation in release…
mem-5514-tahara Jun 9, 2026
7db07d8
fix(polling): guard backoff state mutation against stale timer callbacks
mem-5514-tahara Jun 9, 2026
2aed826
refactor(exponential-backoff): extract _resolveInitialDelay to DRY up…
mem-5514-tahara Jun 9, 2026
24d5de5
fix(polling): prevent stale in-flight checks from emitting to new sub…
mem-5514-tahara Jun 9, 2026
83b016e
Merge remote-tracking branch 'origin/main' into feat/exponential-backoff
mem-5514-tahara Jul 15, 2026
b901a63
fix: address PR review feedback for exponential backoff
mem-5514-tahara Jul 20, 2026
479a19f
fix: reject non-finite backoffMultiplier and expose backoff config
mem-5514-tahara Jul 20, 2026
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
229 changes: 220 additions & 9 deletions lib/src/internet_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ typedef ConnectivityCheckCallback = Future<InternetCheckResult> Function(
/// will prevent memory leaks and free up resources.
///
/// ```dart
/// listener.cancel();
/// await listener.cancel();
/// ```
class InternetConnection {
/// Returns the singleton instance of [InternetConnection].
Expand All @@ -77,13 +77,34 @@ class InternetConnection {
/// used along with any [customCheckOptions] provided.
///
/// - If [useDefaultOptions] is `false`, you must provide a non-empty
/// [customCheckOptions] list.
/// [customCheckOptions] list, otherwise an [ArgumentError] is thrown.
///
/// The [customConnectivityCheck] allows you to provide a custom method for
/// checking endpoint reachability. If provided, it will be used for all
/// connectivity checks instead of the default HTTP HEAD request
/// implementation.
///
/// The [useExponentialBackoff] flag enables exponential backoff for the
/// polling interval used by [onStatusChange]. Defaults to `false`, which
/// preserves the existing fixed-[checkInterval] polling behaviour exactly.
/// When `true`:
/// - [backoffInitialDelay] is the delay applied after the first detected
/// failure. If omitted, it defaults to (and tracks) [checkInterval],
/// including through [setIntervalAndResetTimer] calls that don't specify
/// an explicit [backoffInitialDelay].
/// - [backoffMaxDelay] caps how large the delay may grow. Defaults to 60
/// seconds.
/// - [backoffMultiplier] is the factor applied to the delay on each
/// consecutive failure. Defaults to `2.0`.
/// - The delay resets to [checkInterval] as soon as the connection is
/// restored, and resets to [backoffInitialDelay] whenever
/// [setIntervalAndResetTimer] is called or the last listener cancels.
///
/// An [ArgumentError] is thrown if [useExponentialBackoff] is `true` and
/// [backoffMultiplier] is not a finite number `>= 1.0`, or if
/// [backoffMaxDelay] or the effective [backoffInitialDelay] is not a
/// positive [Duration] no greater than [backoffMaxDelay].
///
/// Make sure to call [dispose] when this instance is no longer needed to free
/// up resources.
InternetConnection.createInstance({
Expand All @@ -93,12 +114,54 @@ class InternetConnection {
this.enableStrictCheck = false,
this.customConnectivityCheck,
this.triggerStream,
this.useExponentialBackoff = false,
Duration? backoffInitialDelay,
Duration backoffMaxDelay = const Duration(seconds: 60),
double backoffMultiplier = 2.0,
}) : _checkInterval = checkInterval ?? _defaultCheckInterval,
assert(
useDefaultOptions || customCheckOptions?.isNotEmpty == true,
'You must provide a list of options if you are not using the '
'default ones.',
) {
_backoffInitialDelayExplicit = backoffInitialDelay != null,
_backoffInitialDelay =
_resolveInitialDelay(backoffInitialDelay, checkInterval),
_backoffMaxDelay = backoffMaxDelay,
_backoffMultiplier = backoffMultiplier,
_currentBackoffDelay =
_resolveInitialDelay(backoffInitialDelay, checkInterval) {
if (!useDefaultOptions && customCheckOptions?.isNotEmpty != true) {
throw ArgumentError(
'You must provide a list of options if you are not using the '
'default ones.',
);
}
if (useExponentialBackoff) {
if (!backoffMultiplier.isFinite || backoffMultiplier < 1.0) {
throw ArgumentError.value(
backoffMultiplier,
'backoffMultiplier',
'Must be a finite number >= 1.0 to prevent shrinking or invalid '
'intervals.',
);
}
if (backoffMaxDelay <= Duration.zero) {
throw ArgumentError.value(
backoffMaxDelay,
'backoffMaxDelay',
'Must be greater than zero.',
);
}
if (_backoffInitialDelay <= Duration.zero) {
throw ArgumentError.value(
_backoffInitialDelay,
'backoffInitialDelay',
'backoffInitialDelay (or checkInterval if implicitly used) must be greater than zero.',
);
}
if (_backoffInitialDelay > _backoffMaxDelay) {
throw ArgumentError(
'backoffInitialDelay (or checkInterval if implicitly used) '
'must be less than or equal to backoffMaxDelay.',
);
}
}
_internetCheckOptions = List.unmodifiable([
if (useDefaultOptions) ..._defaultCheckOptions,
if (customCheckOptions != null) ...customCheckOptions,
Expand All @@ -114,6 +177,12 @@ class InternetConnection {
/// The default check interval duration.
static const _defaultCheckInterval = Duration(seconds: 10);

static Duration _resolveInitialDelay(
Duration? backoffInitialDelay,
Duration? checkInterval,
) =>
backoffInitialDelay ?? checkInterval ?? _defaultCheckInterval;

/// The default list of [Uri]s used for checking internet reachability.
static final _defaultCheckOptions = List<InternetCheckOption>.unmodifiable([
InternetCheckOption(uri: Uri.parse('https://one.one.one.one')),
Expand Down Expand Up @@ -170,12 +239,71 @@ class InternetConnection {
/// whenever it emits an event.
final Stream? triggerStream;

/// Whether exponential backoff is enabled for the polling interval.
///
/// When `true`, the polling interval grows on consecutive failures and resets
/// to [checkInterval] when the connection is restored.
///
/// Defaults to `false`.
final bool useExponentialBackoff;

/// Whether [_backoffInitialDelay] was explicitly provided by the caller.
///
/// When `false`, [_backoffInitialDelay] tracks [_checkInterval] so that
/// a [setIntervalAndResetTimer] call keeps both values in sync.
final bool _backoffInitialDelayExplicit;

/// The initial delay used on the first failure when backoff is enabled.
///
/// Defaults to [checkInterval]. Updated by [setIntervalAndResetTimer] when
/// no explicit value was provided at construction time.
Duration _backoffInitialDelay;

/// The upper bound on the backoff delay.
///
/// Defaults to 60 seconds.
final Duration _backoffMaxDelay;

/// The multiplicative factor applied to the delay on each consecutive failure.
///
/// Defaults to 2.0.
final double _backoffMultiplier;

/// Whether the backoff state was forcefully reset by an interval change.
bool _backoffNeedsReset = false;

/// The live backoff delay, updated each polling cycle when backoff is enabled.
///
/// Resets to [_backoffInitialDelay] on reconnect or subscription cancel.
Duration _currentBackoffDelay;

/// The last known internet connection status result.
InternetStatus? _lastStatus;

/// The handle for the timer used for periodic status checks.
Timer? _timerHandle;

/// Monotonically increasing counter bumped whenever an in-flight
/// [_maybeEmitStatusUpdate] must be invalidated: on [setIntervalAndResetTimer]
/// and on [_handleStatusChangeCancel].
///
/// Each [_maybeEmitStatusUpdate] invocation captures this value on entry.
/// Before mutating shared backoff state or scheduling the next timer it
/// checks that the value has not changed. A mismatch means this invocation
/// is stale — another caller already rescheduled and owns the next cycle.
int _generation = 0;

/// Monotonically increasing counter bumped only on [_handleStatusChangeCancel].
///
/// Captured before the [await internetStatus] gap and compared before
/// emitting. A mismatch means a cancel+resubscribe cycle happened while
/// the check was in-flight: the result belongs to the old subscription context
/// and must not be emitted to the new subscriber.
///
/// Unlike [_generation], this is NOT bumped by [setIntervalAndResetTimer],
/// because interval changes do not affect which subscriber owns the result.
int _cancelGeneration = 0;

/// Checks if the [Uri] specified in [option] is reachable.
///
/// Returns a [Future] that completes with an [InternetCheckResult] indicating
Expand Down Expand Up @@ -208,13 +336,44 @@ class InternetConnection {
/// resets the connection checking timer.
void setIntervalAndResetTimer(Duration duration) {
_checkInterval = duration;
if (useExponentialBackoff) {
// Keep _backoffInitialDelay in sync with the new checkInterval when the
// caller never provided an explicit backoffInitialDelay.
if (!_backoffInitialDelayExplicit) _backoffInitialDelay = duration;
_currentBackoffDelay = _backoffInitialDelay;
_backoffNeedsReset = true;
}
Comment thread
mem-5514-tahara marked this conversation as resolved.
_generation++;
_timerHandle?.cancel();
_timerHandle = Timer(_checkInterval, _maybeEmitStatusUpdate);
}
Comment thread
mem-5514-tahara marked this conversation as resolved.

/// Returns the current duration between connection checks.
Duration get checkInterval => _checkInterval;

/// Returns the delay applied after the first detected failure when
/// [useExponentialBackoff] is enabled.
///
/// If no explicit value was provided at construction time, this tracks
/// [checkInterval], including through [setIntervalAndResetTimer] calls.
Duration get backoffInitialDelay => _backoffInitialDelay;

/// Returns the configured upper bound on the backoff delay when
/// [useExponentialBackoff] is enabled.
Duration get backoffMaxDelay => _backoffMaxDelay;

/// Returns the configured multiplicative factor applied to the delay on
/// each consecutive failure when [useExponentialBackoff] is enabled.
double get backoffMultiplier => _backoffMultiplier;

/// Returns the delay that will be used before the next poll when
/// [useExponentialBackoff] is enabled.
///
/// Useful for surfacing "retrying in Xs" style UI. Resets to
/// [backoffInitialDelay] on reconnect, on [setIntervalAndResetTimer], and
/// when the last listener cancels.
Duration get currentBackoffDelay => _currentBackoffDelay;

/// Checks if there is internet access by verifying connectivity to the
/// specified [Uri]s.
///
Expand Down Expand Up @@ -266,17 +425,65 @@ class InternetConnection {
/// Updates the status and emits it if there are listeners.
Future<void> _maybeEmitStatusUpdate() async {
_timerHandle?.cancel();
final generation = _generation;
final cancelGeneration = _cancelGeneration;

if (!_statusController.hasListener) return;

// Snapshot before possible mutation below — needed to detect first-failure
// vs. ongoing-failure for backoff calculation.
final previousStatus = _lastStatus;

final currentStatus = await internetStatus;

if (_lastStatus != currentStatus && _statusController.hasListener) {
// Only emit if this result still belongs to the current subscription
// context. A cancel+resubscribe while we were awaiting bumps
// _cancelGeneration; the new subscriber owns its own fresh check.
if (_cancelGeneration == cancelGeneration &&
_lastStatus != currentStatus &&
_statusController.hasListener) {
_lastStatus = currentStatus;
_statusController.add(currentStatus);
}

_timerHandle = Timer(_checkInterval, _maybeEmitStatusUpdate);
if (!_statusController.hasListener) return;
// Guard before mutating shared backoff state: a setIntervalAndResetTimer
// call that arrived while we were awaiting internetStatus has already
// bumped _generation and scheduled its own timer. Mutating
// _currentBackoffDelay / _backoffNeedsReset here would silently overwrite
// the reset that setIntervalAndResetTimer applied.
if (_generation != generation) return;

Duration nextDelay;
if (useExponentialBackoff) {
if (currentStatus == InternetStatus.connected) {
_currentBackoffDelay = _backoffInitialDelay;
nextDelay = _checkInterval;
} else if (previousStatus != InternetStatus.disconnected ||
_backoffNeedsReset) {
// First failure: previousStatus is either null (first ever poll) or
// connected — both mean we have not yet been in a backoff streak.
// Also, if _backoffNeedsReset is true, we treat this as a first failure to
// reset the backoff delay, even if the previous status was already disconnected.
_backoffNeedsReset = false;
_currentBackoffDelay = _backoffInitialDelay > _backoffMaxDelay
? _backoffMaxDelay
: _backoffInitialDelay;
nextDelay = _currentBackoffDelay;
} else {
// Ongoing failure: grow the delay.
final ms =
(_currentBackoffDelay.inMilliseconds * _backoffMultiplier).round();
_currentBackoffDelay = Duration(
milliseconds: ms.clamp(0, _backoffMaxDelay.inMilliseconds).toInt(),
);
nextDelay = _currentBackoffDelay;
}
} else {
nextDelay = _checkInterval;
}

_timerHandle = Timer(nextDelay, _maybeEmitStatusUpdate);
}

/// Handles cancellation of status change events.
Expand All @@ -285,9 +492,13 @@ class InternetConnection {
Future<void> _handleStatusChangeCancel() async {
await _triggerSubscription?.cancel();
_triggerSubscription = null;
_cancelGeneration++;
_generation++;
_timerHandle?.cancel();
_timerHandle = null;
_lastStatus = null;
_backoffNeedsReset = false;
if (useExponentialBackoff) _currentBackoffDelay = _backoffInitialDelay;
}

/// The result of the last attempt to check the internet status.
Expand Down
Loading