From 99be0bcb2653782e7067d0a5d3df231234d70a3e Mon Sep 17 00:00:00 2001 From: Joshua Harms Date: Mon, 13 May 2024 01:07:22 -0500 Subject: [PATCH] Add ExponentialRetryPolicy with max retries and optional timeout --- .../ExponentialRetryPolicyOptionsTests.cs | 90 +++++ .../ExponentialRetryPolicyTests.cs | 349 ++++++++++++++++++ .../TestCloneableRequestMessage.cs | 20 + .../TestClasses/TestRequestResult.cs | 22 ++ .../TestClasses/TestShopifyException.cs | 11 + ShopifySharp/Infrastructure/AssemblyInfo.cs | 2 + .../ExponentialRetryPolicy.cs | 107 ++++++ .../ExponentialRetryPolicyOptions.cs | 50 +++ ...hopifyExponentialRetryCanceledException.cs | 17 + ShopifySharp/Infrastructure/TaskScheduler.cs | 18 + 10 files changed, 686 insertions(+) create mode 100644 ShopifySharp.Tests/Infrastructure/Policies/ExponentialRetry/ExponentialRetryPolicyOptionsTests.cs create mode 100644 ShopifySharp.Tests/Infrastructure/Policies/ExponentialRetry/ExponentialRetryPolicyTests.cs create mode 100644 ShopifySharp.Tests/TestClasses/TestCloneableRequestMessage.cs create mode 100644 ShopifySharp.Tests/TestClasses/TestRequestResult.cs create mode 100644 ShopifySharp.Tests/TestClasses/TestShopifyException.cs create mode 100644 ShopifySharp/Infrastructure/Policies/ExponentialRetry/ExponentialRetryPolicy.cs create mode 100644 ShopifySharp/Infrastructure/Policies/ExponentialRetry/ExponentialRetryPolicyOptions.cs create mode 100644 ShopifySharp/Infrastructure/ShopifyExponentialRetryCanceledException.cs create mode 100644 ShopifySharp/Infrastructure/TaskScheduler.cs diff --git a/ShopifySharp.Tests/Infrastructure/Policies/ExponentialRetry/ExponentialRetryPolicyOptionsTests.cs b/ShopifySharp.Tests/Infrastructure/Policies/ExponentialRetry/ExponentialRetryPolicyOptionsTests.cs new file mode 100644 index 000000000..bbea5aa69 --- /dev/null +++ b/ShopifySharp.Tests/Infrastructure/Policies/ExponentialRetry/ExponentialRetryPolicyOptionsTests.cs @@ -0,0 +1,90 @@ +#nullable enable +using System; +using FluentAssertions; +using JetBrains.Annotations; +using ShopifySharp.Infrastructure.Policies.ExponentialRetry; +using Xunit; + +namespace ShopifySharp.Tests.Infrastructure.Policies.ExponentialRetry; + +[Trait("Category", "Retry policies"), Trait("Category", "ExponentialRetryPolicyOptions"), Trait("Category", "DotNetFramework"), Collection("DotNetFramework tests")] +[TestSubject(typeof(ExponentialRetryPolicyOptions))] +public class ExponentialRetryPolicyOptionsTests +{ + [Fact] + public void DefaultFactoryMethod_ShouldCreateOptionsThatPassValidation() + { + var options = ExponentialRetryPolicyOptions.Default(); + + var act = options.Validate; + + act.Should().NotThrow().And.Subject.Should().NotBeNull(); + } + + [Theory] + [InlineData(null, null, true)] + [InlineData(null, 1, false)] + [InlineData(1, null, false)] + [InlineData(1, 1, false)] + public void Validate_ShouldValidateMaxRetriesAndMaxDelayBeforeTimeout(int? maxRetries, int? maxDelay, bool shouldThrow) + { + var options = new ExponentialRetryPolicyOptions + { + MaximumDelayBetweenRetries = TimeSpan.FromSeconds(1), + InitialBackoffInMilliseconds = 100, + MaximumRetriesBeforeRequestCancellation = maxRetries, + MaximumDelayBeforeRequestCancellation = maxDelay is null ? null : TimeSpan.FromSeconds(maxDelay.Value), + }; + + var act = () => options.Validate(); + + if (shouldThrow) + act.Should().Throw(); + else + act.Should().NotThrow().And.Subject.Should().NotBeNull(); + } + + [Theory] + [InlineData(-1, true)] + [InlineData(0, true)] + [InlineData(1, false)] + public void Validate_ShouldValidateBackoffInMilliseconds(int backoffInMilliseconds, bool shouldThrow) + { + var options = new ExponentialRetryPolicyOptions + { + MaximumDelayBetweenRetries = TimeSpan.FromSeconds(1), + InitialBackoffInMilliseconds = backoffInMilliseconds, + MaximumRetriesBeforeRequestCancellation = 1, + MaximumDelayBeforeRequestCancellation = null, + }; + + var act = () => options.Validate(); + + if (shouldThrow) + act.Should().Throw(); + else + act.Should().NotThrow().And.Subject.Should().NotBeNull(); + } + + [Theory] + [InlineData(-1, true)] + [InlineData(0, true)] + [InlineData(1, false)] + public void Validate_ShouldValidateMaximumDelayBetweenRetries(int maximumDelayBetweenRetries, bool shouldThrow) + { + var options = new ExponentialRetryPolicyOptions + { + MaximumDelayBetweenRetries = TimeSpan.FromSeconds(maximumDelayBetweenRetries), + InitialBackoffInMilliseconds = 100, + MaximumRetriesBeforeRequestCancellation = 1, + MaximumDelayBeforeRequestCancellation = null, + }; + + var act = () => options.Validate(); + + if (shouldThrow) + act.Should().Throw(); + else + act.Should().NotThrow().And.Subject.Should().NotBeNull(); + } +} diff --git a/ShopifySharp.Tests/Infrastructure/Policies/ExponentialRetry/ExponentialRetryPolicyTests.cs b/ShopifySharp.Tests/Infrastructure/Policies/ExponentialRetry/ExponentialRetryPolicyTests.cs new file mode 100644 index 000000000..7d4a0e9e9 --- /dev/null +++ b/ShopifySharp.Tests/Infrastructure/Policies/ExponentialRetry/ExponentialRetryPolicyTests.cs @@ -0,0 +1,349 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using JetBrains.Annotations; +using NSubstitute; +using NSubstitute.Core; +using ShopifySharp.Infrastructure; +using ShopifySharp.Infrastructure.Policies.ExponentialRetry; +using ShopifySharp.Tests.TestClasses; +using Xunit; + +namespace ShopifySharp.Tests.Infrastructure.Policies.ExponentialRetry; + +[Trait("Category", "Retry policies"), Trait("Category", "ExponentialRetryPolicy"), Trait("Category", "DotNetFramework"), Collection("DotNetFramework tests")] +[TestSubject(typeof(ExponentialRetryPolicy))] +public class ExponentialRetryPolicyTests +{ + private const int BackoffInMilliseconds = 100; + private const int MaximumRetries = 5; + private readonly IResponseClassifier _responseClassifier; + private readonly ITaskScheduler _taskScheduler; + private readonly ExecuteRequestAsync _executeRequest; + + public ExponentialRetryPolicyTests() + { + _responseClassifier = Substitute.For(); + _taskScheduler = Substitute.For(); + _executeRequest = Substitute.For>(); + + // Always return a completed task when the scheduler wants to delay, so no actual time is spent waiting during a test + _taskScheduler.DelayAsync(Arg.Any()) + .Returns(Task.CompletedTask); + } + + private ExponentialRetryPolicy SetupPolicy([CanBeNull] Action configure = null) + { + var options = new ExponentialRetryPolicyOptions + { + MaximumDelayBetweenRetries = TimeSpan.FromSeconds(1), + InitialBackoffInMilliseconds = BackoffInMilliseconds, + MaximumRetriesBeforeRequestCancellation = MaximumRetries, + MaximumDelayBeforeRequestCancellation = null + }; + configure?.Invoke(options); + return new ExponentialRetryPolicy( + options, + _responseClassifier, + _taskScheduler + ); + } + + [Fact] + public void PolicyConstructor_ShouldValidateOptionsBeforeRunning() + { + var act = () => SetupPolicy(x => + { + // Null retries and null delay will cause the options Validate function to throw + x.MaximumRetriesBeforeRequestCancellation = null; + x.MaximumDelayBeforeRequestCancellation = null; + }); + + act.Should().Throw(); + } + + [Fact] + public async Task Run_ShouldReturnResult() + { + const int expectedValue = 5; + var request = new TestCloneableRequestMessage(); + var expectedResult = new TestRequestResult(expectedValue); + + _executeRequest.Invoke(request) + .Returns(expectedResult); + + var policy = SetupPolicy(); + var result = await policy.Run(request, _executeRequest, CancellationToken.None); + + result.Should().NotBeNull(); + result.Result.Should().Be(expectedValue); + } + + [Fact] + public async Task Run_ShouldThrowWhenRequestIsNotRetriableAsync() + { + var ex = new TestShopifyException(); + var request = Substitute.For(); + + _executeRequest.When(x => x.Invoke(request)) + .Throw(ex); + _responseClassifier.IsRetriableException(ex, 1) + .Returns(false); + + var policy = SetupPolicy(); + var act = () => policy.Run(request, _executeRequest, CancellationToken.None); + + await act.Should().ThrowAsync(); + _responseClassifier.Received(1).IsRetriableException(ex, 1); + } + + [Fact] + public async Task Run_ShouldRetryWhenRequestIsRetriableAsync() + { + const int expectedValue = 5; + var request = new TestCloneableRequestMessage(); + var expectedResult = new TestRequestResult(expectedValue); + var ex = new TestShopifyException(); + var iteration = 0; + + _executeRequest(Arg.Any()) + .Returns(expectedResult); + _executeRequest.When(x => x.Invoke(Arg.Any())) + .Do(_ => + { + iteration++; + if (iteration < 4) + throw ex; + }); + + _responseClassifier.IsRetriableException(ex, Arg.Is(x => x < 4)) + .Returns(true); + _responseClassifier.IsRetriableException(ex, Arg.Is(x => x >= 4)) + .Returns(false); + + var policy = SetupPolicy(); + var act = () => policy.Run(request, _executeRequest, CancellationToken.None); + + var result = await act.Should().NotThrowAsync(); + result.Subject.Should().NotBeNull(); + result.Subject.Result.Should().Be(expectedValue); + iteration.Should().Be(4); + Received.InOrder(() => + { + _executeRequest.Invoke(request); + _responseClassifier.IsRetriableException(ex, 1); + _taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(100), Arg.Any()); + + _executeRequest.Invoke(Arg.Any()); + _responseClassifier.IsRetriableException(ex, 2); + _taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(200), Arg.Any()); + + _executeRequest.Invoke(Arg.Any()); + _responseClassifier.IsRetriableException(ex, 3); + _taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(400), Arg.Any()); + + _executeRequest.Invoke(Arg.Any()); + }); + } + + [Fact(Timeout = 1000)] + public async Task Run_ShouldHandleNullMaxRetries() + { + const int expectedIterations = 20; + var ex = new TestShopifyException(); + var request = new TestCloneableRequestMessage(); + var iteration = 0; + + _executeRequest.When(x => x.Invoke(Arg.Any())) + .Do(_ => + { + iteration++; + // Cancel after 20 loops + if (iteration == expectedIterations) + throw new TestException(); + throw ex; + }); + + _responseClassifier.IsRetriableException(ex, Arg.Is(x => x == iteration)) + .Returns(true); + + var policy = SetupPolicy(x => + { + x.MaximumRetriesBeforeRequestCancellation = null; + x.MaximumDelayBeforeRequestCancellation = TimeSpan.FromSeconds(5); + }); + var act = () => policy.Run(request, _executeRequest, CancellationToken.None); + + await act.Should().ThrowAsync(); + iteration.Should().Be(expectedIterations); + await _executeRequest.Received(expectedIterations).Invoke(Arg.Any()); + // These will receive one less call, because TestException is not caught by the policy and crashes/exits the run + _responseClassifier.Received(expectedIterations - 1).IsRetriableException(ex, Arg.Is(x => x >= 1 && x <= expectedIterations)); + await _taskScheduler.Received(expectedIterations - 1).DelayAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Run_ShouldRetryUntilMaxRetriesIsReachedThenThrow() + { + const int maximumRetries = 2; + var ex = new TestShopifyException(); + var request = new TestCloneableRequestMessage(); + var iteration = 0; + + _executeRequest.When(x => x.Invoke(Arg.Any())) + .Do(_ => + { + iteration++; + throw ex; + }); + + _responseClassifier.IsRetriableException(ex, Arg.Is(x => x == iteration)) + .Returns(true); + + var policy = SetupPolicy(x => x.MaximumRetriesBeforeRequestCancellation = maximumRetries); + var act = () => policy.Run(request, _executeRequest, CancellationToken.None); + + await act.Should().ThrowAsync() + .Where(x => x.CurrentTry == maximumRetries) + .Where(x => x.MaximumRetries == maximumRetries); + iteration.Should().Be(maximumRetries); + Received.InOrder(() => + { + _executeRequest.Invoke(request); + _responseClassifier.IsRetriableException(ex, 1); + _taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(100), Arg.Any()); + + _executeRequest.Invoke(Arg.Any()); + _responseClassifier.IsRetriableException(ex, 2); + }); + await _taskScheduler.Received(0) + .DelayAsync(TimeSpan.FromMilliseconds(200), Arg.Any()); + } + + [Fact(Timeout = 1000)] + public async Task Run_ShouldIncreaseDelayBetweenRetriesUntilItReachesMaximumDelayBetweenRetries_ThenUseMaximumDelayBetweenRetries() + { + const int backoffInMilliseconds = 50; + const int expectedIterationsAfterReachingMaximum = 3; + var ex = new TestShopifyException(); + var request = new TestCloneableRequestMessage(); + var maximumDelayBetweenRetries = TimeSpan.FromMilliseconds(777); + var currentDelaySeenCount = 0; + + _executeRequest.When(x => x.Invoke(Arg.Any())) + .Throw(ex); + _responseClassifier.IsRetriableException(ex, Arg.Any()) + .Returns(true); + _taskScheduler.When(x => x.DelayAsync(Arg.Any(), Arg.Any())) + .Do(x => + { + var currentDelay = x.Arg(); + // Cancel once the policy starts using the maximum delay multiple times + if (currentDelay == maximumDelayBetweenRetries) + { + currentDelaySeenCount++; + if (currentDelaySeenCount == expectedIterationsAfterReachingMaximum) + { + throw new TestException(); + } + } + }); + + var policy = SetupPolicy(x => + { + x.InitialBackoffInMilliseconds = backoffInMilliseconds; + x.MaximumRetriesBeforeRequestCancellation = null; + x.MaximumDelayBetweenRetries = maximumDelayBetweenRetries; + x.MaximumDelayBeforeRequestCancellation = TimeSpan.FromMinutes(1); + }); + var act = () => policy.Run(request, _executeRequest, CancellationToken.None); + + await act.Should().ThrowAsync(); + Received.InOrder(() => + { + _taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(50), Arg.Any()); + _taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(100), Arg.Any()); + _taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(200), Arg.Any()); + _taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(400), Arg.Any()); + _taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(777), Arg.Any()); + _taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(777), Arg.Any()); + _taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(777), Arg.Any()); + }); + } + + [Fact] + public async Task Run_ShouldRetryUntilMaximumDelayIsReachedThenThrow() + { + // TODO: this test could be improved by using the Microsoft.BCL.TimeProvider package, + // which includes System.Threading.Tasks.TimeProviderThreadingExtensions for creating + // cancellation token sources with a timeout using a TimeProvider. + const int maximumDelayMilliseconds = 0; + const int maximumRetries = 100; + var ex = new TestShopifyException(); + var request = new TestCloneableRequestMessage(); + var timeout = new TimeSpan(maximumDelayMilliseconds); + var inputCancellationToken = CancellationToken.None; + + _executeRequest.When(x => x.Invoke(Arg.Any())) + .Throw(ex); + + _responseClassifier.IsRetriableException(ex, Arg.Any()) + .Returns(true); + + var policy = SetupPolicy(x => + { + x.MaximumDelayBeforeRequestCancellation = timeout; + x.MaximumRetriesBeforeRequestCancellation = maximumRetries; + }); + var act = () => policy.Run(request, _executeRequest, inputCancellationToken); + + await act.Should().ThrowAsync(); + // 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. + Received.InOrder(() => + { + _executeRequest.Invoke(request); + _responseClassifier.IsRetriableException(ex, 1); + _taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(BackoffInMilliseconds), Arg.Any()); + }); + } + + [Fact] + public async Task Run_ShouldThrowWhenCancellationTokenIsCanceled() + { + const int maximumRetries = 100; + var ex = new TestShopifyException(); + var request = new TestCloneableRequestMessage(); + var inputCancellationToken = new CancellationTokenSource(); + var iteration = 0; + + _executeRequest.When(x => x.Invoke(Arg.Any())) + .Do(_ => + { + iteration++; + if (iteration > 1) + inputCancellationToken.Cancel(true); + throw ex; + }); + _responseClassifier.IsRetriableException(ex, Arg.Any()) + .Returns(true); + + var policy = SetupPolicy(x => x.MaximumRetriesBeforeRequestCancellation = maximumRetries); + var act = () => policy.Run(request, _executeRequest, inputCancellationToken.Token); + + await act.Should().ThrowAsync(); + Received.InOrder(() => + { + _executeRequest.Invoke(request); + _responseClassifier.IsRetriableException(ex, 1); + _taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(100), Arg.Any()); + + _executeRequest.Invoke(Arg.Any()); + _responseClassifier.IsRetriableException(ex, 2); + _taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(200), Arg.Any()); + }); + await _taskScheduler.Received(0).DelayAsync(TimeSpan.FromMilliseconds(400), Arg.Any()); + } +} diff --git a/ShopifySharp.Tests/TestClasses/TestCloneableRequestMessage.cs b/ShopifySharp.Tests/TestClasses/TestCloneableRequestMessage.cs new file mode 100644 index 000000000..3fafb5524 --- /dev/null +++ b/ShopifySharp.Tests/TestClasses/TestCloneableRequestMessage.cs @@ -0,0 +1,20 @@ +#nullable enable + +using System; +using System.Net.Http; +using ShopifySharp.Infrastructure; + +namespace ShopifySharp.Tests.TestClasses; + +public class TestCloneableRequestMessage : CloneableRequestMessage +{ + public TestCloneableRequestMessage() + : base( + new Uri("https://github.com/nozzlegear/shopifysharp"), + HttpMethod.Get, + null + ) + { + } +} + diff --git a/ShopifySharp.Tests/TestClasses/TestRequestResult.cs b/ShopifySharp.Tests/TestClasses/TestRequestResult.cs new file mode 100644 index 000000000..79c23c10b --- /dev/null +++ b/ShopifySharp.Tests/TestClasses/TestRequestResult.cs @@ -0,0 +1,22 @@ +#nullable enable + +using System.Net; +using System.Net.Http; + +namespace ShopifySharp.Tests.TestClasses; + +public class TestRequestResult : RequestResult +{ + public TestRequestResult( + T result + ) : base("", + new HttpResponseMessage(HttpStatusCode.OK), + new HttpResponseMessage(HttpStatusCode.OK).Headers, + result, + "", + "", + HttpStatusCode.OK + ) + { + } +} diff --git a/ShopifySharp.Tests/TestClasses/TestShopifyException.cs b/ShopifySharp.Tests/TestClasses/TestShopifyException.cs new file mode 100644 index 000000000..5b56c823c --- /dev/null +++ b/ShopifySharp.Tests/TestClasses/TestShopifyException.cs @@ -0,0 +1,11 @@ +#nullable enable + +namespace ShopifySharp.Tests.TestClasses; + +public class TestShopifyException : ShopifyException +{ + public override string ToString() + { + return "Test Shopify Exception"; + } +} diff --git a/ShopifySharp/Infrastructure/AssemblyInfo.cs b/ShopifySharp/Infrastructure/AssemblyInfo.cs index ae7c217e2..f725edeef 100644 --- a/ShopifySharp/Infrastructure/AssemblyInfo.cs +++ b/ShopifySharp/Infrastructure/AssemblyInfo.cs @@ -1 +1,3 @@ [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ShopifySharp.Tests")] +// DynamicProxyGenAssembly2 is NSubstitute +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/ShopifySharp/Infrastructure/Policies/ExponentialRetry/ExponentialRetryPolicy.cs b/ShopifySharp/Infrastructure/Policies/ExponentialRetry/ExponentialRetryPolicy.cs new file mode 100644 index 000000000..9b913b1f7 --- /dev/null +++ b/ShopifySharp/Infrastructure/Policies/ExponentialRetry/ExponentialRetryPolicy.cs @@ -0,0 +1,107 @@ +#nullable enable +using System; +using System.Threading; +using System.Threading.Tasks; +using ShopifySharp.Infrastructure; +using ShopifySharp.Infrastructure.Policies.ExponentialRetry; +using TaskScheduler = ShopifySharp.Infrastructure.TaskScheduler; + +namespace ShopifySharp; + +/// A request execution policy that retries failed requests with an exponentially increasing delay between each retry. +/// The policy can be configured with a maximum number of retries, a maximum time period before the request should be canceled, +/// or both. +public class ExponentialRetryPolicy : IRequestExecutionPolicy +{ + private readonly ExponentialRetryPolicyOptions _options; + private readonly IResponseClassifier _responseClassifier; + private readonly ITaskScheduler _taskScheduler = new TaskScheduler(); + + // ReSharper disable once MemberCanBePrivate.Global + public ExponentialRetryPolicy(ExponentialRetryPolicyOptions options) + { + options.Validate(); + _options = options; + _responseClassifier = new ResponseClassifier(true, options.MaximumRetriesBeforeRequestCancellation ?? int.MaxValue); + } + + internal ExponentialRetryPolicy( + ExponentialRetryPolicyOptions options, + IResponseClassifier responseClassifier, + ITaskScheduler taskScheduler + ) : this(options) + { + _responseClassifier = responseClassifier; + _taskScheduler = taskScheduler; + } + + public async Task> Run( + CloneableRequestMessage requestMessage, + ExecuteRequestAsync executeRequestAsync, + CancellationToken cancellationToken, + int? graphqlQueryCost = null) + { + var currentTry = 1; + var useMaximumDelayBetweenRetries = false; + var clonedRequestMessage = requestMessage; + var combinedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + if (_options.MaximumDelayBeforeRequestCancellation is not null) + combinedCancellationToken.CancelAfter(_options.MaximumDelayBeforeRequestCancellation.Value); + + while (true) + { + combinedCancellationToken.Token.ThrowIfCancellationRequested(); + + try + { + var value = await executeRequestAsync.Invoke(clonedRequestMessage); + return value; + } + catch (ShopifyException ex) + { + if (!_responseClassifier.IsRetriableException(ex, currentTry)) + { + throw; + } + } + + if (_options.MaximumRetriesBeforeRequestCancellation is not null && currentTry + 1 > _options.MaximumRetriesBeforeRequestCancellation) + throw new ShopifyExponentialRetryCanceledException(currentTry, _options); + + // 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; + + if (useMaximumDelayBetweenRetries) + { + nextDelay = _options.MaximumDelayBetweenRetries; + } + else + { + try + { + nextDelay = TimeSpan.FromMilliseconds(Math.Pow(2, currentTry - 1) * _options.InitialBackoffInMilliseconds); + + if (nextDelay > _options.MaximumDelayBetweenRetries) + { + nextDelay = _options.MaximumDelayBetweenRetries; + } + } + catch (OverflowException) + { + // TODO: add logging here once ShopifySharp supports it + useMaximumDelayBetweenRetries = true; + nextDelay = _options.MaximumDelayBetweenRetries; + } + } + + currentTry++; + + // Delay and then try again + await _taskScheduler.DelayAsync(nextDelay, combinedCancellationToken.Token); + clonedRequestMessage = await requestMessage.CloneAsync(); + } + } +} diff --git a/ShopifySharp/Infrastructure/Policies/ExponentialRetry/ExponentialRetryPolicyOptions.cs b/ShopifySharp/Infrastructure/Policies/ExponentialRetry/ExponentialRetryPolicyOptions.cs new file mode 100644 index 000000000..9e71d4190 --- /dev/null +++ b/ShopifySharp/Infrastructure/Policies/ExponentialRetry/ExponentialRetryPolicyOptions.cs @@ -0,0 +1,50 @@ +#nullable enable +using System; + +namespace ShopifySharp.Infrastructure.Policies.ExponentialRetry; + +/// +/// Options for configuring the . Note: you must set at least one of either +/// or . +/// If both values are null, the policy will throw an exception when it calls . +/// +public record ExponentialRetryPolicyOptions +{ +#if NET8_0_OR_GREATER + public required int InitialBackoffInMilliseconds { get; set; } + /// 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. + public required TimeSpan MaximumDelayBetweenRetries { get; set; } +#else + public int InitialBackoffInMilliseconds { get; set; } + /// 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. + public TimeSpan MaximumDelayBetweenRetries { get; set; } +#endif + public int? MaximumRetriesBeforeRequestCancellation { get; set; } + public TimeSpan? MaximumDelayBeforeRequestCancellation { get; set; } + + /// + /// Validates this instance and throws an if misconfigured. + /// + public void Validate() + { + if (InitialBackoffInMilliseconds <= 0) + throw new ArgumentException($"You must specify a value greater than zero for {nameof(InitialBackoffInMilliseconds)}."); + + if (MaximumDelayBetweenRetries <= TimeSpan.Zero) + throw new ArgumentException($"You must specify a {nameof(TimeSpan)} value greater than zero for {nameof(MaximumDelayBetweenRetries)}."); + + if (MaximumRetriesBeforeRequestCancellation is null && MaximumDelayBeforeRequestCancellation is null) + throw new ArgumentException($"You must specify at least one of {nameof(MaximumRetriesBeforeRequestCancellation)} or {nameof(MaximumDelayBeforeRequestCancellation)}."); + } + + public static ExponentialRetryPolicyOptions Default() => + new() + { + MaximumRetriesBeforeRequestCancellation = 10, + MaximumDelayBetweenRetries = TimeSpan.FromSeconds(1), + MaximumDelayBeforeRequestCancellation = TimeSpan.FromSeconds(5), + InitialBackoffInMilliseconds = 100, + }; +} diff --git a/ShopifySharp/Infrastructure/ShopifyExponentialRetryCanceledException.cs b/ShopifySharp/Infrastructure/ShopifyExponentialRetryCanceledException.cs new file mode 100644 index 000000000..01c8b8493 --- /dev/null +++ b/ShopifySharp/Infrastructure/ShopifyExponentialRetryCanceledException.cs @@ -0,0 +1,17 @@ +#nullable enable +using System; +using ShopifySharp.Infrastructure.Policies.ExponentialRetry; + +namespace ShopifySharp; + +[Serializable] +public class ShopifyExponentialRetryCanceledException( + int currentTry, + ExponentialRetryPolicyOptions options +) : ShopifyException +{ + public int CurrentTry { get; } = currentTry; + public int BackoffInMilliseconds { get; } = options.InitialBackoffInMilliseconds; + public int? MaximumRetries { get; } = options.MaximumRetriesBeforeRequestCancellation; + public TimeSpan? MaximumDelayBeforeTimeout { get; } = options.MaximumDelayBeforeRequestCancellation; +} diff --git a/ShopifySharp/Infrastructure/TaskScheduler.cs b/ShopifySharp/Infrastructure/TaskScheduler.cs new file mode 100644 index 000000000..75d4a362e --- /dev/null +++ b/ShopifySharp/Infrastructure/TaskScheduler.cs @@ -0,0 +1,18 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace ShopifySharp.Infrastructure; + +internal interface ITaskScheduler +{ + /// + public Task DelayAsync(TimeSpan length, CancellationToken cancellationToken = default); +} + +/// A tiny utility that wraps so that it can be mocked in unit tests. +internal class TaskScheduler : ITaskScheduler +{ + public Task DelayAsync(TimeSpan length, CancellationToken cancellationToken = default) => + Task.Delay(length, cancellationToken); +}