diff --git a/src/TeamsISO.Engine/Pipeline/ExponentialBackoff.cs b/src/TeamsISO.Engine/Pipeline/ExponentialBackoff.cs new file mode 100644 index 0000000..5e6da15 --- /dev/null +++ b/src/TeamsISO.Engine/Pipeline/ExponentialBackoff.cs @@ -0,0 +1,46 @@ +namespace TeamsISO.Engine.Pipeline; + +/// +/// Exponential backoff policy used by 's restart supervisor. +/// Doubles the delay on each successive attempt and caps it at cap. After +/// maxAttempts consecutive failures, returns true. +/// +public sealed class ExponentialBackoff +{ + private readonly int _maxAttempts; + private readonly TimeSpan _initial; + private readonly TimeSpan _cap; + + public ExponentialBackoff(int maxAttempts, TimeSpan initial, TimeSpan cap) + { + if (maxAttempts <= 0) throw new ArgumentOutOfRangeException(nameof(maxAttempts)); + if (initial <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(initial)); + if (cap < initial) throw new ArgumentOutOfRangeException(nameof(cap)); + _maxAttempts = maxAttempts; + _initial = initial; + _cap = cap; + } + + /// Default policy: 5 attempts, 1s initial, 30s cap. + public static ExponentialBackoff Default { get; } = + new(maxAttempts: 5, initial: TimeSpan.FromSeconds(1), cap: TimeSpan.FromSeconds(30)); + + public int MaxAttempts => _maxAttempts; + + /// + /// Returns the delay before the -th retry. + /// is 1-based. + /// + public TimeSpan NextDelay(int attempt) + { + if (attempt <= 0) throw new ArgumentOutOfRangeException(nameof(attempt)); + var multiplier = Math.Pow(2, attempt - 1); + var delaySeconds = Math.Min(_initial.TotalSeconds * multiplier, _cap.TotalSeconds); + return TimeSpan.FromSeconds(delaySeconds); + } + + /// + /// Returns true once exceeds maxAttempts. + /// + public bool ShouldGiveUp(int attemptCount) => attemptCount > _maxAttempts; +} diff --git a/src/tests/TeamsISO.Engine.Tests/Pipeline/ExponentialBackoffTests.cs b/src/tests/TeamsISO.Engine.Tests/Pipeline/ExponentialBackoffTests.cs new file mode 100644 index 0000000..f182fba --- /dev/null +++ b/src/tests/TeamsISO.Engine.Tests/Pipeline/ExponentialBackoffTests.cs @@ -0,0 +1,31 @@ +using TeamsISO.Engine.Pipeline; + +namespace TeamsISO.Engine.Tests.Pipeline; + +public class ExponentialBackoffTests +{ + [Theory] + [InlineData(1, 1)] + [InlineData(2, 2)] + [InlineData(3, 4)] + [InlineData(4, 8)] + [InlineData(5, 16)] + [InlineData(6, 30)] // capped at 30s + [InlineData(7, 30)] + public void NextDelay_FollowsExponentialSequenceWithCap(int attempt, int expectedSeconds) + { + var policy = new ExponentialBackoff(maxAttempts: 5, initial: TimeSpan.FromSeconds(1), cap: TimeSpan.FromSeconds(30)); + policy.NextDelay(attempt).Should().Be(TimeSpan.FromSeconds(expectedSeconds)); + } + + [Theory] + [InlineData(1, false)] + [InlineData(4, false)] + [InlineData(5, false)] // 5 attempts allowed + [InlineData(6, true)] // give up after the 5th + public void ShouldGiveUp_AfterMaxAttempts(int attemptCount, bool expectGiveUp) + { + var policy = new ExponentialBackoff(maxAttempts: 5, initial: TimeSpan.FromSeconds(1), cap: TimeSpan.FromSeconds(30)); + policy.ShouldGiveUp(attemptCount).Should().Be(expectGiveUp); + } +}