feat(pipeline): add ExponentialBackoff policy
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
Zac Gaetano 2026-05-07 15:24:13 +00:00
parent aecbda674d
commit 798a5abd64
2 changed files with 77 additions and 0 deletions

View file

@ -0,0 +1,46 @@
namespace TeamsISO.Engine.Pipeline;
/// <summary>
/// Exponential backoff policy used by <see cref="IsoPipeline"/>'s restart supervisor.
/// Doubles the delay on each successive attempt and caps it at <c>cap</c>. After
/// <c>maxAttempts</c> consecutive failures, <see cref="ShouldGiveUp"/> returns true.
/// </summary>
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;
}
/// <summary>Default policy: 5 attempts, 1s initial, 30s cap.</summary>
public static ExponentialBackoff Default { get; } =
new(maxAttempts: 5, initial: TimeSpan.FromSeconds(1), cap: TimeSpan.FromSeconds(30));
public int MaxAttempts => _maxAttempts;
/// <summary>
/// Returns the delay before the <paramref name="attempt"/>-th retry.
/// <paramref name="attempt"/> is 1-based.
/// </summary>
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);
}
/// <summary>
/// Returns true once <paramref name="attemptCount"/> exceeds <c>maxAttempts</c>.
/// </summary>
public bool ShouldGiveUp(int attemptCount) => attemptCount > _maxAttempts;
}

View file

@ -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);
}
}