Skip to content

Commit

Permalink
feature: Add support for retrying first failure immediately in Expone…
Browse files Browse the repository at this point in the history
…ntialRetryPolicy with `ExponentialRetryPolicyOptions.FirstRetryIsImmediate`
  • Loading branch information
nozzlegear committed May 16, 2024
1 parent 26774b5 commit 57c91f1
Show file tree
Hide file tree
Showing 3 changed files with 74 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,36 @@ public async Task Run_ShouldRetryWhenRequestIsRetriableAsync()
});
}

[Fact]
public async Task Run_ShouldRetryImmediatelyWhenConfigured()
{
const bool firstRetryIsImmediate = true;
var ex = new TestShopifyException();
var iteration = 0;

_executeRequest.When(x => x.Invoke(_cloneableRequestMessage))
.Do(_ =>
{
if (iteration >= 3) throw new TestException();
iteration++;
throw ex;
});
_responseClassifier.IsRetriableException(ex, Arg.Any<int>())
.Returns(true);

var policy = SetupPolicy(x => x.FirstRetryIsImmediate = firstRetryIsImmediate);
var act = () => policy.Run(_cloneableRequestMessage, _executeRequest, CancellationToken.None);

await act.Should().ThrowAsync<TestException>();
iteration.Should().Be(3);
Received.InOrder(() =>
{
_taskScheduler.DelayAsync(TimeSpan.Zero, Arg.Any<CancellationToken>());
_taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(100), Arg.Any<CancellationToken>());
_taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(200), Arg.Any<CancellationToken>());
});
}

[Fact(Timeout = 1000)]
public async Task Run_ShouldHandleNullMaxRetries()
{
Expand All @@ -172,9 +202,9 @@ public async Task Run_ShouldHandleNullMaxRetries()
.Do(_ =>
{
iteration++;
// Cancel after 20 loops
if (iteration == expectedIterations)
throw new TestException();
// Cancel after 20 loops
if (iteration == expectedIterations)
throw new TestException();
throw ex;
});

Expand Down Expand Up @@ -297,7 +327,6 @@ public async Task Run_ShouldRetryUntilMaximumDelayIsReachedThenThrow()

_executeRequest.When(x => x.Invoke(_cloneableRequestMessage))
.Throw(ex);

_responseClassifier.IsRetriableException(ex, Arg.Any<int>())
.Returns(true);

Expand All @@ -312,6 +341,7 @@ public async Task Run_ShouldRetryUntilMaximumDelayIsReachedThenThrow()
// For now, we expect this test to execute the request, check the exception and wait. This
// is because the cancellation token source puts the cancellation on a different thread when
// cancellation is requested.
// TODO: This seems to be a little bit flaky due to the comment above, sometimes the test receives a second _executeRequest before cancelling
Received.InOrder(() =>
{
_executeRequest.Invoke(_cloneableRequestMessage);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,27 +72,28 @@ public async Task<RequestResult<T>> Run<T>(
// We can quickly hit an overflow by using exponential math to calculate a delay and pass it to the timespan constructor.
// To avoid that, we check to see if one of the previous loops' delays passed the maximum delay between retries. If so,
// we use the maximum delay rather than calculating another one and potentially hitting that overflow.
TimeSpan nextDelay;
var nextDelay = useMaximumDelayBetweenRetries ? _options.MaximumDelayBetweenRetries : CalculateNextDelay(currentTry);

if (useMaximumDelayBetweenRetries)
{
nextDelay = _options.MaximumDelayBetweenRetries;
}
else
{
nextDelay = TimeSpan.FromMilliseconds(Math.Pow(2, currentTry - 1) * _options.InitialBackoffInMilliseconds);

if (nextDelay > _options.MaximumDelayBetweenRetries)
{
useMaximumDelayBetweenRetries = true;
nextDelay = _options.MaximumDelayBetweenRetries;
}
}
if (nextDelay >= _options.MaximumDelayBetweenRetries)
useMaximumDelayBetweenRetries = true;

currentTry++;

// Delay and then try again
await _taskScheduler.DelayAsync(nextDelay, combinedCancellationToken.Token);
}
}

private TimeSpan CalculateNextDelay(int currentTry)
{
if (currentTry == 1 && _options.FirstRetryIsImmediate)
return TimeSpan.Zero;

var exponent = currentTry - (_options.FirstRetryIsImmediate ? 2 : 1);
var delay = Math.Pow(2, Math.Max(0, exponent)) * _options.InitialBackoffInMilliseconds;
var calculatedDelay = TimeSpan.FromMilliseconds(delay);

return calculatedDelay > _options.MaximumDelayBetweenRetries ? _options.MaximumDelayBetweenRetries : calculatedDelay;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,45 @@ namespace ShopifySharp.Infrastructure.Policies.ExponentialRetry;
/// </summary>
public record ExponentialRetryPolicyOptions
{
/// <summary>
/// Indicates whether the policy should immediately retry the first failure per request before applying the
/// exponential backoff strategy.
/// </summary>
/// <value>
/// <c>true</c> if the first retry should be immediate; otherwise, <c>false</c>.
/// </value>
/// <remarks>
/// Setting this property to <c>true</c> can be useful in scenarios where transient failures are likely and
/// immediate retry might resolve the failure. If set to <c>false</c>, all retries including the first one
/// will follow the exponential backoff intervals.
/// </remarks>
public bool FirstRetryIsImmediate { get; set; }

#if NET8_0_OR_GREATER
public required int InitialBackoffInMilliseconds { get; set; }

/// <summary>
/// The maximum amount of time that can be spent waiting before retrying a request. This is an effective cap on the
/// exponential growth of the policy's retry delay, which could eventually lead to an overflow without it.
/// </summary>
public required TimeSpan MaximumDelayBetweenRetries { get; set; }
#else
public int InitialBackoffInMilliseconds { get; set; }
/// <summary>
/// The maximum amount of time that can be spent waiting before retrying a request. This is an effective cap on the
/// exponential growth of the policy's retry delay, which could eventually lead to an overflow without it.
/// </summary>
public TimeSpan MaximumDelayBetweenRetries { get; set; }
#endif
public int? MaximumRetriesBeforeRequestCancellation { get; set; }
public TimeSpan? MaximumDelayBeforeRequestCancellation { get; set; }

/// <summary>
/// Validates this instance and throws an <see cref="ArgumentException"/> if misconfigured.
/// Validates this instance and throws <see cref="ArgumentException"/> if misconfigured.
/// </summary>
/// <throws>
/// <see cref="ArgumentException"/> when the options are misconfigured.
/// </throws>
public void Validate()
{
if (InitialBackoffInMilliseconds <= 0)
Expand All @@ -42,6 +64,7 @@ public void Validate()
public static ExponentialRetryPolicyOptions Default() =>
new()
{
FirstRetryIsImmediate = false,
MaximumRetriesBeforeRequestCancellation = 10,
MaximumDelayBetweenRetries = TimeSpan.FromSeconds(1),
MaximumDelayBeforeRequestCancellation = TimeSpan.FromSeconds(5),
Expand Down

0 comments on commit 57c91f1

Please sign in to comment.