diff --git a/packages/komodo_defi_framework/pubspec_overrides.yaml b/packages/komodo_defi_framework/pubspec_overrides.yaml index def0e2c0..8be21ebc 100644 --- a/packages/komodo_defi_framework/pubspec_overrides.yaml +++ b/packages/komodo_defi_framework/pubspec_overrides.yaml @@ -1,5 +1,7 @@ -# melos_managed_dependency_overrides: komodo_defi_rpc_methods,komodo_defi_types,komodo_wallet_build_transformer +# melos_managed_dependency_overrides: komodo_defi_rpc_methods,komodo_defi_types,komodo_wallet_build_transformer,komodo_coins dependency_overrides: + komodo_coins: + path: ../komodo_coins komodo_defi_rpc_methods: path: ../komodo_defi_rpc_methods komodo_defi_types: diff --git a/packages/komodo_defi_local_auth/pubspec_overrides.yaml b/packages/komodo_defi_local_auth/pubspec_overrides.yaml index cfb3b857..f9037265 100644 --- a/packages/komodo_defi_local_auth/pubspec_overrides.yaml +++ b/packages/komodo_defi_local_auth/pubspec_overrides.yaml @@ -1,5 +1,7 @@ -# melos_managed_dependency_overrides: komodo_defi_framework,komodo_defi_rpc_methods,komodo_defi_types,komodo_wallet_build_transformer +# melos_managed_dependency_overrides: komodo_defi_framework,komodo_defi_rpc_methods,komodo_defi_types,komodo_wallet_build_transformer,komodo_coins dependency_overrides: + komodo_coins: + path: ../komodo_coins komodo_defi_framework: path: ../komodo_defi_framework komodo_defi_rpc_methods: diff --git a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart index 9041f5f0..3e4f065a 100644 --- a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart @@ -1,5 +1,6 @@ import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; import 'package:komodo_defi_sdk/src/_internal_exports.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; /// Manager responsible for handling pubkey operations across different assets @@ -12,33 +13,20 @@ class PubkeyManager { /// Get pubkeys for a given asset, handling HD/non-HD differences internally Future getPubkeys(Asset asset) async { - final finalStatus = await _activationManager.activateAsset(asset).last; - if (finalStatus.isComplete && !finalStatus.isSuccess) { - throw StateError( - 'Failed to activate asset ${asset.id.name}. ${finalStatus.toJson()}', - ); - } - + await retry(() => _activationManager.activateAsset(asset).last); final strategy = await _resolvePubkeyStrategy(asset); return strategy.getPubkeys(asset.id, _client); } /// Create a new pubkey for an asset if supported Future createNewPubkey(Asset asset) async { - final activationStatus = await _activationManager.activateAsset(asset).last; - if (activationStatus.isComplete && !activationStatus.isSuccess) { - throw StateError( - 'Failed to activate asset ${asset.id.name}. ${activationStatus.toJson()}', - ); - } - + await retry(() => _activationManager.activateAsset(asset).last); final strategy = await _resolvePubkeyStrategy(asset); if (!strategy.supportsMultipleAddresses) { throw UnsupportedError( 'Asset ${asset.id.name} does not support multiple addresses', ); } - return strategy.getNewAddress(asset.id, _client); } diff --git a/packages/komodo_defi_sdk/lib/src/withdrawals/legacy_withdrawal_manager.dart b/packages/komodo_defi_sdk/lib/src/withdrawals/legacy_withdrawal_manager.dart index b65962ec..fa0289ca 100644 --- a/packages/komodo_defi_sdk/lib/src/withdrawals/legacy_withdrawal_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/withdrawals/legacy_withdrawal_manager.dart @@ -45,7 +45,8 @@ class LegacyWithdrawalManager implements WithdrawalManager { coin: result.coin, toAddress: result.to.first, fee: result.fee, - kmdRewardsEligible: result.kmdRewards != null && + kmdRewardsEligible: + result.kmdRewards != null && Decimal.parse(result.kmdRewards!.amount) > Decimal.zero, ), ); @@ -67,7 +68,8 @@ class LegacyWithdrawalManager implements WithdrawalManager { coin: parameters.asset, toAddress: parameters.toAddress, fee: result.fee, - kmdRewardsEligible: result.kmdRewards != null && + kmdRewardsEligible: + result.kmdRewards != null && Decimal.parse(result.kmdRewards!.amount) > Decimal.zero, ), ); @@ -112,7 +114,7 @@ class LegacyWithdrawalManager implements WithdrawalManager { } return response.details as WithdrawResult; - } catch (e) { + } catch (e, s) { if (e is WithdrawalException) { rethrow; } diff --git a/packages/komodo_defi_sdk/lib/src/withdrawals/withdrawal_manager.dart b/packages/komodo_defi_sdk/lib/src/withdrawals/withdrawal_manager.dart index 5740a97f..509dcafd 100644 --- a/packages/komodo_defi_sdk/lib/src/withdrawals/withdrawal_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/withdrawals/withdrawal_manager.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:decimal/decimal.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; import 'package:komodo_defi_sdk/src/_internal_exports.dart'; +import 'package:komodo_defi_sdk/src/withdrawals/legacy_withdrawal_manager.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; /// Manages asset withdrawals using task-based API @@ -42,6 +43,18 @@ class WithdrawalManager { WithdrawParameters parameters, ) async { try { + final asset = + _assetProvider.findAssetsByConfigId(parameters.asset).single; + final isTendermintProtocol = asset.protocol is TendermintProtocol; + + // Tendermint assets are not yet supported by the task-based API + // and require a legacy implementation + if (isTendermintProtocol) { + final legacyManager = LegacyWithdrawalManager(_client); + return await legacyManager.previewWithdrawal(parameters); + } + + // Use task-based approach for non-Tendermint assets final stream = (await _client.rpc.withdraw.init( parameters, )).watch( @@ -86,6 +99,16 @@ class WithdrawalManager { try { final asset = _assetProvider.findAssetsByConfigId(parameters.asset).single; + final isTendermintProtocol = asset.protocol is TendermintProtocol; + + // Tendermint assets are not yet supported by the task-based API + // and require a legacy implementation + if (isTendermintProtocol) { + final legacyManager = LegacyWithdrawalManager(_client); + yield* legacyManager.withdraw(parameters); + return; + } + final activationStatus = await _activationManager.activateAsset(asset).last; diff --git a/packages/komodo_defi_types/.gitignore b/packages/komodo_defi_types/.gitignore index 3cceda55..4b454be3 100644 --- a/packages/komodo_defi_types/.gitignore +++ b/packages/komodo_defi_types/.gitignore @@ -5,3 +5,6 @@ # Avoid committing pubspec.lock for library packages; see # https://dart.dev/guides/libraries/private-files#pubspeclock. pubspec.lock + +# Flutter/Dart build output folder +build/ \ No newline at end of file diff --git a/packages/komodo_defi_types/lib/komodo_defi_type_utils.dart b/packages/komodo_defi_types/lib/komodo_defi_type_utils.dart index 894f1532..b5792da3 100644 --- a/packages/komodo_defi_types/lib/komodo_defi_type_utils.dart +++ b/packages/komodo_defi_types/lib/komodo_defi_type_utils.dart @@ -8,4 +8,5 @@ export 'src/utils/json_type_utils.dart'; export 'src/utils/live_data.dart'; export 'src/utils/live_data_builder.dart'; export 'src/utils/mnemonic_validator.dart'; +export 'src/utils/retry_utils.dart'; export 'src/utils/security_utils.dart'; diff --git a/packages/komodo_defi_types/lib/src/transactions/fee_info.dart b/packages/komodo_defi_types/lib/src/transactions/fee_info.dart index cd39826a..39328745 100644 --- a/packages/komodo_defi_types/lib/src/transactions/fee_info.dart +++ b/packages/komodo_defi_types/lib/src/transactions/fee_info.dart @@ -47,6 +47,16 @@ sealed class FeeInfo with _$FeeInfo { gasPrice: Decimal.parse(json['gas_price'].toString()), gasLimit: json['gas_limit'] as int, ); + // Legacy withdraw API returns "Tendermint" instead of "CosmosGas", + // so add this case for compatibility and as a fallback. + case 'Tendermint': + return FeeInfo.cosmosGas( + coin: json['coin'] as String? ?? '', + // The doc sometimes shows 0.05 as a number (double), + // so we convert it to string, then parse: + gasPrice: Decimal.parse(json['amount'].toString()), + gasLimit: json['gas_limit'] as int, + ); case 'CosmosGas': return FeeInfo.cosmosGas( coin: json['coin'] as String? ?? '', diff --git a/packages/komodo_defi_types/lib/src/utils/backoff_strategy.dart b/packages/komodo_defi_types/lib/src/utils/backoff_strategy.dart new file mode 100644 index 00000000..aad8849b --- /dev/null +++ b/packages/komodo_defi_types/lib/src/utils/backoff_strategy.dart @@ -0,0 +1,153 @@ +import 'dart:math' as math; + +import 'package:komodo_defi_types/src/utils/retry_utils.dart'; + +/// Base class for defining backoff strategies. +/// +/// Implement this abstract class to create custom backoff strategies +/// for use with [retry]. +abstract class BackoffStrategy { + /// Calculates the next delay based on the current attempt and state. + /// + /// [attempt] is the current retry attempt (0-based) + /// [currentDelay] is the most recently used delay + Duration nextDelay(int attempt, Duration currentDelay); + + /// Creates a deep copy of this strategy. + /// + /// This is used by the retry function to avoid mutating the original strategy. + BackoffStrategy clone(); +} + +/// Implements exponential backoff with optional jitter. +/// +/// This strategy doubles the delay after each attempt, capped by a maximum delay. +/// When jitter is enabled, it adds a random variance to prevent synchronized +/// retries in distributed systems. +class ExponentialBackoff implements BackoffStrategy { + /// Creates an exponential backoff strategy + /// + /// [initialDelay] Starting delay between retries (default: 200ms) + /// [maxDelay] Maximum delay between retries (default: 5s) + /// [withJitter] Whether to add random jitter to prevent thundering herd (default: false) + /// [random] Optional random number generator for testing + ExponentialBackoff({ + this.initialDelay = const Duration(milliseconds: 200), + this.maxDelay = const Duration(seconds: 5), + this.withJitter = false, + math.Random? random, + }) : _random = random ?? math.Random(); + + /// Initial delay duration before applying backoff + final Duration initialDelay; + + /// Maximum delay duration to cap exponential growth + final Duration maxDelay; + + /// Whether to add random jitter to the delay + final bool withJitter; + + /// Random number generator for jitter calculation + final math.Random _random; + + @override + Duration nextDelay(int attempt, Duration currentDelay) { + if (attempt == 0) { + return _applyJitter(initialDelay); + } + + final nextDelay = currentDelay * 2; + final cappedDelay = nextDelay > maxDelay ? maxDelay : nextDelay; + + return _applyJitter(cappedDelay); + } + + /// Applies jitter to the delay if enabled + Duration _applyJitter(Duration delay) { + if (!withJitter) return delay; + + final jitterFactor = 0.85 + (_random.nextDouble() * 0.3); + final jitteredMs = (delay.inMilliseconds * jitterFactor).round(); + + return Duration(milliseconds: jitteredMs); + } + + @override + BackoffStrategy clone() { + return ExponentialBackoff( + initialDelay: initialDelay, + maxDelay: maxDelay, + withJitter: withJitter, + ); + } +} + +/// Implements a constant backoff strategy with fixed delay. +/// +/// This strategy uses the same delay for all retry attempts. +class ConstantBackoff implements BackoffStrategy { + /// Creates a constant backoff strategy + /// + /// [delay] Fixed delay between retries (default: 1s) + ConstantBackoff({ + this.delay = const Duration(seconds: 1), + }); + + /// Fixed delay to use between retry attempts + final Duration delay; + + @override + Duration nextDelay(int attempt, Duration currentDelay) { + return delay; + } + + @override + BackoffStrategy clone() { + return ConstantBackoff(delay: delay); + } +} + +/// Implements a linear backoff strategy. +/// +/// This strategy increases the delay by a fixed amount after each attempt, +/// capped by a maximum delay. +class LinearBackoff implements BackoffStrategy { + /// Creates a linear backoff strategy + /// + /// [initialDelay] Starting delay between retries (default: 200ms) + /// [increment] Amount to increase delay by after each attempt (default: 200ms) + /// [maxDelay] Maximum delay between retries (default: 5s) + LinearBackoff({ + this.initialDelay = const Duration(milliseconds: 200), + this.increment = const Duration(milliseconds: 200), + this.maxDelay = const Duration(seconds: 5), + }); + + /// Initial delay duration + final Duration initialDelay; + + /// Increment to add to the delay after each attempt + final Duration increment; + + /// Maximum delay duration + final Duration maxDelay; + + @override + Duration nextDelay(int attempt, Duration currentDelay) { + if (attempt == 0) { + return initialDelay; + } + + final nextDelay = currentDelay + increment; + return nextDelay > maxDelay ? maxDelay : nextDelay; + } + + @override + BackoffStrategy clone() { + return LinearBackoff( + initialDelay: initialDelay, + increment: increment, + maxDelay: maxDelay, + ); + } +} diff --git a/packages/komodo_defi_types/lib/src/utils/retry_utils.dart b/packages/komodo_defi_types/lib/src/utils/retry_utils.dart new file mode 100644 index 00000000..6dbc6e14 --- /dev/null +++ b/packages/komodo_defi_types/lib/src/utils/retry_utils.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:komodo_defi_types/src/utils/backoff_strategy.dart'; + +/// Retry utility with configurable backoff strategy. +/// +/// This function executes [functionToRetry] and retries on failure using +/// the provided backoff strategy. +/// +/// Parameters: +/// - [functionToRetry]: The asynchronous function to execute and retry. +/// - [maxAttempts]: Maximum number of retry attempts (default: 5). +/// - [backoffStrategy]: Strategy for calculating delay between retries +/// (default: [ExponentialBackoff] without jitter). +/// - [retryTimeout]: Optional overall timeout for all retry attempts. NOTE: +/// This timeout is not applied to the individual function calls, but to the +/// retry operation as a whole. If the function takes longer than this +/// timeout to complete, the retry operation will be aborted. +/// - [shouldRetry]: Optional function that determines if a specific error +/// should trigger a retry. If it returns false, the error is rethrown immediately. +/// - [shouldRetryNoIncrement]: Optional function for special cases where an +/// error should trigger a retry without incrementing the attempt counter. +/// Use with caution. Intended for false positives where the error doesn't +/// indicate a failure of [functionToRetry]. +/// - [onRetry]: Optional callback executed before each retry attempt with +/// the current attempt count, error, and delay information. +/// +/// Example: +/// ```dart +/// final result = await retry( +/// () => fetchDataFromApi(), +/// maxAttempts: 3, +/// backoffStrategy: ExponentialBackoff(withJitter: true), +/// shouldRetry: (e) => e is NetworkTimeoutException, +/// onRetry: (attempt, error, delay) => +/// print('Retry $attempt after $delay due to $error'), +/// ); +/// ``` +Future retry( + Future Function() functionToRetry, { + int maxAttempts = 5, + BackoffStrategy? backoffStrategy, + Duration? retryTimeout, + bool Function(Object error)? shouldRetry, + bool Function(Object error)? shouldRetryNoIncrement, + void Function(int attempt, Object error, Duration delay)? onRetry, +}) async { + backoffStrategy ??= ExponentialBackoff(); + final strategy = backoffStrategy.clone(); + var attempt = 0; + var delay = Duration.zero; + final stopwatch = Stopwatch()..start(); + + while (true) { + if (retryTimeout != null && stopwatch.elapsed >= retryTimeout) { + throw TimeoutException( + 'Retry operation timed out after ${stopwatch.elapsed}', + retryTimeout, + ); + } + + try { + // RPC calls are scheduled microtasks, so we need to run them in a zone + // to catch errors that are thrown in the microtask queue, which would + // otherwise be unhandled. + final completer = Completer(); + + // Completer is awaited and used to return the result, so we can unawait + // the result of the runZonedGuarded call. awaiting this would block + // the function from returning until the completer is completed - breaking + // the retry loop and unit tests. + unawaited( + runZonedGuarded( + () async { + try { + final result = await functionToRetry(); + if (!completer.isCompleted) { + completer.complete(result); + } + } catch (e, stack) { + if (!completer.isCompleted) { + completer.completeError(e, stack); + } + } + }, + (error, stack) { + if (!completer.isCompleted) { + completer.completeError(error, stack); + } + }, + ), + ); + + final result = await completer.future; + return result; + } catch (e) { + if (shouldRetryNoIncrement != null && shouldRetryNoIncrement(e)) { + delay = strategy.nextDelay(attempt, delay); + + if (onRetry != null) { + onRetry(attempt, e, delay); + } + + await Future.delayed(delay); + continue; + } + + attempt++; + if (attempt >= maxAttempts || (shouldRetry != null && !shouldRetry(e))) { + if (onRetry != null) { + onRetry(attempt, e, delay); + } + rethrow; + } + + delay = strategy.nextDelay(attempt - 1, delay); + + if (onRetry != null) { + onRetry(attempt, e, delay); + } + + await Future.delayed(delay); + } + } +} diff --git a/packages/komodo_defi_types/test/utils/retry_utils_test.dart b/packages/komodo_defi_types/test/utils/retry_utils_test.dart new file mode 100644 index 00000000..c85bbe89 --- /dev/null +++ b/packages/komodo_defi_types/test/utils/retry_utils_test.dart @@ -0,0 +1,328 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:komodo_defi_types/src/utils/backoff_strategy.dart'; +import 'package:komodo_defi_types/src/utils/retry_utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('retry function', () { + test('succeeds on first try', () async { + var callCount = 0; + + final result = await retry(() async { + callCount++; + return 'success'; + }); + + expect(result, equals('success')); + expect(callCount, equals(1)); + }); + + test('retries on failure and succeeds', () async { + var callCount = 0; + + final result = await retry(() async { + callCount++; + if (callCount == 1) { + throw Exception('Simulated failure'); + } + return 'success after retry'; + }); + + expect(result, equals('success after retry')); + expect(callCount, equals(2)); + }); + + test('respects retryTimeout', () async { + expect( + retry( + () async { + await Future.delayed(const Duration(milliseconds: 50)); + throw Exception('Will timeout'); + }, + maxAttempts: 10, + retryTimeout: const Duration(milliseconds: 100), + backoffStrategy: + ConstantBackoff(delay: const Duration(milliseconds: 10)), + ), + throwsA(isA()), + ); + }); + + test('respects shouldRetry callback', () async { + var callCount = 0; + final retryError = Exception('Should retry'); + final nonRetryError = Exception('Should not retry'); + + try { + await retry( + () { + callCount++; + throw retryError; + }, + maxAttempts: 3, + shouldRetry: (e) => e.toString() == retryError.toString(), + backoffStrategy: + ConstantBackoff(delay: const Duration(milliseconds: 10)), + ); + } catch (e) { + expect(e.toString(), equals(retryError.toString())); + } + expect(callCount, equals(3)); + + callCount = 0; + try { + await retry( + () { + callCount++; + throw nonRetryError; + }, + maxAttempts: 3, + shouldRetry: (e) => e.toString() == retryError.toString(), + backoffStrategy: + ConstantBackoff(delay: const Duration(milliseconds: 10)), + ); + } catch (e) { + expect(e.toString(), equals(nonRetryError.toString())); + } + expect(callCount, equals(1)); + }); + + test('respects shouldRetryNoIncrement callback', () async { + var callCount = 0; + final attempts = []; + + try { + await retry( + () async { + callCount++; + if (callCount <= 2) { + throw Exception('No increment'); + } else if (callCount <= 4) { + throw Exception('Normal failure'); + } + return 'Success'; + }, + maxAttempts: 2, + shouldRetryNoIncrement: (e) => e.toString().contains('No increment'), + backoffStrategy: + ConstantBackoff(delay: const Duration(milliseconds: 10)), + onRetry: (attempt, error, delay) { + attempts.add(attempt); + }, + ); + } catch (e) { + // Expected to fail after maxAttempts + } + + // We expect 2 retries that don't increment the counter + // followed by 2 normal retries that do + expect(callCount, equals(4)); + expect(attempts, equals([0, 0, 1, 2])); + }); + + test('calls onRetry with correct arguments', () async { + final attempts = []; + final errors = []; + final delays = []; + + try { + await retry( + () async { + throw Exception('Test error ${attempts.length}'); + }, + maxAttempts: 3, + backoffStrategy: + ConstantBackoff(delay: const Duration(milliseconds: 15)), + onRetry: (attempt, error, delay) { + attempts.add(attempt); + errors.add(error.toString()); + delays.add(delay); + }, + ); + } catch (e) { + // Expected to fail + } + + expect(attempts, equals([1, 2, 3])); + expect(errors.length, equals(3)); + expect(errors[0], contains('Test error 0')); + expect(errors[1], contains('Test error 1')); + expect(errors[2], contains('Test error 2')); + expect(delays, everyElement(equals(const Duration(milliseconds: 15)))); + }); + + test('propagates original exception', () async { + final originalError = StateError('Original error'); + + expect( + () async { + await retry( + () { + throw originalError; + }, + maxAttempts: 2, + backoffStrategy: + ConstantBackoff(delay: const Duration(milliseconds: 10)), + ); + }, + throwsA(same(originalError)), + ); + }); + + test('handles errors in microtask queue with runZonedGuarded', () async { + expect( + () async { + await retry( + () async { + scheduleMicrotask(() => throw Exception('Microtask error')); + + // This future will never complete because the error above + // should be caught + return await Completer().future; + }, + maxAttempts: 2, + backoffStrategy: + ConstantBackoff(delay: const Duration(milliseconds: 10)), + ); + }, + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('Microtask error'), + ), + ), + ); + }); + }); + + group('ExponentialBackoff', () { + test('uses initialDelay for first attempt', () { + final strategy = ExponentialBackoff( + initialDelay: const Duration(milliseconds: 100), + withJitter: false, + ); + + final delay = strategy.nextDelay(0, Duration.zero); + expect(delay, equals(const Duration(milliseconds: 100))); + }); + + test('doubles delay for each subsequent attempt', () { + final strategy = ExponentialBackoff( + initialDelay: const Duration(milliseconds: 100), + maxDelay: const Duration(seconds: 10), + withJitter: false, + ); + + var delay = strategy.nextDelay(0, Duration.zero); + expect(delay, equals(const Duration(milliseconds: 100))); + + delay = strategy.nextDelay(1, delay); + expect(delay, equals(const Duration(milliseconds: 200))); + + delay = strategy.nextDelay(2, delay); + expect(delay, equals(const Duration(milliseconds: 400))); + + delay = strategy.nextDelay(3, delay); + expect(delay, equals(const Duration(milliseconds: 800))); + }); + + test('caps delay at maxDelay', () { + final strategy = ExponentialBackoff( + initialDelay: const Duration(milliseconds: 1000), + maxDelay: const Duration(milliseconds: 3000), + withJitter: false, + ); + + var delay = strategy.nextDelay(0, Duration.zero); + expect(delay, equals(const Duration(milliseconds: 1000))); + + delay = strategy.nextDelay(1, delay); + expect(delay, equals(const Duration(milliseconds: 2000))); + + delay = strategy.nextDelay(2, delay); + // This would be 4000ms but should be capped at 3000ms + expect(delay, equals(const Duration(milliseconds: 3000))); + + delay = strategy.nextDelay(3, delay); + // Still capped at 3000ms + expect(delay, equals(const Duration(milliseconds: 3000))); + }); + + test('produces consistent delays without jitter', () { + final strategy = ExponentialBackoff( + initialDelay: const Duration(milliseconds: 100), + withJitter: false, + ); + + final firstRun = []; + var delay = Duration.zero; + for (var i = 0; i < 5; i++) { + delay = strategy.nextDelay(i, delay); + firstRun.add(delay); + } + + final secondRun = []; + delay = Duration.zero; + for (var i = 0; i < 5; i++) { + delay = strategy.nextDelay(i, delay); + secondRun.add(delay); + } + + // Without jitter, delays should be exactly the same + for (var i = 0; i < 5; i++) { + expect(firstRun[i], equals(secondRun[i])); + } + }); + + test('applies jitter when enabled', () { + // Use fixed random for deterministic testing + final random = _FixedRandom(0.5); + final strategy = ExponentialBackoff( + initialDelay: const Duration(milliseconds: 100), + withJitter: true, + random: random, + ); + + final delay = strategy.nextDelay(0, Duration.zero); + // With jitter factor of 0.5, we expect jitterFactor to be 0.85 + (0.5 * 0.3) = 1.0 + // So delay should be 100ms * 1.0 = 100ms + expect(delay.inMilliseconds, equals(100)); + }); + + test('clone creates proper copy', () { + final original = ExponentialBackoff( + initialDelay: const Duration(milliseconds: 150), + maxDelay: const Duration(seconds: 3), + withJitter: true, + ); + + final clone = original.clone() as ExponentialBackoff; + + expect(clone.initialDelay, equals(original.initialDelay)); + expect(clone.maxDelay, equals(original.maxDelay)); + expect(clone.withJitter, equals(original.withJitter)); + + // Make sure they're truly separate instances + expect(identical(clone, original), isFalse); + }); + }); +} + +/// A fixed random number generator for deterministic tests +class _FixedRandom implements math.Random { + _FixedRandom(this.value); + + final double value; + + @override + bool nextBool() => value > 0.5; + + @override + double nextDouble() => value; + + @override + int nextInt(int max) => (value * max).floor(); +}