Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce RetryStrategyOptions.MaxDelay property #1620

Merged
merged 2 commits into from
Sep 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions src/Polly.Core/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,8 @@ Polly.Retry.RetryStrategyOptions<TResult>.Delay.get -> System.TimeSpan
Polly.Retry.RetryStrategyOptions<TResult>.Delay.set -> void
Polly.Retry.RetryStrategyOptions<TResult>.DelayGenerator.get -> System.Func<Polly.Retry.RetryDelayGeneratorArguments<TResult>, System.Threading.Tasks.ValueTask<System.TimeSpan?>>?
Polly.Retry.RetryStrategyOptions<TResult>.DelayGenerator.set -> void
Polly.Retry.RetryStrategyOptions<TResult>.MaxDelay.get -> System.TimeSpan?
Polly.Retry.RetryStrategyOptions<TResult>.MaxDelay.set -> void
Polly.Retry.RetryStrategyOptions<TResult>.MaxRetryAttempts.get -> int
Polly.Retry.RetryStrategyOptions<TResult>.MaxRetryAttempts.set -> void
Polly.Retry.RetryStrategyOptions<TResult>.OnRetry.get -> System.Func<Polly.Retry.OnRetryArguments<TResult>, System.Threading.Tasks.ValueTask>?
Expand Down
29 changes: 25 additions & 4 deletions src/Polly.Core/Retry/RetryHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,26 @@ internal static class RetryHelper

public static bool IsValidDelay(TimeSpan delay) => delay >= TimeSpan.Zero;

public static TimeSpan GetRetryDelay(DelayBackoffType type, bool jitter, int attempt, TimeSpan baseDelay, ref double state, Func<double> randomizer)
public static TimeSpan GetRetryDelay(
DelayBackoffType type,
bool jitter,
int attempt,
TimeSpan baseDelay,
TimeSpan? maxDelay,
ref double state,
Func<double> randomizer)
{
try
{
return GetRetryDelayCore(type, jitter, attempt, baseDelay, ref state, randomizer);
var delay = GetRetryDelayCore(type, jitter, attempt, baseDelay, ref state, randomizer);

// stryker disable once equality : no means to test this
if (maxDelay is TimeSpan maxDelayValue && delay > maxDelayValue)
{
return maxDelay.Value;
}

return delay;
}
catch (OverflowException)
{
Expand Down Expand Up @@ -89,7 +104,7 @@ private static TimeSpan DecorrelatedJitterBackoffV2(int attempt, TimeSpan baseDe
long targetTicksFirstDelay = baseDelay.Ticks;

double t = attempt + randomizer();
double next = Math.Pow(2, t) * Math.Tanh(Math.Sqrt(PFactor * t));
double next = Math.Pow(ExponentialFactor, t) * Math.Tanh(Math.Sqrt(PFactor * t));

double formulaIntrinsicValue = next - prev;
prev = next;
Expand All @@ -98,5 +113,11 @@ private static TimeSpan DecorrelatedJitterBackoffV2(int attempt, TimeSpan baseDe
}

private static TimeSpan ApplyJitter(TimeSpan delay, Func<double> randomizer)
=> TimeSpan.FromMilliseconds(delay.TotalMilliseconds + ((delay.TotalMilliseconds * JitterFactor) * randomizer()));
{
var offset = (delay.TotalMilliseconds * JitterFactor) / 2;
var randomDelay = (delay.TotalMilliseconds * JitterFactor * randomizer()) - offset;
var newDelay = delay.TotalMilliseconds + randomDelay;

return TimeSpan.FromMilliseconds(newDelay);
}
}
5 changes: 4 additions & 1 deletion src/Polly.Core/Retry/RetryResilienceStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public RetryResilienceStrategy(
{
ShouldHandle = options.ShouldHandle;
BaseDelay = options.Delay;
MaxDelay = options.MaxDelay;
BackoffType = options.BackoffType;
RetryCount = options.MaxRetryAttempts;
OnRetry = options.OnRetry;
Expand All @@ -28,6 +29,8 @@ public RetryResilienceStrategy(

public TimeSpan BaseDelay { get; }

public TimeSpan? MaxDelay { get; }

public DelayBackoffType BackoffType { get; }

public int RetryCount { get; }
Expand Down Expand Up @@ -61,7 +64,7 @@ protected internal override async ValueTask<Outcome<T>> ExecuteCore<TState>(Func
return outcome;
}

var delay = RetryHelper.GetRetryDelay(BackoffType, UseJitter, attempt, BaseDelay, ref retryState, _randomizer);
var delay = RetryHelper.GetRetryDelay(BackoffType, UseJitter, attempt, BaseDelay, MaxDelay, ref retryState, _randomizer);
if (DelayGenerator is not null)
{
var delayArgs = new RetryDelayGeneratorArguments<T>(context, outcome, attempt);
Expand Down
18 changes: 16 additions & 2 deletions src/Polly.Core/Retry/RetryStrategyOptions.TResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

namespace Polly.Retry;

#pragma warning disable IL2026 // Addressed with DynamicDependency on ValidationHelper.Validate method

/// <summary>
/// Represents the options used to configure a retry strategy.
/// </summary>
Expand Down Expand Up @@ -49,7 +51,6 @@ public class RetryStrategyOptions<TResult> : ResilienceStrategyOptions
/// </value>
public bool UseJitter { get; set; }

#pragma warning disable IL2026 // Addressed with DynamicDependency on ValidationHelper.Validate method
/// <summary>
/// Gets or sets the base delay between retries.
/// </summary>
Expand All @@ -73,7 +74,20 @@ public class RetryStrategyOptions<TResult> : ResilienceStrategyOptions
/// </value>
[Range(typeof(TimeSpan), "00:00:00", "1.00:00:00")]
public TimeSpan Delay { get; set; } = RetryConstants.DefaultBaseDelay;
#pragma warning restore IL2026

/// <summary>
/// Gets or sets the maximum delay between retries.
/// </summary>
/// <remarks>
/// This property is used to cap maximum delay between retries. It is useful when you want to limit the maximum delay after certain
/// number of between retries when it could reach a unreasonably high values, especially if <see cref="DelayBackoffType.Exponential"/> backoff is used.
/// If not specified, the delay is not capped. This property is ignored for delays generated by <see cref="DelayGenerator"/>.
/// </remarks>
/// <value>
/// The default value is <see langword="null"/>.
/// </value>
[Range(typeof(TimeSpan), "00:00:00", "1.00:00:00")]
public TimeSpan? MaxDelay { get; set; }

/// <summary>
/// Gets or sets a predicate that determines whether the retry should be executed for a given outcome.
Expand Down
164 changes: 120 additions & 44 deletions test/Polly.Core.Tests/Retry/RetryHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,79 +28,155 @@ public void UnsupportedRetryBackoffType_Throws(bool jitter)
Assert.Throws<ArgumentOutOfRangeException>(() =>
{
double state = 0;
return RetryHelper.GetRetryDelay(type, jitter, 0, TimeSpan.FromSeconds(1), ref state, _randomizer);
return RetryHelper.GetRetryDelay(type, jitter, 0, TimeSpan.FromSeconds(1), null, ref state, _randomizer);
});
}

[InlineData(true)]
[InlineData(false)]
[Theory]
public void Constant_Ok(bool jitter)
[Fact]
public void Constant_Ok()
{
double state = 0;
if (jitter)
{
_randomizer = () => 0.5;
}

RetryHelper.GetRetryDelay(DelayBackoffType.Constant, jitter, 0, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Constant, jitter, 1, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Constant, jitter, 2, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Constant, false, 0, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Constant, false, 1, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Constant, false, 2, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);

RetryHelper.GetRetryDelay(DelayBackoffType.Constant, false, 0, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(1));
RetryHelper.GetRetryDelay(DelayBackoffType.Constant, false, 1, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(1));
RetryHelper.GetRetryDelay(DelayBackoffType.Constant, false, 2, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(1));
}

var expected = !jitter ? TimeSpan.FromSeconds(1) : TimeSpan.FromSeconds(1.25);
[Fact]
public void Constant_Jitter_Ok()
{
double state = 0;

RetryHelper.GetRetryDelay(DelayBackoffType.Constant, jitter, 0, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(expected);
RetryHelper.GetRetryDelay(DelayBackoffType.Constant, jitter, 1, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(expected);
RetryHelper.GetRetryDelay(DelayBackoffType.Constant, jitter, 2, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(expected);
RetryHelper.GetRetryDelay(DelayBackoffType.Constant, true, 0, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Constant, true, 1, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Constant, true, 2, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);

_randomizer = () => 0.0;
RetryHelper
.GetRetryDelay(DelayBackoffType.Constant, true, 0, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
.Should()
.Be(TimeSpan.FromSeconds(0.75));

_randomizer = () => 0.4;
RetryHelper
.GetRetryDelay(DelayBackoffType.Constant, true, 0, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
.Should()
.Be(TimeSpan.FromSeconds(0.95));

_randomizer = () => 0.6;
RetryHelper.GetRetryDelay(DelayBackoffType.Constant, true, 1, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
.Should()
.Be(TimeSpan.FromSeconds(1.05));

_randomizer = () => 1.0;
RetryHelper
.GetRetryDelay(DelayBackoffType.Constant, true, 0, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
.Should()
.Be(TimeSpan.FromSeconds(1.25));
}

[InlineData(true)]
[InlineData(false)]
[Theory]
public void Linear_Ok(bool jitter)
[Fact]
public void Linear_Ok()
{
double state = 0;

RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 0, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 1, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 2, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Linear, false, 0, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Linear, false, 1, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Linear, false, 2, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);

if (jitter)
{
_randomizer = () => 0.5;
RetryHelper.GetRetryDelay(DelayBackoffType.Linear, false, 0, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(1));
RetryHelper.GetRetryDelay(DelayBackoffType.Linear, false, 1, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(2));
RetryHelper.GetRetryDelay(DelayBackoffType.Linear, false, 2, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(3));
}

RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 0, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(1.25));
RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 1, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(2.5));
RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 2, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(3.75));
}
else
{
RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 0, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(1));
RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 1, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(2));
RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 2, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(3));
}
[Fact]
public void Linear_Jitter_Ok()
{
double state = 0;

RetryHelper.GetRetryDelay(DelayBackoffType.Linear, true, 0, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Linear, true, 1, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Linear, true, 2, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);

_randomizer = () => 0.0;
RetryHelper
.GetRetryDelay(DelayBackoffType.Linear, true, 2, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
.Should()
.Be(TimeSpan.FromSeconds(2.25));

_randomizer = () => 0.4;
RetryHelper
.GetRetryDelay(DelayBackoffType.Linear, true, 2, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
.Should()
.Be(TimeSpan.FromSeconds(2.85));

_randomizer = () => 0.5;
RetryHelper
.GetRetryDelay(DelayBackoffType.Linear, true, 2, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
.Should()
.Be(TimeSpan.FromSeconds(3));

_randomizer = () => 0.6;
RetryHelper.GetRetryDelay(DelayBackoffType.Linear, true, 2, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
.Should()
.Be(TimeSpan.FromSeconds(3.15));

_randomizer = () => 1.0;
RetryHelper
.GetRetryDelay(DelayBackoffType.Linear, true, 2, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
.Should()
.Be(TimeSpan.FromSeconds(3.75));
}

[Fact]
public void Exponential_Ok()
{
double state = 0;

RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 0, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 1, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 2, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 0, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 1, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 2, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);

RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 0, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(1));
RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 1, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(2));
RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 2, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(4));
}

[InlineData(DelayBackoffType.Linear, false)]
[InlineData(DelayBackoffType.Exponential, false)]
[InlineData(DelayBackoffType.Constant, false)]
[InlineData(DelayBackoffType.Linear, true)]
[InlineData(DelayBackoffType.Exponential, true)]
[InlineData(DelayBackoffType.Constant, true)]
[Theory]
public void MaxDelay_Ok(DelayBackoffType type, bool jitter)
{
_randomizer = () => 0.5;
var expected = TimeSpan.FromSeconds(1);
double state = 0;

RetryHelper.GetRetryDelay(type, jitter, 2, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(expected);
}

[Fact]
public void MaxDelay_DelayLessThanMaxDelay_Respected()
{
double state = 0;
var expected = TimeSpan.FromSeconds(1);

RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 0, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(1));
RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 1, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(2));
RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 2, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(4));
RetryHelper.GetRetryDelay(DelayBackoffType.Constant, false, 2, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), ref state, _randomizer).Should().Be(expected);
}

[Fact]
public void GetRetryDelay_Overflow_ReturnsMaxTimeSpan()
{
double state = 0;

RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 1000, TimeSpan.FromDays(1), ref state, _randomizer).Should().Be(TimeSpan.MaxValue);
RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 1000, TimeSpan.FromDays(1), null, ref state, _randomizer).Should().Be(TimeSpan.MaxValue);
}

[InlineData(1)]
Expand Down Expand Up @@ -144,7 +220,7 @@ private static IReadOnlyList<TimeSpan> GetExponentialWithJitterBackoff(bool cont

for (int i = 0; i < retryCount; i++)
{
result.Add(RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, true, i, baseDelay, ref state, random));
result.Add(RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, true, i, baseDelay, null, ref state, random));
}

return result;
Expand Down
24 changes: 24 additions & 0 deletions test/Polly.Core.Tests/Retry/RetryResilienceStrategyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,30 @@ public async void OnRetry_EnsureCorrectArguments()
delays[2].Should().Be(TimeSpan.FromSeconds(6));
}

[Fact]
public async void MaxDelay_EnsureRespected()
{
var delays = new List<TimeSpan>();
_options.OnRetry = args =>
{
delays.Add(args.RetryDelay);
return default;
};

_options.ShouldHandle = args => PredicateResult.True();
_options.MaxRetryAttempts = 3;
_options.BackoffType = DelayBackoffType.Linear;
_options.MaxDelay = TimeSpan.FromMilliseconds(123);

var sut = CreateSut();

await ExecuteAndAdvance(sut);

delays[0].Should().Be(TimeSpan.FromMilliseconds(123));
delays[1].Should().Be(TimeSpan.FromMilliseconds(123));
delays[2].Should().Be(TimeSpan.FromMilliseconds(123));
}

[Fact]
public async Task OnRetry_EnsureExecutionTime()
{
Expand Down
4 changes: 3 additions & 1 deletion test/Polly.Core.Tests/Retry/RetryStrategyOptionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ public void InvalidOptions()
DelayGenerator = null!,
OnRetry = null!,
MaxRetryAttempts = -3,
Delay = TimeSpan.MinValue
Delay = TimeSpan.MinValue,
MaxDelay = TimeSpan.FromSeconds(-10)
};

options.Invoking(o => ValidationHelper.ValidateObject(new(o, "Invalid Options")))
Expand All @@ -56,6 +57,7 @@ Invalid Options
Validation Errors:
The field MaxRetryAttempts must be between 1 and 2147483647.
The field Delay must be between 00:00:00 and 1.00:00:00.
The field MaxDelay must be between 00:00:00 and 1.00:00:00.
The ShouldHandle field is required.
""");
}
Expand Down