From 589f50b25f4e53840545d28d75efade6349f85f4 Mon Sep 17 00:00:00 2001 From: Jonas Dellinger Date: Tue, 12 May 2020 17:53:17 +0200 Subject: [PATCH] Added SimpleRetryHandler --- .../Http/SimpleRetryHandlerTest.cs | 170 ++++++++++++++++++ SpotifyAPI.Web/Http/SimpleRetryHandler.cs | 95 ++++++++++ SpotifyAPI.Web/Util/Ensure.cs | 2 +- 3 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 SpotifyAPI.Web.Tests/Http/SimpleRetryHandlerTest.cs create mode 100644 SpotifyAPI.Web/Http/SimpleRetryHandler.cs diff --git a/SpotifyAPI.Web.Tests/Http/SimpleRetryHandlerTest.cs b/SpotifyAPI.Web.Tests/Http/SimpleRetryHandlerTest.cs new file mode 100644 index 00000000..404fa257 --- /dev/null +++ b/SpotifyAPI.Web.Tests/Http/SimpleRetryHandlerTest.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using SpotifyAPI.Web.Http; + +namespace SpotifyAPI.Web +{ + [TestFixture] + public class SimpleRetryHandlerTest + { + [Test] + public async Task HandleRetry_TooManyRequestsWithNoSuccess() + { + var sleep = new Mock>(); + + var request = new Mock(); + var initialResponse = new Mock(); + initialResponse.SetupGet(r => r.StatusCode).Returns(HttpStatusCode.TooManyRequests); + initialResponse.SetupGet(r => r.Headers).Returns(new Dictionary { + { "Retry-After", "50" } + }); + + var retryCalled = 0; + Task retry(IRequest request) + { + retryCalled++; + return Task.FromResult(initialResponse.Object); + } + + var handler = new SimpleRetryHandler(sleep.Object) + { + TooManyRequestsConsumesARetry = true, + RetryTimes = 2 + }; + var response = await handler.HandleRetry(request.Object, initialResponse.Object, retry); + + Assert.AreEqual(2, retryCalled); + Assert.AreEqual(initialResponse.Object, response); + sleep.Verify(s => s(50000), Times.Exactly(2)); + + } + + [Test] + public async Task HandleRetry_TooManyRetriesWithSuccess() + { + var sleep = new Mock>(); + + var request = new Mock(); + var initialResponse = new Mock(); + initialResponse.SetupGet(r => r.StatusCode).Returns(HttpStatusCode.TooManyRequests); + initialResponse.SetupGet(r => r.Headers).Returns(new Dictionary { + { "Retry-After", "50" } + }); + var successResponse = new Mock(); + successResponse.SetupGet(r => r.StatusCode).Returns(HttpStatusCode.OK); + + var retryCalled = 0; + Task retry(IRequest request) + { + retryCalled++; + return Task.FromResult(successResponse.Object); + } + + var handler = new SimpleRetryHandler(sleep.Object) + { + TooManyRequestsConsumesARetry = true, + RetryTimes = 10 + }; + var response = await handler.HandleRetry(request.Object, initialResponse.Object, retry); + + Assert.AreEqual(1, retryCalled); + Assert.AreEqual(successResponse.Object, response); + sleep.Verify(s => s(50000), Times.Once); + } + + [Test] + public async Task HandleRetry_TooManyRetriesWithSuccessNoConsume() + { + var sleep = new Mock>(); + + var request = new Mock(); + var initialResponse = new Mock(); + initialResponse.SetupGet(r => r.StatusCode).Returns(HttpStatusCode.TooManyRequests); + initialResponse.SetupGet(r => r.Headers).Returns(new Dictionary { + { "Retry-After", "50" } + }); + var successResponse = new Mock(); + successResponse.SetupGet(r => r.StatusCode).Returns(HttpStatusCode.OK); + + var retryCalled = 0; + Task retry(IRequest request) + { + retryCalled++; + return Task.FromResult(successResponse.Object); + } + + var handler = new SimpleRetryHandler(sleep.Object) + { + TooManyRequestsConsumesARetry = false, + RetryTimes = 0 + }; + var response = await handler.HandleRetry(request.Object, initialResponse.Object, retry); + + Assert.AreEqual(1, retryCalled); + Assert.AreEqual(successResponse.Object, response); + sleep.Verify(s => s(50000), Times.Once); + } + + [Test] + public async Task HandleRetry_ServerErrors() + { + var sleep = new Mock>(); + + var request = new Mock(); + var initialResponse = new Mock(); + initialResponse.SetupGet(r => r.StatusCode).Returns(HttpStatusCode.BadGateway); + + var retryCalled = 0; + Task retry(IRequest request) + { + retryCalled++; + return Task.FromResult(initialResponse.Object); + } + + var handler = new SimpleRetryHandler(sleep.Object) + { + TooManyRequestsConsumesARetry = true, + RetryTimes = 10, + RetryAfter = 50 + }; + var response = await handler.HandleRetry(request.Object, initialResponse.Object, retry); + + Assert.AreEqual(10, retryCalled); + Assert.AreEqual(initialResponse.Object, response); + sleep.Verify(s => s(50), Times.Exactly(10)); + } + + [Test] + public async Task HandleRetry_DirectSuccess() + { + var sleep = new Mock>(); + + var request = new Mock(); + var initialResponse = new Mock(); + initialResponse.SetupGet(r => r.StatusCode).Returns(HttpStatusCode.OK); + + var retryCalled = 0; + Task retry(IRequest request) + { + retryCalled++; + return Task.FromResult(initialResponse.Object); + } + + var handler = new SimpleRetryHandler(sleep.Object) + { + TooManyRequestsConsumesARetry = true, + RetryTimes = 10, + RetryAfter = 50 + }; + var response = await handler.HandleRetry(request.Object, initialResponse.Object, retry); + + Assert.AreEqual(0, retryCalled); + Assert.AreEqual(initialResponse.Object, response); + sleep.Verify(s => s(50), Times.Exactly(0)); + } + } +} diff --git a/SpotifyAPI.Web/Http/SimpleRetryHandler.cs b/SpotifyAPI.Web/Http/SimpleRetryHandler.cs new file mode 100644 index 00000000..53ab8fb4 --- /dev/null +++ b/SpotifyAPI.Web/Http/SimpleRetryHandler.cs @@ -0,0 +1,95 @@ +using System.Linq; +using System.Net; +using System; +using System.Threading.Tasks; +using System.Collections.Generic; + +namespace SpotifyAPI.Web.Http +{ + public class SimpleRetryHandler : IRetryHandler + { + private readonly Func _sleep; + + /// + /// Specifies after how many miliseconds should a failed request be retried. + /// + public int RetryAfter { get; set; } + + /// + /// Maximum number of tries for one failed request. + /// + public int RetryTimes { get; set; } + + /// + /// Whether a failure of type "Too Many Requests" should use up one of the allocated retry attempts. + /// + public bool TooManyRequestsConsumesARetry { get; set; } + + /// + /// Error codes that will trigger auto-retry + /// + public IEnumerable RetryErrorCodes { get; set; } + + /// + /// A simple retry handler which retries a request based on status codes with a fixed sleep interval. + /// It also supports Retry-After headers sent by spotify. The execution will be delayed by the amount in + /// the Retry-After header + /// + /// + public SimpleRetryHandler() : this(Task.Delay) { } + public SimpleRetryHandler(Func sleep) + { + _sleep = sleep; + RetryAfter = 50; + RetryTimes = 10; + TooManyRequestsConsumesARetry = false; + RetryErrorCodes = new[] { + HttpStatusCode.InternalServerError, + HttpStatusCode.BadGateway, + HttpStatusCode.ServiceUnavailable + }; + } + + private static int? ParseTooManyRetriesToMs(IResponse response) + { + if (response.StatusCode != HttpStatusCode.TooManyRequests) + { + return null; + } + if (int.TryParse(response.Headers["Retry-After"], out int secondsToWait)) + { + return secondsToWait * 1000; + } + + throw new APIException("429 received, but unable to parse Retry-After Header. This should not happen!"); + } + + public Task HandleRetry(IRequest request, IResponse response, Func> retry) + { + Ensure.ArgumentNotNull(response, nameof(response)); + + return HandleRetryInternally(request, response, retry, RetryTimes); + } + + private async Task HandleRetryInternally(IRequest request, IResponse response, Func> retry, int triesLeft) + { + var secondsToWait = ParseTooManyRetriesToMs(response); + if (secondsToWait != null && (!TooManyRequestsConsumesARetry || triesLeft > 0)) + { + await _sleep(secondsToWait.Value).ConfigureAwait(false); + response = await retry(request).ConfigureAwait(false); + var newTriesLeft = TooManyRequestsConsumesARetry ? triesLeft - 1 : triesLeft; + return await HandleRetryInternally(request, response, retry, newTriesLeft).ConfigureAwait(false); + } + + while (RetryErrorCodes.Contains(response.StatusCode) && triesLeft > 0) + { + await _sleep(RetryAfter).ConfigureAwait(false); + response = await retry(request).ConfigureAwait(false); + return await HandleRetryInternally(request, response, retry, triesLeft - 1).ConfigureAwait(false); + } + + return response; + } + } +} diff --git a/SpotifyAPI.Web/Util/Ensure.cs b/SpotifyAPI.Web/Util/Ensure.cs index fad3839c..5b8e46ba 100644 --- a/SpotifyAPI.Web/Util/Ensure.cs +++ b/SpotifyAPI.Web/Util/Ensure.cs @@ -39,7 +39,7 @@ namespace SpotifyAPI.Web throw new ArgumentException("String is empty or null", name); } - public static void ArgumentNotNullOrEmptyList(IList value, string name) + public static void ArgumentNotNullOrEmptyList(IEnumerable value, string name) { if (value != null && value.Any()) {