Skip to content

feat: Add optional exponential backoff for polling interval (#99)#139

Open
mem-5514-tahara wants to merge 7 commits into
OutdatedGuy:mainfrom
mem-5514-tahara:feat/exponential-backoff
Open

feat: Add optional exponential backoff for polling interval (#99)#139
mem-5514-tahara wants to merge 7 commits into
OutdatedGuy:mainfrom
mem-5514-tahara:feat/exponential-backoff

Conversation

@mem-5514-tahara

Copy link
Copy Markdown

Hi @OutdatedGuy! Thanks for maintaining this awesome package.

Closes #99

Problem

The onStatusChange polling loop retries at a fixed checkInterval (default 10 s) regardless of connection state. During extended outages this results in unnecessary battery drain, wasted CPU cycles, and pointless network traffic on mobile devices.

Analysis

Inspecting _maybeEmitStatusUpdate() in lib/src/internet_connection.dart confirmed the timer is always rescheduled with the same _checkInterval:

_maybeEmitStatusUpdate()
  └─ await internetStatus
  └─ if status changed → emit
  └─ Timer(_checkInterval, _maybeEmitStatusUpdate)  // always the same value

Key design constraints identified before implementation:

  • _lastStatus is mutated inside _maybeEmitStatusUpdate, so distinguishing "first failure" from "ongoing failure" requires snapshotting its value before the update.
  • All new parameters must default to "backoff disabled" (useExponentialBackoff = false) to leave the singleton InternetConnection() and existing callers completely unaffected.
  • Backoff state must reset on reconnect, on setIntervalAndResetTimer(), and on last-listener cancel.
  • When backoffInitialDelay is not explicitly provided, it should track _checkInterval so a setIntervalAndResetTimer() call does not leave a stale initial delay from construction time.

Changes

Files modified:

  • lib/src/internet_connection.dart
  • test/internet_connection_test.dart

New createInstance parameters

Parameter Type Default Description
useExponentialBackoff bool false Opt-in flag; false preserves existing behaviour exactly
backoffInitialDelay Duration? same as checkInterval Delay applied after the first failure
backoffMaxDelay Duration Duration(seconds: 60) Upper bound on the retry interval
backoffMultiplier double 2.0 Factor applied to the delay on each consecutive failure

Backoff logic

first failure    → nextDelay = backoffInitialDelay
ongoing failure  → nextDelay = min(currentDelay × multiplier, maxDelay)
reconnect        → nextDelay = checkInterval  (reset)

previousStatus is snapshotted before the _lastStatus mutation so that the branch correctly identifies the first failure even when previousStatus is null (very first poll):

final previousStatus = _lastStatus;  // snapshot before mutation

// ... _lastStatus update ...

if (useExponentialBackoff) {
  if (currentStatus == InternetStatus.connected) {
    _currentBackoffDelay = _backoffInitialDelay;
    nextDelay = _checkInterval;
  } else if (previousStatus != InternetStatus.disconnected) {
    // First failure: previousStatus is null (initial poll) or connected.
    _currentBackoffDelay = _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;
  }
}

.toInt() is appended to .clamp() as a defensive cast: ms is int in current Dart, but num.clamp can return num depending on the Dart version or analyzer strictness, which would cause a type error on the milliseconds: field.

Backoff state reset conditions

Trigger Behaviour
currentStatus == connected Reset _currentBackoffDelay to _backoffInitialDelay
setIntervalAndResetTimer(duration) If backoffInitialDelay was not explicitly provided, also update _backoffInitialDelay to duration before resetting _currentBackoffDelay; otherwise keep the caller-specified value
Last listener cancelled (_handleStatusChangeCancel) Reset _currentBackoffDelay to _backoffInitialDelay so the next subscription starts clean

The backoffInitialDelay tracking is implemented with a final bool _backoffInitialDelayExplicit flag set in the initializer list. When false, _backoffInitialDelay is updated in setIntervalAndResetTimer to stay in sync with _checkInterval, preventing a stale initial delay from lingering after the interval is changed dynamically.

Usage example

final connection = InternetConnection.createInstance(
  checkInterval: const Duration(seconds: 10),
  useExponentialBackoff: true,
  backoffInitialDelay: const Duration(seconds: 10),
  backoffMaxDelay: const Duration(minutes: 1),
  backoffMultiplier: 2.0,
);
// Retry delays on failure: 10s → 20s → 40s → 60s → 60s → ...
// Resets to 10s immediately on reconnect.

Tests

Added group('exponentialBackoff', ...) to test/internet_connection_test.dart. Tests use customConnectivityCheck with DateTime.now() call logging to avoid real network calls and enable deterministic timing assertions.

Test case What it verifies
disabled by default Fixed interval is preserved with backoff off (regression guard)
first failure sets initialDelay backoffInitialDelay is used on the first disconnect
delay grows on consecutive failures Gaps between calls are monotonically non-decreasing
delay is capped at maxDelay No gap exceeds backoffMaxDelay
delay resets to checkInterval on reconnect Interval reverts to checkInterval after reconnect
setIntervalAndResetTimer resets backoff Backoff sequence restarts from backoffInitialDelay after interval change
re-subscription resets backoff Cancel + re-listen starts the backoff sequence from the beginning
first poll returning disconnected previousStatus == null is treated as first failure, not ongoing
dart test              →  30/30 passed
dart analyze lib/ test/ →  No issues found

When `useExponentialBackoff` is enabled, the retry interval grows
exponentially on consecutive failures and resets to `checkInterval`
on reconnect, reducing unnecessary battery drain and network traffic
during prolonged outages.

New `createInstance` parameters:
- `useExponentialBackoff` (bool, default false)
- `backoffInitialDelay` (Duration?, default: checkInterval)
- `backoffMaxDelay` (Duration, default: 60s)
- `backoffMultiplier` (double, default: 2.0)

Closes OutdatedGuy#99

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in exponential backoff strategy to the InternetConnection.createInstance polling loop to reduce polling frequency during extended disconnects (while preserving existing behavior by default), and extends the test suite to cover the new timing behavior.

Changes:

  • Introduces useExponentialBackoff and related backoff configuration parameters on InternetConnection.createInstance.
  • Implements adaptive timer rescheduling in _maybeEmitStatusUpdate() based on connection status and backoff state.
  • Adds a new exponentialBackoff test group to validate backoff growth, capping, and reset behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
lib/src/internet_connection.dart Adds backoff configuration/state and uses it to compute the next polling delay.
test/internet_connection_test.dart Adds tests intended to validate backoff timing and reset behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/src/internet_connection.dart
Comment thread lib/src/internet_connection.dart
Comment thread lib/src/internet_connection.dart
Comment thread lib/src/internet_connection.dart Outdated
Comment thread test/internet_connection_test.dart Outdated
As pointed out by Copilot, calling `setIntervalAndResetTimer` while already disconnected would cause the next poll to be treated as an ongoing failure (immediately multiplying the delay) because `previousStatus` remained disconnected.

This commit introduces a `_backoffNeedsReset` flag to force the next poll to be treated as a "first failure" without mutating `_lastStatus` (which would have caused duplicate disconnected events). Also includes a new test to verify implicit initial delay synchronization.
- Update assertion for `backoffMultiplier` to require a value >= 1.0.
  Values between 0 and 1 would cause the polling delay to decay toward 0ms,
  leading to a tight polling loop during an outage.
- Add assertions to ensure `backoffInitialDelay` (or its fallback `checkInterval`)
  is greater than zero to prevent immediate/negative timer loops.
- Add assertion to ensure `backoffInitialDelay` is less than or equal to
  `backoffMaxDelay`, keeping the backoff sequence logically sound and
  preventing the delay from shrinking on the second poll.
Repository owner locked and limited conversation to collaborators Jun 8, 2026
Repository owner unlocked this conversation Jun 8, 2026
Replace always-true expect(sub, isNotNull) with real behavioral checks:
verify call count (~4-5 in 550ms) and that consecutive gaps stay under
200ms, catching regressions where backoff activates unintentionally.
@mem-5514-tahara

Copy link
Copy Markdown
Author

Hi @OutdatedGuy! I’ve addressed the issues GitHub Copilot flagged, so could you please take another look at my proposal?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Exponential Backoff for Retries

2 participants