diff --git a/Kentico.Kontent.Delivery.Rx.Tests/DeliveryObservableProxyTests.cs b/Kentico.Kontent.Delivery.Rx.Tests/DeliveryObservableProxyTests.cs index d7f086f0..a31a8b17 100644 --- a/Kentico.Kontent.Delivery.Rx.Tests/DeliveryObservableProxyTests.cs +++ b/Kentico.Kontent.Delivery.Rx.Tests/DeliveryObservableProxyTests.cs @@ -4,12 +4,12 @@ using System.Linq; using System.Net.Http; using System.Reactive.Linq; +using System.Threading.Tasks; using FakeItEasy; using Kentico.Kontent.Delivery.InlineContentItems; -using Kentico.Kontent.Delivery.ResiliencePolicy; +using Kentico.Kontent.Delivery.RetryPolicy; using Kentico.Kontent.Delivery.StrongTyping; using Microsoft.Extensions.Options; -using Polly; using RichardSzalay.MockHttp; using Xunit; @@ -256,16 +256,18 @@ private IDeliveryClient GetDeliveryClient(Action mockAction) var contentPropertyMapper = new PropertyMapper(); var contentTypeProvider = new CustomTypeProvider(); var modelProvider = new ModelProvider(contentLinkUrlResolver, contentItemsProcessor, contentTypeProvider, contentPropertyMapper); - var resiliencePolicyProvider = A.Fake(); - A.CallTo(() => resiliencePolicyProvider.Policy) - .Returns(Policy.HandleResult(result => true).RetryAsync(deliveryOptions.Value.MaxRetryAttempts)); + var retryPolicy = A.Fake(); + var retryPolicyProvider = A.Fake(); + A.CallTo(() => retryPolicyProvider.GetRetryPolicy()).Returns(retryPolicy); + A.CallTo(() => retryPolicy.ExecuteAsync(A>>._)) + .ReturnsLazily(call => call.GetArgument>>(0)()); var client = new DeliveryClient( deliveryOptions, httpClient, contentLinkUrlResolver, null, modelProvider, - resiliencePolicyProvider, + retryPolicyProvider, contentTypeProvider ); diff --git a/Kentico.Kontent.Delivery.Tests/Builders/DeliveryClient/DeliveryClientBuilderTests.cs b/Kentico.Kontent.Delivery.Tests/Builders/DeliveryClient/DeliveryClientBuilderTests.cs index 7a4debfe..ada7345a 100644 --- a/Kentico.Kontent.Delivery.Tests/Builders/DeliveryClient/DeliveryClientBuilderTests.cs +++ b/Kentico.Kontent.Delivery.Tests/Builders/DeliveryClient/DeliveryClientBuilderTests.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using FakeItEasy; using Kentico.Kontent.Delivery.InlineContentItems; -using Kentico.Kontent.Delivery.ResiliencePolicy; +using Kentico.Kontent.Delivery.RetryPolicy; using Kentico.Kontent.Delivery.Tests.DependencyInjectionFrameworks.Helpers; using RichardSzalay.MockHttp; using Xunit; @@ -49,7 +49,7 @@ public void BuildWithDeliveryOptions_ReturnsDeliveryClientWithDeliveryOptions() public void BuildWithOptionalSteps_ReturnsDeliveryClientWithSetInstances() { var mockModelProvider = A.Fake(); - var mockResiliencePolicyProvider = A.Fake(); + var mockRetryPolicyProvider = A.Fake(); var mockPropertyMapper = A.Fake(); var mockContentLinkUrlResolver = A.Fake(); var mockInlineContentItemsProcessor = A.Fake(); @@ -69,7 +69,7 @@ public void BuildWithOptionalSteps_ReturnsDeliveryClientWithSetInstances() .WithInlineContentItemsResolver(mockAnContentItemsResolver) .WithModelProvider(mockModelProvider) .WithPropertyMapper(mockPropertyMapper) - .WithResiliencePolicyProvider(mockResiliencePolicyProvider) + .WithRetryPolicyProvider(mockRetryPolicyProvider) .WithTypeProvider(mockTypeProvider) .Build(); @@ -78,7 +78,7 @@ public void BuildWithOptionalSteps_ReturnsDeliveryClientWithSetInstances() Assert.Equal(mockInlineContentItemsProcessor, deliveryClient.InlineContentItemsProcessor); Assert.Equal(mockModelProvider, deliveryClient.ModelProvider); Assert.Equal(mockPropertyMapper, deliveryClient.PropertyMapper); - Assert.Equal(mockResiliencePolicyProvider, deliveryClient.ResiliencePolicyProvider); + Assert.Equal(mockRetryPolicyProvider, deliveryClient.RetryPolicyProvider); Assert.Equal(mockTypeProvider, deliveryClient.TypeProvider); Assert.Equal(mockHttp, deliveryClient.HttpClient); } @@ -123,7 +123,7 @@ public void BuildWithOptionalStepsAndCustomProvider_ReturnsDeliveryClientWithSet } [Fact] - public void BuildWithoutOptionalStepts_ReturnsDeliveryClientWithDefaultImplementations() + public void BuildWithoutOptionalSteps_ReturnsDeliveryClientWithDefaultImplementations() { var expectedResolvableInlineContentItemsTypes = new[] { @@ -143,7 +143,7 @@ public void BuildWithoutOptionalStepts_ReturnsDeliveryClientWithDefaultImplement Assert.NotNull(deliveryClient.ContentLinkUrlResolver); Assert.NotNull(deliveryClient.HttpClient); Assert.NotNull(deliveryClient.InlineContentItemsProcessor); - Assert.NotNull(deliveryClient.ResiliencePolicyProvider); + Assert.NotNull(deliveryClient.RetryPolicyProvider); Assert.Equal(expectedResolvableInlineContentItemsTypes, actualResolvableInlineContentItemTypes); } @@ -200,7 +200,7 @@ public void BuildWithOptionsAndNullResiliencePolicyProvider_ThrowsArgumentNullEx { var builderStep = DeliveryClientBuilder.WithProjectId(_guid); - Assert.Throws(() => builderStep.WithResiliencePolicyProvider(null)); + Assert.Throws(() => builderStep.WithRetryPolicyProvider(null)); } [Fact] diff --git a/Kentico.Kontent.Delivery.Tests/Builders/DeliveryOptions/DeliveryOptionsBuilderTests.cs b/Kentico.Kontent.Delivery.Tests/Builders/DeliveryOptions/DeliveryOptionsBuilderTests.cs index ffaf244f..8226593a 100644 --- a/Kentico.Kontent.Delivery.Tests/Builders/DeliveryOptions/DeliveryOptionsBuilderTests.cs +++ b/Kentico.Kontent.Delivery.Tests/Builders/DeliveryOptions/DeliveryOptionsBuilderTests.cs @@ -21,12 +21,12 @@ public void BuildWithProjectIdAndUseProductionApi() var deliveryOptions = DeliveryOptionsBuilder .CreateInstance() .WithProjectId(ProjectId) - .UseProductionApi + .UseProductionApi() .Build(); Assert.Equal(deliveryOptions.ProjectId, ProjectId); Assert.False(deliveryOptions.UsePreviewApi); - Assert.False(deliveryOptions.UseSecuredProductionApi); + Assert.False(deliveryOptions.UseSecureAccess); } [Fact] @@ -49,68 +49,64 @@ public void BuildWithProjectIdAndSecuredProductionApi() var deliveryOptions = DeliveryOptionsBuilder .CreateInstance() .WithProjectId(ProjectId) - .UseSecuredProductionApi(SecuredApiKey) + .UseProductionApi(SecuredApiKey) .Build(); Assert.Equal(ProjectId, deliveryOptions.ProjectId); - Assert.True(deliveryOptions.UseSecuredProductionApi); - Assert.Equal(SecuredApiKey, deliveryOptions.SecuredProductionApiKey); + Assert.True(deliveryOptions.UseSecureAccess); + Assert.Equal(SecuredApiKey, deliveryOptions.SecureAccessApiKey); } - + [Fact] - public void BuildWithMaxRetryAttempts() + public void BuildWithRetryPolicyOptions() { - const int maxRetryAttempts = 10; + var retryOptions = new DefaultRetryPolicyOptions(); var deliveryOptions = DeliveryOptionsBuilder .CreateInstance() .WithProjectId(ProjectId) - .UseProductionApi - .WithMaxRetryAttempts(maxRetryAttempts) + .UseProductionApi() + .WithDefaultRetryPolicyOptions(retryOptions) .Build(); - Assert.Equal(deliveryOptions.MaxRetryAttempts, maxRetryAttempts); + Assert.Equal(deliveryOptions.DefaultRetryPolicyOptions, retryOptions); } [Fact] - public void BuildWithZeroMaxRetryAttemps_ResilienceLogicIsDisabled() + public void BuildWithNullRetryPolicyOptions_ThrowsException() { - const int maxRetryAttempts = 0; - - var deliveryOptions = DeliveryOptionsBuilder + Assert.Throws(() => DeliveryOptionsBuilder .CreateInstance() .WithProjectId(ProjectId) - .UseProductionApi - .WithMaxRetryAttempts(maxRetryAttempts) - .Build(); - - Assert.False(deliveryOptions.EnableResilienceLogic); + .UseProductionApi() + .WithDefaultRetryPolicyOptions(null) + .Build()); } [Fact] - public void BuildWithWaitForLoadingNewContent() + public void BuildWithDisabledRetryLogic() { var deliveryOptions = DeliveryOptionsBuilder .CreateInstance() .WithProjectId(Guid.NewGuid()) - .UseProductionApi - .WaitForLoadingNewContent + .UseProductionApi() + .DisableRetryPolicy() .Build(); - Assert.True(deliveryOptions.WaitForLoadingNewContent); + Assert.False(deliveryOptions.EnableRetryPolicy); } [Fact] - public void BuildWithDisabledResilienceLogic() + public void BuildWithWaitForLoadingNewContent() { var deliveryOptions = DeliveryOptionsBuilder .CreateInstance() .WithProjectId(Guid.NewGuid()) - .UseProductionApi - .DisableResilienceLogic + .UseProductionApi() + .WaitForLoadingNewContent() .Build(); - Assert.False(deliveryOptions.EnableResilienceLogic); + Assert.True(deliveryOptions.WaitForLoadingNewContent); } [Fact] @@ -136,7 +132,7 @@ public void BuildWithCustomEndpointForProductionApi() var deliveryOptions = DeliveryOptionsBuilder .CreateInstance() .WithProjectId(ProjectId) - .UseProductionApi + .UseProductionApi() .WithCustomEndpoint(customEndpoint) .Build(); @@ -168,7 +164,7 @@ public void BuildWithCustomEndpointAsUriForProductionApi() var deliveryOptions = DeliveryOptionsBuilder .CreateInstance() .WithProjectId(ProjectId) - .UseProductionApi + .UseProductionApi() .WithCustomEndpoint(uri) .Build(); diff --git a/Kentico.Kontent.Delivery.Tests/Builders/DeliveryOptions/DeliveryOptionsValidatorTests.cs b/Kentico.Kontent.Delivery.Tests/Builders/DeliveryOptions/DeliveryOptionsValidatorTests.cs index 174b4948..63f69e66 100644 --- a/Kentico.Kontent.Delivery.Tests/Builders/DeliveryOptions/DeliveryOptionsValidatorTests.cs +++ b/Kentico.Kontent.Delivery.Tests/Builders/DeliveryOptions/DeliveryOptionsValidatorTests.cs @@ -8,17 +8,77 @@ public class DeliveryOptionsValidatorTests private readonly Guid _guid = Guid.NewGuid(); [Fact] - public void ValidateOptionsWithNegativeMaxRetryAttempts() + public void ValidateRetryOptions_NegativeDeltaBackoff_Throws() { var deliveryOptions = new Delivery.DeliveryOptions { ProjectId = _guid.ToString(), - MaxRetryAttempts = -10 + DefaultRetryPolicyOptions = new DefaultRetryPolicyOptions + { + DeltaBackoff = TimeSpan.FromSeconds(-1) + } }; Assert.Throws(() => deliveryOptions.Validate()); } + [Fact] + public void ValidateRetryOptions_ZeroDeltaBackoff_Throws() + { + var deliveryOptions = new Delivery.DeliveryOptions + { + ProjectId = _guid.ToString(), + DefaultRetryPolicyOptions = new DefaultRetryPolicyOptions + { + DeltaBackoff = TimeSpan.Zero + } + }; + + Assert.Throws(() => deliveryOptions.Validate()); + } + + [Fact] + public void ValidateRetryOptions_NegativeMaxCumulativeWaitTime_Throws() + { + var deliveryOptions = new Delivery.DeliveryOptions + { + ProjectId = _guid.ToString(), + DefaultRetryPolicyOptions = new DefaultRetryPolicyOptions + { + MaxCumulativeWaitTime = TimeSpan.FromSeconds(-1) + } + }; + + Assert.Throws(() => deliveryOptions.Validate()); + } + + [Fact] + public void ValidateRetryOptions_ZeroMaxCumulativeWaitTime_Throws() + { + var deliveryOptions = new Delivery.DeliveryOptions + { + ProjectId = _guid.ToString(), + DefaultRetryPolicyOptions = new DefaultRetryPolicyOptions + { + MaxCumulativeWaitTime = TimeSpan.Zero + } + }; + + Assert.Throws(() => deliveryOptions.Validate()); + } + + [Fact] + public void ValidateNullRetryOptions_Throws() + { + var deliveryOptions = new Delivery.DeliveryOptions + { + ProjectId = _guid.ToString(), + DefaultRetryPolicyOptions = null + }; + + Assert.Throws(() => deliveryOptions.Validate()); + } + [Fact] public void ValidateOptionsWithEmptyProjectId() { @@ -62,7 +122,7 @@ public void ValidateOptionsWithNullSecuredApiKey() .CreateInstance() .WithProjectId(_guid); - Assert.Throws(() => deliveryOptionsStep.UseSecuredProductionApi(null)); + Assert.Throws(() => deliveryOptionsStep.UseProductionApi(null)); } [Fact] @@ -86,8 +146,8 @@ public void ValidateOptionsUseOfPreviewAndProductionApiSimultaneously() ProjectId = _guid.ToString(), UsePreviewApi = true, PreviewApiKey = previewApiKey, - UseSecuredProductionApi = true, - SecuredProductionApiKey = productionApiKey + UseSecureAccess = true, + SecureAccessApiKey = productionApiKey }; Assert.Throws(() => deliveryOptions.Validate()); @@ -111,7 +171,7 @@ public void ValidateOptionsWithEnabledSecuredApiWithSetKey() var deliveryOptions = new Delivery.DeliveryOptions { ProjectId = _guid.ToString(), - UseSecuredProductionApi = true + UseSecureAccess = true }; Assert.Throws(() => deliveryOptions.Validate()); @@ -126,7 +186,7 @@ public void ValidateOptionsWithInvalidEndpointFormat(string endpoint) var deliveryOptionsSteps = DeliveryOptionsBuilder .CreateInstance() .WithProjectId(_guid) - .UseProductionApi; + .UseProductionApi(); Assert.Throws(() => deliveryOptionsSteps.WithCustomEndpoint(endpoint)); } @@ -137,7 +197,7 @@ public void ValidateOptionsWithNullUriEndpoint() var deliveryOptionsSteps = DeliveryOptionsBuilder .CreateInstance() .WithProjectId(_guid) - .UseProductionApi; + .UseProductionApi(); Assert.Throws(() => deliveryOptionsSteps.WithCustomEndpoint((Uri)null)); } @@ -149,7 +209,7 @@ public void ValidateOptionsWithUriEndpointWrongScheme() var deliveryOptionsSteps = DeliveryOptionsBuilder .CreateInstance() .WithProjectId(_guid) - .UseProductionApi; + .UseProductionApi(); Assert.Throws(() => deliveryOptionsSteps.WithCustomEndpoint(incorrectSchemeUri)); } @@ -161,7 +221,7 @@ public void ValidateOptionsWithRelativeUriEndpoint() var deliveryOptionsSteps = DeliveryOptionsBuilder .CreateInstance() .WithProjectId(_guid) - .UseProductionApi; + .UseProductionApi(); Assert.Throws(() => deliveryOptionsSteps.WithCustomEndpoint(relativeUri)); } diff --git a/Kentico.Kontent.Delivery.Tests/ContentLinkResolverTests.cs b/Kentico.Kontent.Delivery.Tests/ContentLinkResolverTests.cs index 3fa96b97..1d047a72 100644 --- a/Kentico.Kontent.Delivery.Tests/ContentLinkResolverTests.cs +++ b/Kentico.Kontent.Delivery.Tests/ContentLinkResolverTests.cs @@ -3,7 +3,7 @@ using System; using System.IO; using Kentico.Kontent.Delivery.ContentLinks; -using Kentico.Kontent.Delivery.ResiliencePolicy; +using Kentico.Kontent.Delivery.RetryPolicy; using Kentico.Kontent.Delivery.StrongTyping; using Kentico.Kontent.Delivery.Tests.Factories; using Microsoft.Extensions.Options; @@ -124,7 +124,7 @@ public async void ResolveLinksInStronglyTypedModel() var deliveryOptions = Options.Create(new DeliveryOptions { ProjectId = guid }); var httpClient = mockHttp.ToHttpClient(); - var resiliencePolicyProvider = new DefaultResiliencePolicyProvider(deliveryOptions); + var resiliencePolicyProvider = new DefaultRetryPolicyProvider(deliveryOptions); var contentLinkUrlResolver = new CustomContentLinkUrlResolver(); var contentItemsProcessor = InlineContentItemsProcessorFactory.Create(); var modelProvider= new ModelProvider(contentLinkUrlResolver, contentItemsProcessor, new CustomTypeProvider(), new PropertyMapper()); diff --git a/Kentico.Kontent.Delivery.Tests/DeliveryClientTests.cs b/Kentico.Kontent.Delivery.Tests/DeliveryClientTests.cs index a8e10dd1..fb32dcb7 100644 --- a/Kentico.Kontent.Delivery.Tests/DeliveryClientTests.cs +++ b/Kentico.Kontent.Delivery.Tests/DeliveryClientTests.cs @@ -1,6 +1,5 @@ using FakeItEasy; using Kentico.Kontent.Delivery.Tests.Factories; -using Polly; using RichardSzalay.MockHttp; using System; using System.Collections.Generic; @@ -9,6 +8,8 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Threading.Tasks; +using Kentico.Kontent.Delivery.RetryPolicy; using Kentico.Kontent.Delivery.StrongTyping; using Xunit; @@ -553,9 +554,6 @@ public void GetStronglyTypedGenericWithAttributesResponse() A.CallTo(() => _mockTypeProvider.GetType("complete_content_type")) .ReturnsLazily(() => typeof(ContentItemModelWithAttributes)); A.CallTo(() => _mockTypeProvider.GetType("homepage")).ReturnsLazily(() => typeof(Homepage)); - A.CallTo(() => client.ResiliencePolicyProvider.Policy) - .Returns(Policy.HandleResult(result => true) - .RetryAsync(client.DeliveryOptions.MaxRetryAttempts)); ContentItemModelWithAttributes item = (ContentItemModelWithAttributes)client.GetItemAsync("complete_content_item").Result.Item; @@ -795,10 +793,11 @@ public void LongUrl() .Respond("application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}DeliveryClient{Path.DirectorySeparatorChar}items.json"))); var client = DeliveryClientFactory.GetMockedDeliveryClientWithProjectId(_guid, _mockHttp); - - A.CallTo(() => client.ResiliencePolicyProvider.Policy) - .Returns(Policy.HandleResult(result => true) - .RetryAsync(client.DeliveryOptions.MaxRetryAttempts)); + var retryPolicy = A.Fake(); + A.CallTo(() => client.RetryPolicyProvider.GetRetryPolicy()) + .Returns(retryPolicy); + A.CallTo(() => retryPolicy.ExecuteAsync(A>>._)) + .ReturnsLazily(c => c.GetArgument>>(0)()); var elements = new ElementsParameter(Enumerable.Range(0, 1000).Select(i => "test").ToArray()); var inFilter = new InFilter("test", Enumerable.Range(0, 1000).Select(i => "test").ToArray()); @@ -852,16 +851,18 @@ public async void PreviewAndSecuredProductionThrowsWhenBothEnabled(bool usePrevi { ProjectId = _guid.ToString(), UsePreviewApi = usePreviewApi, - UseSecuredProductionApi = useSecuredProduction, + UseSecureAccess = useSecuredProduction, PreviewApiKey = "someKey", - SecuredProductionApiKey = "someKey" + SecureAccessApiKey = "someKey" }; var client = DeliveryClientFactory.GetMockedDeliveryClientWithOptions(options, _mockHttp); - A.CallTo(() => client.ResiliencePolicyProvider.Policy) - .Returns(Policy.HandleResult(result => true) - .RetryAsync(client.DeliveryOptions.MaxRetryAttempts)); + var retryPolicy = A.Fake(); + A.CallTo(() => client.RetryPolicyProvider.GetRetryPolicy()) + .Returns(retryPolicy); + A.CallTo(() => retryPolicy.ExecuteAsync(A>>._)) + .ReturnsLazily(c => c.GetArgument>>(0)()); if (usePreviewApi && useSecuredProduction) { @@ -884,8 +885,8 @@ public async void SecuredProductionAddCorrectHeader() var options = new DeliveryOptions { ProjectId = _guid.ToString(), - SecuredProductionApiKey = securityKey, - UseSecuredProductionApi = true + SecureAccessApiKey = securityKey, + UseSecureAccess = true }; _mockHttp .Expect($"{_baseUrl}/items") @@ -894,160 +895,16 @@ public async void SecuredProductionAddCorrectHeader() var client = DeliveryClientFactory.GetMockedDeliveryClientWithOptions(options, _mockHttp); - A.CallTo(() => client.ResiliencePolicyProvider.Policy) - .Returns(Policy.HandleResult(result => false) - .RetryAsync(client.DeliveryOptions.MaxRetryAttempts)); + var retryPolicy = A.Fake(); + A.CallTo(() => client.RetryPolicyProvider.GetRetryPolicy()) + .Returns(retryPolicy); + A.CallTo(() => retryPolicy.ExecuteAsync(A>>._)) + .ReturnsLazily(c => c.GetArgument>>(0)()); await client.GetItemsAsync(); _mockHttp.VerifyNoOutstandingExpectation(); } - [Fact] - public async void Retries_WithDefaultSettings_Retries() - { - var actualHttpRequestCount = 0; - var retryAttempts = 4; - var expectedRetryAttempts = retryAttempts + 1; - - _mockHttp - .When($"{_baseUrl}/items") - .Respond((request) => - GetResponseAndLogRequest(HttpStatusCode.RequestTimeout, ref actualHttpRequestCount)); - - var client = DeliveryClientFactory.GetMockedDeliveryClientWithProjectId(_guid, _mockHttp); - - A.CallTo(() => client.ResiliencePolicyProvider.Policy) - .Returns(Policy.HandleResult(result => true).RetryAsync(retryAttempts)); - - await Assert.ThrowsAsync(async () => await client.GetItemsAsync()); - Assert.Equal(expectedRetryAttempts, actualHttpRequestCount); - } - - [Fact] - public async void Retries_EnableResilienceLogicDisabled_DoesNotRetry() - { - var actualHttpRequestCount = 0; - - _mockHttp - .When($"{_baseUrl}/items") - .Respond((request) => - GetResponseAndLogRequest(HttpStatusCode.RequestTimeout, ref actualHttpRequestCount)); - - var options = new DeliveryOptions - { - ProjectId = _guid.ToString(), - EnableResilienceLogic = false - }; - var client = DeliveryClientFactory.GetMockedDeliveryClientWithOptions(options, _mockHttp); - - await Assert.ThrowsAsync(async () => await client.GetItemsAsync()); - Assert.Equal(1, actualHttpRequestCount); - } - - [Fact] - public async void Retries_WithMaxRetrySet_SettingReflected() - { - int retryAttempts = 3; - int expectedAttempts = retryAttempts + 1; - int actualHttpRequestCount = 0; - - _mockHttp - .When($"{_baseUrl}/items") - .Respond((request) => - GetResponseAndLogRequest(HttpStatusCode.RequestTimeout, ref actualHttpRequestCount)); - - var options = new DeliveryOptions - { - ProjectId = _guid.ToString(), - MaxRetryAttempts = retryAttempts - }; - var client = DeliveryClientFactory.GetMockedDeliveryClientWithOptions(options, _mockHttp); - - A.CallTo(() => client.ResiliencePolicyProvider.Policy) - .Returns(Policy.HandleResult(result => true).RetryAsync(retryAttempts)); - - await Assert.ThrowsAsync(async () => await client.GetItemsAsync()); - - Assert.Equal(expectedAttempts, actualHttpRequestCount); - } - - [Fact] - public async void Retries_WithCustomResilencePolicy_PolicyUsed() - { - int retryAttempts = 1; - int expectedAttepts = retryAttempts + 1; - int actualHttpRequestCount = 0; - - _mockHttp - .When($"{_baseUrl}/items") - .Respond((request) => - GetResponseAndLogRequest(HttpStatusCode.NotImplemented, ref actualHttpRequestCount)); - - var client = DeliveryClientFactory.GetMockedDeliveryClientWithProjectId(_guid, _mockHttp); - - A.CallTo(() => client.ResiliencePolicyProvider.Policy) - .Returns(Policy.HandleResult(result => true).RetryAsync(retryAttempts)); - - await Assert.ThrowsAsync(async () => await client.GetItemsAsync()); - - A.CallTo(() => client.ResiliencePolicyProvider.Policy).MustHaveHappened(); - Assert.Equal(expectedAttepts, actualHttpRequestCount); - } - - [Fact] - public async void Retries_WithCustomResilencePolicyAndPolicyDisabled_PolicyIgnored() - { - int policyRetryAttempts = 2; - int expectedAttepts = 1; - int actualHttpRequestCount = 0; - - _mockHttp - .When($"{_baseUrl}/items") - .Respond((request) => - GetResponseAndLogRequest(HttpStatusCode.NotImplemented, ref actualHttpRequestCount)); - - var options = new DeliveryOptions() - { - ProjectId = _guid.ToString(), - EnableResilienceLogic = false - }; - var client = DeliveryClientFactory.GetMockedDeliveryClientWithOptions(options, _mockHttp); - - A.CallTo(() => client.ResiliencePolicyProvider.Policy) - .Returns(Policy.HandleResult(result => true).RetryAsync(policyRetryAttempts)); - - await Assert.ThrowsAsync(async () => await client.GetItemsAsync()); - Assert.Equal(expectedAttepts, actualHttpRequestCount); - } - - [Fact] - public async void Retries_WithCustomResilencePolicyWithMaxRetrySet_PolicyUsedMaxRetryIgnored() - { - int policyRetryAttempts = 1; - int expectedAttepts = policyRetryAttempts + 1; - int ignoredRetryAttempt = 3; - int actualHttpRequestCount = 0; - - _mockHttp - .When($"{_baseUrl}/items") - .Respond((request) => - GetResponseAndLogRequest(HttpStatusCode.NotImplemented, ref actualHttpRequestCount)); - var options = new DeliveryOptions - { - ProjectId = _guid.ToString(), - MaxRetryAttempts = ignoredRetryAttempt - }; - var client = DeliveryClientFactory.GetMockedDeliveryClientWithOptions(options, _mockHttp); - - A.CallTo(() => client.ResiliencePolicyProvider.Policy) - .Returns(Policy.HandleResult(result => true).RetryAsync(policyRetryAttempts)); - - await Assert.ThrowsAsync(async () => await client.GetItemsAsync()); - - A.CallTo(() => client.ResiliencePolicyProvider.Policy).MustHaveHappened(); - Assert.Equal(expectedAttepts, actualHttpRequestCount); - } - [Fact] public async void CorrectSdkVersionHeaderAdded() { @@ -1063,9 +920,11 @@ public async void CorrectSdkVersionHeaderAdded() var client = DeliveryClientFactory.GetMockedDeliveryClientWithProjectId(_guid, _mockHttp); - A.CallTo(() => client.ResiliencePolicyProvider.Policy) - .Returns(Policy.HandleResult(result => false) - .RetryAsync(client.DeliveryOptions.MaxRetryAttempts)); + var retryPolicy = A.Fake(); + A.CallTo(() => client.RetryPolicyProvider.GetRetryPolicy()) + .Returns(retryPolicy); + A.CallTo(() => retryPolicy.ExecuteAsync(A>>._)) + .ReturnsLazily(c => c.GetArgument>>(0)()); await client.GetItemsAsync(); @@ -1414,6 +1273,43 @@ public async void GetElementAsync_ApiDoesNotReturnStaleContent_ResponseDoesNotIn Assert.False(response.HasStaleContent); } + [Fact] + public async void RetryPolicy_WithDefaultOptions_Retries() + { + _mockHttp + .When($"{_baseUrl}/items") + .Respond((request) => new HttpResponseMessage(HttpStatusCode.RequestTimeout)); + var client = DeliveryClientFactory.GetMockedDeliveryClientWithProjectId(_guid, _mockHttp); + var retryPolicy = A.Fake(); + A.CallTo(() => client.RetryPolicyProvider.GetRetryPolicy()).Returns(retryPolicy); + A.CallTo(() => retryPolicy.ExecuteAsync(A>>._)) + .ReturnsLazily(c => c.GetArgument>>(0)()); + + await Assert.ThrowsAsync(async () => await client.GetItemsAsync()); + + A.CallTo(() => retryPolicy.ExecuteAsync(A>>._)).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async void RetryPolicy_Disabled_DoesNotRetry() + { + _mockHttp + .When($"{_baseUrl}/items") + .Respond(request => new HttpResponseMessage(HttpStatusCode.RequestTimeout)); + var options = new DeliveryOptions + { + ProjectId = _guid.ToString(), + EnableRetryPolicy = false + }; + var client = DeliveryClientFactory.GetMockedDeliveryClientWithOptions(options, _mockHttp); + var retryPolicy = A.Fake(); + A.CallTo(() => client.RetryPolicyProvider.GetRetryPolicy()).Returns(retryPolicy); + + await Assert.ThrowsAsync(async () => await client.GetItemsAsync()); + + A.CallTo(() => retryPolicy.ExecuteAsync(A>>._)).MustNotHaveHappened(); + } + [Fact] [Trait("Issue", "146")] public async void InitializeMultipleInlineContentItemsResolvers() @@ -1455,9 +1351,11 @@ private DeliveryClient InitializeDeliveryClientWithACustomTypeProvider(MockHttpM modelProvider, typeProvider: customTypeProvider); - A.CallTo(() => client.ResiliencePolicyProvider.Policy) - .Returns(Policy.HandleResult(result => true) - .RetryAsync(client.DeliveryOptions.MaxRetryAttempts)); + var retryPolicy = A.Fake(); + A.CallTo(() => client.RetryPolicyProvider.GetRetryPolicy()) + .Returns(retryPolicy); + A.CallTo(() => retryPolicy.ExecuteAsync(A>>._)) + .ReturnsLazily(c => c.GetArgument>>(0)()); return client; } @@ -1468,9 +1366,11 @@ private DeliveryClient InitializeDeliveryClientWithCustomModelProvider(MockHttpM var modelProvider = new ModelProvider(null, null, _mockTypeProvider, mapper); var client = DeliveryClientFactory.GetMockedDeliveryClientWithProjectId(_guid, handler, modelProvider); - A.CallTo(() => client.ResiliencePolicyProvider.Policy) - .Returns(Policy.HandleResult(result => true) - .RetryAsync(client.DeliveryOptions.MaxRetryAttempts)); + var retryPolicy = A.Fake(); + A.CallTo(() => client.RetryPolicyProvider.GetRetryPolicy()) + .Returns(retryPolicy); + A.CallTo(() => retryPolicy.ExecuteAsync(A>>._)) + .ReturnsLazily(c => c.GetArgument>>(0)()); return client; } diff --git a/Kentico.Kontent.Delivery.Tests/DependencyInjectionFrameworks/DeliveryClientAssertionExtensions.cs b/Kentico.Kontent.Delivery.Tests/DependencyInjectionFrameworks/DeliveryClientAssertionExtensions.cs index 5307e731..b062f109 100644 --- a/Kentico.Kontent.Delivery.Tests/DependencyInjectionFrameworks/DeliveryClientAssertionExtensions.cs +++ b/Kentico.Kontent.Delivery.Tests/DependencyInjectionFrameworks/DeliveryClientAssertionExtensions.cs @@ -1,7 +1,7 @@ using System.Linq; using Kentico.Kontent.Delivery.ContentLinks; using Kentico.Kontent.Delivery.InlineContentItems; -using Kentico.Kontent.Delivery.ResiliencePolicy; +using Kentico.Kontent.Delivery.RetryPolicy; using Kentico.Kontent.Delivery.StrongTyping; using Xunit; @@ -31,7 +31,7 @@ private static void AssertDefaultDependenciesWithCustomModelProvider(client.TypeProvider); Assert.IsType(client.ContentLinkUrlResolver); Assert.IsType(client.InlineContentItemsProcessor); - Assert.IsType(client.ResiliencePolicyProvider); + Assert.IsType(client.RetryPolicyProvider); Assert.IsType(client.DeliveryOptions); Assert.IsType(client.ModelProvider); } diff --git a/Kentico.Kontent.Delivery.Tests/Extensions/ServiceCollectionsExtensionsTests.cs b/Kentico.Kontent.Delivery.Tests/Extensions/ServiceCollectionsExtensionsTests.cs index 11b8ee73..df5bcf96 100644 --- a/Kentico.Kontent.Delivery.Tests/Extensions/ServiceCollectionsExtensionsTests.cs +++ b/Kentico.Kontent.Delivery.Tests/Extensions/ServiceCollectionsExtensionsTests.cs @@ -6,7 +6,7 @@ using System.Net.Http; using Kentico.Kontent.Delivery.ContentLinks; using Kentico.Kontent.Delivery.InlineContentItems; -using Kentico.Kontent.Delivery.ResiliencePolicy; +using Kentico.Kontent.Delivery.RetryPolicy; using Kentico.Kontent.Delivery.StrongTyping; using Kentico.Kontent.Delivery.Tests.Factories; using Microsoft.Extensions.Configuration; @@ -48,7 +48,7 @@ public class ServiceCollectionsExtensionsTests { typeof(IInlineContentItemsResolver), typeof(ReplaceWithWarningAboutUnknownItemResolver) }, { typeof(IModelProvider), typeof(ModelProvider) }, { typeof(IPropertyMapper), typeof(PropertyMapper) }, - { typeof(IResiliencePolicyProvider), typeof(DefaultResiliencePolicyProvider) }, + { typeof(IRetryPolicyProvider), typeof(DefaultRetryPolicyProvider) }, { typeof(IDeliveryClient), typeof(DeliveryClient) } } ); @@ -72,7 +72,7 @@ public void AddDeliveryClientWithDeliveryOptions_AllServicesAreRegistered() public void AddDeliveryClientWithProjectId_AllServicesAreRegistered() { _fakeServiceCollection.AddDeliveryClient(builder => - builder.WithProjectId(ProjectId).UseProductionApi.Build()); + builder.WithProjectId(ProjectId).UseProductionApi().Build()); AssertDefaultServiceCollection(_expectedInterfacesWithImplementationTypes, _expectedResolvableContentTypes); } diff --git a/Kentico.Kontent.Delivery.Tests/Factories/DeliveryClientFactory.cs b/Kentico.Kontent.Delivery.Tests/Factories/DeliveryClientFactory.cs index 27784102..9d2d471c 100644 --- a/Kentico.Kontent.Delivery.Tests/Factories/DeliveryClientFactory.cs +++ b/Kentico.Kontent.Delivery.Tests/Factories/DeliveryClientFactory.cs @@ -1,7 +1,7 @@ using System; using FakeItEasy; using Kentico.Kontent.Delivery.InlineContentItems; -using Kentico.Kontent.Delivery.ResiliencePolicy; +using Kentico.Kontent.Delivery.RetryPolicy; using Microsoft.Extensions.Options; using RichardSzalay.MockHttp; @@ -12,7 +12,7 @@ internal static class DeliveryClientFactory private static readonly MockHttpMessageHandler MockHttp = new MockHttpMessageHandler(); private static IModelProvider _mockModelProvider = A.Fake(); private static IPropertyMapper _mockPropertyMapper = A.Fake(); - private static IResiliencePolicyProvider _mockResiliencePolicyProvider = A.Fake(); + private static IRetryPolicyProvider _mockResiliencePolicyProvider = A.Fake(); private static ITypeProvider _mockTypeProvider = A.Fake(); private static IContentLinkUrlResolver _mockContentLinkUrlResolver = A.Fake(); private static IInlineContentItemsProcessor _mockInlineContentItemsProcessor = A.Fake(); @@ -22,7 +22,7 @@ internal static DeliveryClient GetMockedDeliveryClientWithProjectId( MockHttpMessageHandler httpMessageHandler = null, IModelProvider modelProvider = null, IPropertyMapper propertyMapper = null, - IResiliencePolicyProvider resiliencePolicyProvider = null, + IRetryPolicyProvider resiliencePolicyProvider = null, ITypeProvider typeProvider = null, IContentLinkUrlResolver contentLinkUrlResolver = null, IInlineContentItemsProcessor inlineContentItemsProcessor = null diff --git a/Kentico.Kontent.Delivery.Tests/FakeHttpClientTests.cs b/Kentico.Kontent.Delivery.Tests/FakeHttpClientTests.cs index 5ee4b33a..6e9257ca 100644 --- a/Kentico.Kontent.Delivery.Tests/FakeHttpClientTests.cs +++ b/Kentico.Kontent.Delivery.Tests/FakeHttpClientTests.cs @@ -1,9 +1,9 @@ using System; using System.IO; using System.Net.Http; +using System.Threading.Tasks; using FakeItEasy; -using Kentico.Kontent.Delivery.ResiliencePolicy; -using Polly; +using Kentico.Kontent.Delivery.RetryPolicy; using RichardSzalay.MockHttp; using Xunit; @@ -43,7 +43,7 @@ private static DeliveryOptions MockDeliveryOptions(string baseUrl) => DeliveryOptionsBuilder .CreateInstance() .WithProjectId(Guid.NewGuid()) - .UseProductionApi + .UseProductionApi() .WithCustomEndpoint($"{baseUrl}/{{0}}") .Build(); @@ -51,18 +51,19 @@ private static IDeliveryClient MockDeliveryClient(DeliveryOptions deliveryOption { var contentLinkUrlResolver = A.Fake(); var modelProvider = A.Fake(); - var resiliencePolicyProvider = A.Fake(); - A.CallTo(() => resiliencePolicyProvider.Policy) - .Returns(Policy - .HandleResult(result => true) - .RetryAsync(deliveryOptions.MaxRetryAttempts)); + var retryPolicy = A.Fake(); + var retryPolicyProvider = A.Fake(); + A.CallTo(() => retryPolicyProvider.GetRetryPolicy()) + .Returns(retryPolicy); + A.CallTo(() => retryPolicy.ExecuteAsync(A>>._)) + .ReturnsLazily(c => c.GetArgument>>(0)()); var client = DeliveryClientBuilder .WithOptions(_ => deliveryOptions) .WithHttpClient(httpClient) .WithContentLinkUrlResolver(contentLinkUrlResolver) .WithModelProvider(modelProvider) - .WithResiliencePolicyProvider(resiliencePolicyProvider) + .WithRetryPolicyProvider(retryPolicyProvider) .Build(); return client; diff --git a/Kentico.Kontent.Delivery.Tests/RetryPolicy/DefaultRetryPolicyProviderTests.cs b/Kentico.Kontent.Delivery.Tests/RetryPolicy/DefaultRetryPolicyProviderTests.cs new file mode 100644 index 00000000..35340d39 --- /dev/null +++ b/Kentico.Kontent.Delivery.Tests/RetryPolicy/DefaultRetryPolicyProviderTests.cs @@ -0,0 +1,37 @@ +using System; +using Kentico.Kontent.Delivery.RetryPolicy; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Kentico.Kontent.Delivery.Tests.RetryPolicy +{ + public class DefaultRetryPolicyProviderTests + { + [Fact] + public void Constructor_NullRetryPolicyOptions_ThrowsArgumentNullException() + { + var deliveryOptions = Options.Create(new DeliveryOptions + { + DefaultRetryPolicyOptions = null + }); + + Assert.Throws(() => new DefaultRetryPolicyProvider(deliveryOptions)); + } + + [Fact] + public void GetRetryPolicy_ReturnsDefaultPolicy() + { + var deliveryOptions = Options.Create(new DeliveryOptions + { + DefaultRetryPolicyOptions = new DefaultRetryPolicyOptions() + }); + var provider = new DefaultRetryPolicyProvider(deliveryOptions); + + var retryPolicy = provider.GetRetryPolicy(); + + Assert.NotNull(retryPolicy); + Assert.IsType(retryPolicy); + + } + } +} \ No newline at end of file diff --git a/Kentico.Kontent.Delivery.Tests/RetryPolicy/DefaultRetryPolicyTests.cs b/Kentico.Kontent.Delivery.Tests/RetryPolicy/DefaultRetryPolicyTests.cs new file mode 100644 index 00000000..dc8ab8a8 --- /dev/null +++ b/Kentico.Kontent.Delivery.Tests/RetryPolicy/DefaultRetryPolicyTests.cs @@ -0,0 +1,327 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Kentico.Kontent.Delivery.RetryPolicy; +using Xunit; + +namespace Kentico.Kontent.Delivery.Tests.RetryPolicy +{ + public class DefaultRetryPolicyTests + { + [Fact] + public async Task ExecuteAsync_ResponseOk_DoesNotRetry() + { + var options = new DefaultRetryPolicyOptions + { + DeltaBackoff = TimeSpan.FromSeconds(5) + }; + var retryPolicy = new DefaultRetryPolicy(options); + var client = new FakeSender().Returns(HttpStatusCode.OK); + + var response = await retryPolicy.ExecuteAsync(client.SendRequest); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(1, client.TimesCalled); + } + + [Fact] + public async Task ExecuteAsync_RecoversAfterNotSuccessStatusCode() + { + var options = new DefaultRetryPolicyOptions + { + DeltaBackoff = TimeSpan.FromMilliseconds(100) + }; + var retryPolicy = new DefaultRetryPolicy(options); + var client = new FakeSender().Returns(HttpStatusCode.InternalServerError, HttpStatusCode.OK); + + var stopwatch = Stopwatch.StartNew(); + var response = await retryPolicy.ExecuteAsync(client.SendRequest); + stopwatch.Stop(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, client.TimesCalled); + Assert.True(stopwatch.Elapsed > 0.8 * options.DeltaBackoff); + } + + [Fact] + public async Task ExecuteAsync_RecoversAfterException() + { + var options = new DefaultRetryPolicyOptions + { + DeltaBackoff = TimeSpan.FromMilliseconds(100) + }; + var retryPolicy = new DefaultRetryPolicy(options); + var client = new FakeSender() + .Throws(GetExceptionFromStatus(WebExceptionStatus.ConnectionClosed)) + .Returns(HttpStatusCode.OK); + + var stopwatch = Stopwatch.StartNew(); + var response = await retryPolicy.ExecuteAsync(client.SendRequest); + stopwatch.Stop(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, client.TimesCalled); + Assert.True(stopwatch.Elapsed > 0.8 * options.DeltaBackoff); + } + + [Fact] + public async Task ExecuteAsync_UnsuccessfulStatusCode_RetriesUntilCumulativeWaitTimeReached() + { + var options = new DefaultRetryPolicyOptions + { + DeltaBackoff = TimeSpan.FromMilliseconds(100), + MaxCumulativeWaitTime = TimeSpan.FromSeconds(2) + }; + var retryPolicy = new DefaultRetryPolicy(options); + var client = new FakeSender().Returns(HttpStatusCode.InternalServerError); + + var stopwatch = Stopwatch.StartNew(); + var response = await retryPolicy.ExecuteAsync(client.SendRequest); + stopwatch.Stop(); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.True(client.TimesCalled > 1); + var maximumPossibleNextWaitTime = 1.2 * options.DeltaBackoff * Math.Pow(2, client.TimesCalled - 1); + Assert.True(stopwatch.Elapsed > options.MaxCumulativeWaitTime - maximumPossibleNextWaitTime); + } + + [Fact] + public async Task ExecuteAsync_Exception_RetriesUntilCumulativeWaitTimeReached() + { + var options = new DefaultRetryPolicyOptions + { + DeltaBackoff = TimeSpan.FromMilliseconds(100), + MaxCumulativeWaitTime = TimeSpan.FromSeconds(2) + }; + var retryPolicy = new DefaultRetryPolicy(options); + var client = new FakeSender().Throws(GetExceptionFromStatus(WebExceptionStatus.ConnectionClosed)); + + var stopwatch = Stopwatch.StartNew(); + await Assert.ThrowsAsync(() => retryPolicy.ExecuteAsync(client.SendRequest)); + stopwatch.Stop(); + + Assert.True(client.TimesCalled > 1); + var maximumPossibleNextWaitTime = 1.2 * options.DeltaBackoff * Math.Pow(2, client.TimesCalled - 1); + Assert.True(stopwatch.Elapsed > options.MaxCumulativeWaitTime - maximumPossibleNextWaitTime); + } + + [Theory] + [InlineData((HttpStatusCode)429)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task ExecuteAsync_ThrottledRequest_NoHeader_GetsNextWaitTime(HttpStatusCode statusCode) + { + var options = new DefaultRetryPolicyOptions + { + DeltaBackoff = TimeSpan.FromSeconds(10), + MaxCumulativeWaitTime = TimeSpan.FromSeconds(5) + }; + var retryPolicy = new DefaultRetryPolicy(options); + var client = new FakeSender().Returns(statusCode); + + var response = await retryPolicy.ExecuteAsync(client.SendRequest); + + Assert.Equal(statusCode, response.StatusCode); + Assert.Equal(1, client.TimesCalled); + } + + [Theory] + [InlineData((HttpStatusCode)429, -1)] + [InlineData((HttpStatusCode)429, 0)] + [InlineData(HttpStatusCode.ServiceUnavailable, -1)] + [InlineData(HttpStatusCode.ServiceUnavailable, 0)] + public async Task ExecuteAsync_ThrottledRequest_RetryAfterHeaderWithNotPositiveDelta_GetsNextWaitTime(HttpStatusCode statusCode, int waitTimeInSeconds) + { + var options = new DefaultRetryPolicyOptions + { + DeltaBackoff = TimeSpan.FromSeconds(10), + MaxCumulativeWaitTime = TimeSpan.FromSeconds(5) + }; + var retryPolicy = new DefaultRetryPolicy(options); + var mockResponse = new HttpResponseMessage(statusCode); + mockResponse.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(waitTimeInSeconds)); + var client = new FakeSender().Returns(mockResponse); + + var response = await retryPolicy.ExecuteAsync(client.SendRequest); + + Assert.Equal(statusCode, response.StatusCode); + Assert.Equal(1, client.TimesCalled); + } + + [Theory] + [InlineData((HttpStatusCode)429)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task ExecuteAsync_ThrottledRequest_RetryAfterHeaderWithPastDate_GetsNextWaitTime(HttpStatusCode statusCode) + { + var options = new DefaultRetryPolicyOptions + { + DeltaBackoff = TimeSpan.FromSeconds(10), + MaxCumulativeWaitTime = TimeSpan.FromSeconds(5) + }; + var retryPolicy = new DefaultRetryPolicy(options); + var mockResponse = new HttpResponseMessage(statusCode); + mockResponse.Headers.RetryAfter = new RetryConditionHeaderValue(DateTime.UtcNow.AddSeconds(-1)); + var client = new FakeSender().Returns(mockResponse); + + var response = await retryPolicy.ExecuteAsync(client.SendRequest); + + Assert.Equal(statusCode, response.StatusCode); + Assert.Equal(1, client.TimesCalled); + } + + [Theory] + [InlineData((HttpStatusCode)429)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task ExecuteAsync_ThrottledRequest_RetryAfterHeaderWithDelta_ReadsWaitTimeFromHeader(HttpStatusCode statusCode) + { + var options = new DefaultRetryPolicyOptions + { + DeltaBackoff = TimeSpan.FromMilliseconds(100), + MaxCumulativeWaitTime = TimeSpan.FromSeconds(5) + }; + var retryPolicy = new DefaultRetryPolicy(options); + var mockResponse = new HttpResponseMessage(statusCode); + mockResponse.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(6)); + var client = new FakeSender().Returns(mockResponse); + + var response = await retryPolicy.ExecuteAsync(client.SendRequest); + + Assert.Equal(statusCode, response.StatusCode); + Assert.Equal(1, client.TimesCalled); + } + + [Theory] + [InlineData((HttpStatusCode)429)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task ExecuteAsync_ThrottledRequest_RetryAfterHeaderWithDate_ReadsWaitTimeFromHeader(HttpStatusCode statusCode) + { + var options = new DefaultRetryPolicyOptions + { + DeltaBackoff = TimeSpan.FromMilliseconds(100), + MaxCumulativeWaitTime = TimeSpan.FromSeconds(5) + }; + var retryPolicy = new DefaultRetryPolicy(options); + var mockResponse = new HttpResponseMessage(statusCode); + mockResponse.Headers.RetryAfter = new RetryConditionHeaderValue(DateTime.UtcNow.AddSeconds(6)); + var client = new FakeSender().Returns(mockResponse); + + var response = await retryPolicy.ExecuteAsync(client.SendRequest); + + Assert.Equal(statusCode, response.StatusCode); + Assert.Equal(1, client.TimesCalled); + } + + [Theory] + [InlineData(HttpStatusCode.RequestTimeout)] + [InlineData((HttpStatusCode)429)] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + [InlineData(HttpStatusCode.GatewayTimeout)] + public async Task ExecuteAsync_RetriesForCertainStatusCodes(HttpStatusCode statusCode) + { + var options = new DefaultRetryPolicyOptions + { + DeltaBackoff = TimeSpan.FromMilliseconds(100) + }; + var retryPolicy = new DefaultRetryPolicy(options); + var client = new FakeSender().Returns(statusCode, HttpStatusCode.OK); + + var stopwatch = Stopwatch.StartNew(); + var response = await retryPolicy.ExecuteAsync(client.SendRequest); + stopwatch.Stop(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, client.TimesCalled); + Assert.True(stopwatch.Elapsed > 0.8 * options.DeltaBackoff); + } + + [Theory] + [MemberData(nameof(RetriedExceptions))] + public async Task ExecuteAsync_RetriesForCertainExceptions(Exception exception) + { + var options = new DefaultRetryPolicyOptions + { + DeltaBackoff = TimeSpan.FromMilliseconds(100) + }; + var retryPolicy = new DefaultRetryPolicy(options); + var client = new FakeSender() + .Throws(exception) + .Returns(HttpStatusCode.OK); + + var stopwatch = Stopwatch.StartNew(); + var response = await retryPolicy.ExecuteAsync(client.SendRequest); + stopwatch.Stop(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, client.TimesCalled); + Assert.True(stopwatch.Elapsed > 0.8 * options.DeltaBackoff); + } + + private static Exception GetExceptionFromStatus(WebExceptionStatus status) => new HttpRequestException("Exception", new WebException(string.Empty, status)); + public static readonly object[][] RetriedExceptions = + { + new object[] { GetExceptionFromStatus(WebExceptionStatus.ConnectFailure) }, + new object[] { GetExceptionFromStatus(WebExceptionStatus.ConnectionClosed) }, + new object[] { GetExceptionFromStatus(WebExceptionStatus.KeepAliveFailure) }, + new object[] { GetExceptionFromStatus(WebExceptionStatus.NameResolutionFailure) }, + new object[] { GetExceptionFromStatus(WebExceptionStatus.ReceiveFailure) }, + new object[] { GetExceptionFromStatus(WebExceptionStatus.SendFailure) }, + new object[] { GetExceptionFromStatus(WebExceptionStatus.Timeout) }, + }; + + private class FakeSender + { + public int TimesCalled { get; private set; } + private readonly Queue> _responses = new Queue>(); + + public Task SendRequest() + { + ++TimesCalled; + + if (_responses.Count == 1) + { + return _responses.Peek(); + } + return _responses.Any() + ? _responses.Dequeue() + : Task.FromResult((HttpResponseMessage)null); + } + + public FakeSender Returns(params HttpResponseMessage[] responseMessages) + { + foreach (var response in responseMessages) + { + _responses.Enqueue(Task.FromResult(response)); + } + + return this; + } + + + public FakeSender Returns(params HttpStatusCode[] statusCodes) + { + foreach (var statusCode in statusCodes) + { + _responses.Enqueue(Task.FromResult(new HttpResponseMessage(statusCode))); + } + + return this; + } + + public FakeSender Throws(params Exception[] exceptions) + { + foreach (var exception in exceptions) + { + _responses.Enqueue(Task.FromException(exception)); + } + + return this; + } + } + } +} \ No newline at end of file diff --git a/Kentico.Kontent.Delivery.Tests/ValueConverterTests.cs b/Kentico.Kontent.Delivery.Tests/ValueConverterTests.cs index f5bc4a0a..adc18c2f 100644 --- a/Kentico.Kontent.Delivery.Tests/ValueConverterTests.cs +++ b/Kentico.Kontent.Delivery.Tests/ValueConverterTests.cs @@ -7,11 +7,11 @@ using RichardSzalay.MockHttp; using System.IO; using System.Net.Http; +using System.Threading.Tasks; using FakeItEasy; -using Kentico.Kontent.Delivery.ResiliencePolicy; +using Kentico.Kontent.Delivery.RetryPolicy; using Kentico.Kontent.Delivery.StrongTyping; using Microsoft.Extensions.Options; -using Polly; namespace Kentico.Kontent.Delivery.Tests { @@ -109,16 +109,19 @@ private DeliveryClient InitializeDeliveryClient(MockHttpMessageHandler mockHttp) var httpClient = mockHttp.ToHttpClient(); var contentLinkUrlResolver = A.Fake(); var deliveryOptions = new OptionsWrapper(new DeliveryOptions { ProjectId = guid }); - var resiliencePolicyProvider = A.Fake(); - A.CallTo(() => resiliencePolicyProvider.Policy) - .Returns(Policy.HandleResult(result => true).RetryAsync(deliveryOptions.Value.MaxRetryAttempts)); + var retryPolicy = A.Fake(); + var retryPolicyProvider = A.Fake(); + A.CallTo(() => retryPolicyProvider.GetRetryPolicy()) + .Returns(retryPolicy); + A.CallTo(() => retryPolicy.ExecuteAsync(A>>._)) + .ReturnsLazily(c => c.GetArgument>>(0)()); var modelProvider = new ModelProvider( contentLinkUrlResolver, null, new CustomTypeProvider(), new PropertyMapper() ); - var client = new DeliveryClient(deliveryOptions, httpClient, null, null, modelProvider, resiliencePolicyProvider); + var client = new DeliveryClient(deliveryOptions, httpClient, null, null, modelProvider, retryPolicyProvider); return client; } diff --git a/Kentico.Kontent.Delivery/Builders/DeliveryClient/DeliveryClientBuilder.cs b/Kentico.Kontent.Delivery/Builders/DeliveryClient/DeliveryClientBuilder.cs index f4e0fa9e..fa30fa47 100644 --- a/Kentico.Kontent.Delivery/Builders/DeliveryClient/DeliveryClientBuilder.cs +++ b/Kentico.Kontent.Delivery/Builders/DeliveryClient/DeliveryClientBuilder.cs @@ -5,30 +5,30 @@ namespace Kentico.Kontent.Delivery { /// - /// A builder class for creating an instance of the interface. + /// A builder of instances. /// public sealed class DeliveryClientBuilder { private static IDeliveryClientBuilder Builder => new DeliveryClientBuilderImplementation(); /// - /// Mandatory step of the for specifying Kentico Kontent project id. + /// Use project identifier. /// - /// The identifier of the Kentico Kontent project. + /// The identifier of a Kentico Kontent project. public static IOptionalClientSetup WithProjectId(string projectId) => Builder.BuildWithProjectId(projectId); /// - /// Mandatory step of the for specifying Kentico Kontent project id. + /// Use project identifier. /// - /// The identifier of the Kentico Kontent project. + /// The identifier of a Kentico Kontent project. public static IOptionalClientSetup WithProjectId(Guid projectId) => Builder.BuildWithProjectId(projectId); /// - /// Mandatory step of the for specifying Kentico Kontent project settings. + /// Use additional configuration. /// - /// A function that is provided with an instance of and expected to return a valid instance of . + /// A delegate that creates an instance of the using the specified . public static IOptionalClientSetup WithOptions(Func buildDeliveryOptions) => Builder.BuildWithDeliveryOptions(buildDeliveryOptions); } diff --git a/Kentico.Kontent.Delivery/Builders/DeliveryClient/DeliveryClientBuilderImplementation.cs b/Kentico.Kontent.Delivery/Builders/DeliveryClient/DeliveryClientBuilderImplementation.cs index 3e3e0a7f..39a7ae6d 100644 --- a/Kentico.Kontent.Delivery/Builders/DeliveryClient/DeliveryClientBuilderImplementation.cs +++ b/Kentico.Kontent.Delivery/Builders/DeliveryClient/DeliveryClientBuilderImplementation.cs @@ -1,6 +1,6 @@ using Kentico.Kontent.Delivery.Builders.DeliveryOptions; using Kentico.Kontent.Delivery.InlineContentItems; -using Kentico.Kontent.Delivery.ResiliencePolicy; +using Kentico.Kontent.Delivery.RetryPolicy; using Microsoft.Extensions.DependencyInjection; using System; using System.Net.Http; @@ -15,7 +15,6 @@ internal sealed class DeliveryClientBuilderImplementation : IDeliveryClientBuild public IOptionalClientSetup BuildWithDeliveryOptions(Func buildDeliveryOptions) { var builder = DeliveryOptionsBuilder.CreateInstance(); - _deliveryOptions = buildDeliveryOptions(builder); return this; @@ -25,14 +24,14 @@ public IOptionalClientSetup BuildWithProjectId(string projectId) => BuildWithDeliveryOptions(builder => builder .WithProjectId(projectId) - .UseProductionApi + .UseProductionApi() .Build()); public IOptionalClientSetup BuildWithProjectId(Guid projectId) => BuildWithDeliveryOptions(builder => builder .WithProjectId(projectId) - .UseProductionApi + .UseProductionApi() .Build()); IOptionalClientSetup IOptionalClientSetup.WithHttpClient(HttpClient httpClient) @@ -53,8 +52,8 @@ IOptionalClientSetup IOptionalClientSetup.WithModelProvider(IModelProvider model IOptionalClientSetup IOptionalClientSetup.WithTypeProvider(ITypeProvider typeProvider) => RegisterOrThrow(typeProvider, nameof(typeProvider)); - IOptionalClientSetup IOptionalClientSetup.WithResiliencePolicyProvider(IResiliencePolicyProvider resiliencePolicyProvider) - => RegisterOrThrow(resiliencePolicyProvider, nameof(resiliencePolicyProvider)); + IOptionalClientSetup IOptionalClientSetup.WithRetryPolicyProvider(IRetryPolicyProvider retryPolicyProvider) + => RegisterOrThrow(retryPolicyProvider, nameof(retryPolicyProvider)); IOptionalClientSetup IOptionalClientSetup.WithPropertyMapper(IPropertyMapper propertyMapper) => RegisterOrThrow(propertyMapper, nameof(propertyMapper)); @@ -62,16 +61,13 @@ IOptionalClientSetup IOptionalClientSetup.WithPropertyMapper(IPropertyMapper pro IDeliveryClient IDeliveryClientBuild.Build() { _serviceCollection.AddDeliveryClient(_deliveryOptions); - var serviceProvider = _serviceCollection.BuildServiceProvider(); - var client = serviceProvider.GetService(); return client; } - private DeliveryClientBuilderImplementation RegisterOrThrow(TType instance, string parameterName) - where TType : class + private DeliveryClientBuilderImplementation RegisterOrThrow(TType instance, string parameterName) where TType : class { if (instance == null) { diff --git a/Kentico.Kontent.Delivery/Builders/DeliveryClient/IDeliveryClientBuilder.cs b/Kentico.Kontent.Delivery/Builders/DeliveryClient/IDeliveryClientBuilder.cs index 6dd3c56a..5db44d39 100644 --- a/Kentico.Kontent.Delivery/Builders/DeliveryClient/IDeliveryClientBuilder.cs +++ b/Kentico.Kontent.Delivery/Builders/DeliveryClient/IDeliveryClientBuilder.cs @@ -2,12 +2,12 @@ using System.Net.Http; using Kentico.Kontent.Delivery.Builders.DeliveryOptions; using Kentico.Kontent.Delivery.InlineContentItems; -using Kentico.Kontent.Delivery.ResiliencePolicy; +using Kentico.Kontent.Delivery.RetryPolicy; namespace Kentico.Kontent.Delivery.Builders.DeliveryClient { /// - /// Defines the contracts of the mandatory steps for building a Kentico Kontent instance. + /// A builder abstraction of mandatory setup of instances. /// public interface IDeliveryClientBuilder { @@ -22,68 +22,67 @@ public interface IDeliveryClientBuilder } /// - /// Defines the contracts of the optional steps for building a Kentico Kontent instance. + /// A builder abstraction of optional setup of instances. /// public interface IOptionalClientSetup : IDeliveryClientBuild { /// - /// Sets a custom HTTP client instance to the instance. + /// Use a custom HTTP client. /// - /// A custom HTTP client instance + /// A custom HTTP client. IOptionalClientSetup WithHttpClient(HttpClient httpClient); /// - /// Sets a custom instance of an object that can resolve links in rich text elements to the instance. + /// Use a custom object to provide URL for content links in rich text elements. /// - /// An instance of an object that can resolve links in rich text elements + /// An instance of the . IOptionalClientSetup WithContentLinkUrlResolver(IContentLinkUrlResolver contentLinkUrlResolver); /// - /// Sets a custom instance of an object that can resolve specific content type of an inline content item to the instance. + /// Use an object to transform linked items and components of the specified type in rich text elements to a valid HTML fragment. /// - /// Content type to be resolved - /// An instance of an object that can resolve component and linked items to HTML markup - /// + /// The type of the linked item or component to transform. + /// An instance of the . IOptionalClientSetup WithInlineContentItemsResolver(IInlineContentItemsResolver inlineContentItemsResolver); /// - /// Sets a custom instance of an object that can resolve modular content in rich text elements to the instance. + /// Use a custom object to transform HTML content of rich text elements. /// - /// An instance of an object that can resolve modular content in rich text elements + /// An instance of the . IOptionalClientSetup WithInlineContentItemsProcessor(IInlineContentItemsProcessor inlineContentItemsProcessor); /// - /// Sets a custom instance of an object that can JSON responses into strongly typed CLR objects to the instance. + /// Use a custom provider to convert JSON data into objects. /// - /// An instance of an object that can JSON responses into strongly typed CLR objects + /// An instance of the . IOptionalClientSetup WithModelProvider(IModelProvider modelProvider); /// - /// Sets a custom instance of an object that can map Kentico Kontent content types to CLR types to the instance. + /// Use a custom provider to map content type codenames to content type objects. /// - /// An instance of an object that can map Kentico Kontent content types to CLR types + /// An instance of the . IOptionalClientSetup WithTypeProvider(ITypeProvider typeProvider); /// - /// Sets a custom instance of a provider of a resilience (retry) policy to the instance. + /// Use a custom provider to create retry polices for HTTP requests. /// - /// A provider of a resilience (retry) policy - IOptionalClientSetup WithResiliencePolicyProvider(IResiliencePolicyProvider resiliencePolicyProvider); + /// An instance of the . + IOptionalClientSetup WithRetryPolicyProvider(IRetryPolicyProvider retryPolicyProvider); /// - /// Sets a custom instance of an object that can map Kentico Kontent content item fields to model properties to the instance. + /// Use a custom mapper to determine relationships between elements of a content item and properties of a model that represents this item. /// - /// An instance of an object that can map Kentico Kontent content item fields to model properties + /// An instance of the . IOptionalClientSetup WithPropertyMapper(IPropertyMapper propertyMapper); } /// - /// Defines the contract of the last build step that initializes a new instance of the interface. + /// A builder abstraction of the last step in the setup of instances. /// public interface IDeliveryClientBuild { /// - /// Initializes a new instance of the interface for retrieving content of the specified project. + /// Returns a new instance of the . /// IDeliveryClient Build(); } diff --git a/Kentico.Kontent.Delivery/Builders/DeliveryOptions/DeliveryOptionsBuilder.cs b/Kentico.Kontent.Delivery/Builders/DeliveryOptions/DeliveryOptionsBuilder.cs index 0ab2ea59..36794240 100644 --- a/Kentico.Kontent.Delivery/Builders/DeliveryOptions/DeliveryOptionsBuilder.cs +++ b/Kentico.Kontent.Delivery/Builders/DeliveryOptions/DeliveryOptionsBuilder.cs @@ -1,17 +1,17 @@ -using System; -using Kentico.Kontent.Delivery.Builders.DeliveryOptions; +using Kentico.Kontent.Delivery.Builders.DeliveryOptions; +using System; namespace Kentico.Kontent.Delivery { /// - /// A builder class that can be used for creating a instance. + /// A builder of instances. /// public class DeliveryOptionsBuilder : IDeliveryApiConfiguration, IDeliveryOptionsBuilder, IOptionalDeliveryConfiguration { private readonly DeliveryOptions _deliveryOptions = new DeliveryOptions(); /// - /// Creates a new instance of the class for building . + /// Creates a new instance of the class. /// public static IDeliveryOptionsBuilder CreateInstance() => new DeliveryOptionsBuilder(); @@ -34,31 +34,24 @@ IDeliveryApiConfiguration IDeliveryOptionsBuilder.WithProjectId(Guid projectId) return this; } - IOptionalDeliveryConfiguration IOptionalDeliveryConfiguration.WaitForLoadingNewContent + IOptionalDeliveryConfiguration IOptionalDeliveryConfiguration.WaitForLoadingNewContent() { - get - { - _deliveryOptions.WaitForLoadingNewContent = true; + _deliveryOptions.WaitForLoadingNewContent = true; - return this; - } + return this; } - IOptionalDeliveryConfiguration IOptionalDeliveryConfiguration.DisableResilienceLogic + IOptionalDeliveryConfiguration IOptionalDeliveryConfiguration.DisableRetryPolicy() { - get - { - _deliveryOptions.EnableResilienceLogic = false; + _deliveryOptions.EnableRetryPolicy = false; - return this; - } + return this; } - IOptionalDeliveryConfiguration IOptionalDeliveryConfiguration.WithMaxRetryAttempts(int attempts) + IOptionalDeliveryConfiguration IOptionalDeliveryConfiguration.WithDefaultRetryPolicyOptions(DefaultRetryPolicyOptions retryPolicyOptions) { - attempts.ValidateMaxRetryAttempts(); - _deliveryOptions.MaxRetryAttempts = attempts; - _deliveryOptions.EnableResilienceLogic = attempts != 0; + retryPolicyOptions.ValidateRetryPolicyOptions(); + _deliveryOptions.DefaultRetryPolicyOptions = retryPolicyOptions; return this; } @@ -71,14 +64,14 @@ IOptionalDeliveryConfiguration IDeliveryApiConfiguration.UsePreviewApi(string pr return this; } - IOptionalDeliveryConfiguration IDeliveryApiConfiguration.UseProductionApi + IOptionalDeliveryConfiguration IDeliveryApiConfiguration.UseProductionApi() => this; - IOptionalDeliveryConfiguration IDeliveryApiConfiguration.UseSecuredProductionApi(string securedProductionApiKey) + IOptionalDeliveryConfiguration IDeliveryApiConfiguration.UseProductionApi(string secureAccessApiKey) { - securedProductionApiKey.ValidateApiKey(nameof(securedProductionApiKey)); - _deliveryOptions.SecuredProductionApiKey = securedProductionApiKey; - _deliveryOptions.UseSecuredProductionApi = true; + secureAccessApiKey.ValidateApiKey(nameof(secureAccessApiKey)); + _deliveryOptions.SecureAccessApiKey = secureAccessApiKey; + _deliveryOptions.UseSecureAccess = true; return this; } diff --git a/Kentico.Kontent.Delivery/Builders/DeliveryOptions/DeliveryOptionsValidator.cs b/Kentico.Kontent.Delivery/Builders/DeliveryOptions/DeliveryOptionsValidator.cs index a3d568f3..6ec79f73 100644 --- a/Kentico.Kontent.Delivery/Builders/DeliveryOptions/DeliveryOptionsValidator.cs +++ b/Kentico.Kontent.Delivery/Builders/DeliveryOptions/DeliveryOptionsValidator.cs @@ -4,23 +4,23 @@ namespace Kentico.Kontent.Delivery { /// - /// A class that can be used to validate configuration of the instance. + /// Validates instances of the class. /// public static class DeliveryOptionsValidator { internal static Lazy ApiKeyRegex = new Lazy(() => new Regex(@"[A-Za-z0-9+/]+\.[A-Za-z0-9+/]+\.[A-Za-z0-9+/]+", RegexOptions.Compiled)); /// - /// Validates the instance for correct configuration, i.e, project id format, non-negative number of retry attempts, - /// use of either Preview or Production API and whether an API key is set if the API is used. + /// Validates an instance of the class if it is compatible with . + /// If the configuration is not valid, an exception is thrown. /// - /// A instance. + /// An instance of the class. public static void Validate(this DeliveryOptions deliveryOptions) { - ValidateMaxRetryAttempts(deliveryOptions.MaxRetryAttempts); ValidateProjectId(deliveryOptions.ProjectId); ValidateUseOfPreviewAndProductionApi(deliveryOptions); ValidateKeyForEnabledApi(deliveryOptions); + ValidateRetryPolicyOptions(deliveryOptions.DefaultRetryPolicyOptions); } internal static void ValidateProjectId(this string projectId) @@ -37,9 +37,7 @@ internal static void ValidateProjectId(this string projectId) if (!Guid.TryParse(projectId, out var projectIdGuid)) { - throw new ArgumentException( - "Provided string is not a valid project identifier ({ProjectId}). Haven't you accidentally passed the Preview API key instead of the project identifier?", - nameof(projectId)); + throw new ArgumentException("Kentico Kontent project identifier '{ProjectId}' is not valid. Perhaps you have passed an API key instead?", nameof(projectId)); } ValidateProjectId(projectIdGuid); @@ -49,9 +47,7 @@ internal static void ValidateProjectId(this Guid projectId) { if (projectId == Guid.Empty) { - throw new ArgumentException( - "Kentico Kontent project identifier cannot be empty UUID.", - nameof(projectId)); + throw new ArgumentException("Kentico Kontent project identifier is an empty GUID.", nameof(projectId)); } } @@ -61,15 +57,25 @@ internal static void ValidateApiKey(this string apiKey, string parameterName) if (!ApiKeyRegex.Value.IsMatch(apiKey)) { - throw new ArgumentException($"Parameter {parameterName} has invalid format.", parameterName); + throw new ArgumentException($"Parameter {parameterName} is not an API key.", parameterName); } } - internal static void ValidateMaxRetryAttempts(this int attempts) + internal static void ValidateRetryPolicyOptions(this DefaultRetryPolicyOptions retryPolicyOptions) { - if (attempts < 0) + if (retryPolicyOptions == null) { - throw new ArgumentException("Number of maximum retry attempts can't be less than zero.", nameof(attempts)); + throw new ArgumentNullException(nameof(retryPolicyOptions), $"Parameter {nameof(retryPolicyOptions)} is not specified."); + } + + if (retryPolicyOptions.DeltaBackoff <= TimeSpan.Zero) + { + throw new ArgumentException($"Parameter {nameof(retryPolicyOptions.DeltaBackoff)} must be a positive timespan."); + } + + if (retryPolicyOptions.MaxCumulativeWaitTime <= TimeSpan.Zero) + { + throw new ArgumentException($"Parameter {nameof(retryPolicyOptions.MaxCumulativeWaitTime)} must be a positive timespan."); } } @@ -81,7 +87,7 @@ internal static void ValidateCustomEndpoint(this string customEndpoint) if (!canCreateUri) { - throw new ArgumentException($"Parameter {nameof(customEndpoint)} has invalid format.", nameof(customEndpoint)); + throw new ArgumentException($"Parameter {nameof(customEndpoint)} is not a valid URL.", nameof(customEndpoint)); } ValidateCustomEndpoint(uriResult); @@ -96,14 +102,14 @@ internal static void ValidateCustomEndpoint(this Uri customEndpoint) if (!customEndpoint.IsAbsoluteUri) { - throw new ArgumentException($"Parameter {nameof(customEndpoint)} has to be an absolute URI.", nameof(customEndpoint)); + throw new ArgumentException($"Parameter {nameof(customEndpoint)} is not an absolute URL.", nameof(customEndpoint)); } var hasCorrectUriScheme = customEndpoint.Scheme == Uri.UriSchemeHttp || customEndpoint.Scheme == Uri.UriSchemeHttps; if (!hasCorrectUriScheme) { - throw new ArgumentException($"Parameter {nameof(customEndpoint)} has unsupported scheme. Please use either http or https.", nameof(customEndpoint)); + throw new ArgumentException($"Parameter {nameof(customEndpoint)} has scheme that is not supported. Please use either HTTP or HTTPS.", nameof(customEndpoint)); } } @@ -124,20 +130,20 @@ private static void ValidateKeyForEnabledApi(this DeliveryOptions deliveryOption { if (deliveryOptions.UsePreviewApi && string.IsNullOrWhiteSpace(deliveryOptions.PreviewApiKey)) { - throw new InvalidOperationException("The Preview API key must be set while using the Preview API."); + throw new InvalidOperationException("The Preview API key must be set to be able to retrieve content with the Preview API."); } - if (deliveryOptions.UseSecuredProductionApi && string.IsNullOrWhiteSpace(deliveryOptions.SecuredProductionApiKey)) + if (deliveryOptions.UseSecureAccess && string.IsNullOrWhiteSpace(deliveryOptions.SecureAccessApiKey)) { - throw new InvalidOperationException("The Secured Production API key must be set while using the Secured Production API."); + throw new InvalidOperationException("The secure access API key must be set to be able to retrieve content with Production API when secure access is enabled."); } } private static void ValidateUseOfPreviewAndProductionApi(this DeliveryOptions deliveryOptions) { - if (deliveryOptions.UsePreviewApi && deliveryOptions.UseSecuredProductionApi) + if (deliveryOptions.UsePreviewApi && deliveryOptions.UseSecureAccess) { - throw new InvalidOperationException("Preview API and Secured Production API can't be used at the same time."); + throw new InvalidOperationException("Preview API and Production API with secured access enabled can't be used at the same time."); } } } diff --git a/Kentico.Kontent.Delivery/Builders/DeliveryOptions/IDeliveryOptionsBuilder.cs b/Kentico.Kontent.Delivery/Builders/DeliveryOptions/IDeliveryOptionsBuilder.cs index fcaef19c..05aff150 100644 --- a/Kentico.Kontent.Delivery/Builders/DeliveryOptions/IDeliveryOptionsBuilder.cs +++ b/Kentico.Kontent.Delivery/Builders/DeliveryOptions/IDeliveryOptionsBuilder.cs @@ -1,87 +1,84 @@ using System; -using Kentico.Kontent.Delivery.ResiliencePolicy; namespace Kentico.Kontent.Delivery.Builders.DeliveryOptions { /// - /// Defines the contracts of the mandatory steps for building a instance. + /// A builder abstraction of mandatory setup of instances. /// public interface IDeliveryOptionsBuilder { /// - /// A mandatory step of the for specifying Kentico Kontent project id. + /// Use project identifier. /// - /// The identifier of the Kentico Kontent project. + /// The identifier of a Kentico Kontent project. IDeliveryApiConfiguration WithProjectId(string projectId); /// - /// A mandatory step of the for specifying Kentico Kontent project id. + /// Use project identifier. /// - /// The identifier of the Kentico Kontent project. + /// The identifier of a Kentico Kontent project. IDeliveryApiConfiguration WithProjectId(Guid projectId); } /// - /// Defines the contracts of different APIs that might be used. + /// A builder abstraction of API setup of instances. /// public interface IDeliveryApiConfiguration { /// - /// Sets the Delivery Client to make requests to a Production API. + /// Use Production API with secure access disabled to retrieve content. /// - IOptionalDeliveryConfiguration UseProductionApi { get; } + IOptionalDeliveryConfiguration UseProductionApi(); /// - /// Sets the Delivery Client to make requests to a Preview API. + /// Use Production API with secure access enabled to retrieve content. /// - /// A Preview API key - IOptionalDeliveryConfiguration UsePreviewApi(string previewApiKey); + /// An API key for secure access. + IOptionalDeliveryConfiguration UseProductionApi(string secureAccessApiKey); /// - /// Sets the Delivery Client to make requests to a Secured Production API. + /// Use Preview API to retrieve content. /// - /// An API key for secure access. - IOptionalDeliveryConfiguration UseSecuredProductionApi(string securedProductionApiKey); + /// A Preview API key. + IOptionalDeliveryConfiguration UsePreviewApi(string previewApiKey); } /// - /// Defines the contracts of the optional steps for building a instance. + /// A builder abstraction of optional setup of instances. /// public interface IOptionalDeliveryConfiguration : IDeliveryOptionsBuild { /// - /// An optional step that disables retry policy (fallback) for HTTP requests. + /// Disable retry policy for HTTP requests. /// - IOptionalDeliveryConfiguration DisableResilienceLogic { get; } + IOptionalDeliveryConfiguration DisableRetryPolicy(); /// - /// An optional step that sets the client to wait for updated content. - /// It should be used when you are acting upon a webhook call. + /// Provide content that is always up-to-date. + /// We recommend to wait for new content when you have received a webhook notification. + /// However, the request might take longer than usual to complete. /// - IOptionalDeliveryConfiguration WaitForLoadingNewContent { get; } + IOptionalDeliveryConfiguration WaitForLoadingNewContent(); /// - /// An optional step that sets the maximum number of retry attempts. + /// Change configuration of the default retry policy. /// - /// - /// The maximum number of retry attempts from is only used in the default implementation of the interface. - /// Setting the value to 0 will result in the resilience logic not being used. - /// If this method does not specify otherwise, the number of maximum retry attempts will be set to 5. - /// - /// Number greater than 0 representing maximum retry attempts. - IOptionalDeliveryConfiguration WithMaxRetryAttempts(int attempts); + /// Configuration of the default retry policy. + IOptionalDeliveryConfiguration WithDefaultRetryPolicyOptions(DefaultRetryPolicyOptions retryPolicyOptions); /// - /// An optional step that sets a custom endpoint for a chosen API. If "{0}" is provided in the URL, it gets replaced by the projectId. + /// Use a custom format for the Production or Preview API endpoint address. + /// The project identifier will be inserted at the position of the first format item "{0}". /// /// /// While both HTTP and HTTPS protocols are supported, we recommend always using HTTPS. /// - /// A custom endpoint URL address. + /// A custom format for the Production API endpoint address. IOptionalDeliveryConfiguration WithCustomEndpoint(string customEndpoint); /// - /// An optional step that sets a custom endpoint for a chosen API. + /// Use a custom format for the Production or Preview API endpoint address. + /// The project identifier will be inserted at the position of the first format item "{0}". /// /// /// While both HTTP and HTTPS protocols are supported, we recommend always using HTTPS. @@ -89,16 +86,15 @@ public interface IOptionalDeliveryConfiguration : IDeliveryOptionsBuild /// A custom endpoint URI. IOptionalDeliveryConfiguration WithCustomEndpoint(Uri customEndpoint); } - + /// - /// Defines the contract of the last build step that creates a new instance of the of the class. + /// A builder abstraction of the last step in setup of instances. /// public interface IDeliveryOptionsBuild { /// - /// Creates a new instance of the class that configures the Kentico Delivery Client. + /// Returns a new instance of the class. /// - /// A new instance Delivery.DeliveryOptions Build(); } } diff --git a/Kentico.Kontent.Delivery/Configuration/DefaultRetryPolicyOptions.cs b/Kentico.Kontent.Delivery/Configuration/DefaultRetryPolicyOptions.cs new file mode 100644 index 00000000..6e74611d --- /dev/null +++ b/Kentico.Kontent.Delivery/Configuration/DefaultRetryPolicyOptions.cs @@ -0,0 +1,24 @@ +using System; +using Kentico.Kontent.Delivery.RetryPolicy; + +namespace Kentico.Kontent.Delivery +{ + /// + /// Represents configuration of the that performs retries using a randomized exponential back off scheme to determine the interval between retries. + /// + public class DefaultRetryPolicyOptions + { + /// + /// Gets or sets the back-off interval between retries. + /// The default value is 1 second. + /// + public TimeSpan DeltaBackoff { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Gets or sets the maximum cumulative wait time. + /// If the cumulative wait time exceeds this value, the client will stop retrying and return the error to the application. + /// The default value is 30 seconds. + /// + public TimeSpan MaxCumulativeWaitTime { get; set; } = TimeSpan.FromSeconds(30); + } +} \ No newline at end of file diff --git a/Kentico.Kontent.Delivery/Configuration/DeliveryOptions .cs b/Kentico.Kontent.Delivery/Configuration/DeliveryOptions .cs index 660cdcab..14c7cf3d 100644 --- a/Kentico.Kontent.Delivery/Configuration/DeliveryOptions .cs +++ b/Kentico.Kontent.Delivery/Configuration/DeliveryOptions .cs @@ -1,67 +1,65 @@ namespace Kentico.Kontent.Delivery { /// - /// Keeps settings which are provided by customer or have default values, used in . + /// Represents configuration of the . /// public class DeliveryOptions { /// - /// Gets or sets the Production endpoint address. + /// Gets or sets the format of the Production API endpoint address. + /// The project identifier will be inserted at the position of the first format item "{0}". /// public string ProductionEndpoint { get; set; } = "https://deliver.kontent.ai/{0}"; /// - /// Gets or sets the Preview endpoint address. + /// Gets or sets the format of the Preview API endpoint address. + /// The project identifier will be inserted at the position of the first format item "{0}". /// public string PreviewEndpoint { get; set; } = "https://preview-deliver.kontent.ai/{0}"; /// - /// Gets or sets the Project identifier. + /// Gets or sets the project identifier. /// public string ProjectId { get; set; } /// - /// Gets or sets the Preview API key. + /// Gets or sets the API key that is used to retrieve content with the Preview API. /// public string PreviewApiKey { get; set; } /// - /// Gets or sets whether the Preview API should be used. If TRUE, needs to be set as well. + /// Gets or sets a value that determines if the Preview API is used to retrieve content. + /// If the Preview API is used the must be set. /// - /// - /// This property enables quick toggling between production and preview configuration even with set. - /// It can be used for debugging and verification of unpublished content in time-critical scenarios, however, we recommend - /// working with only either the production or preview Delivery API, not both, within a single project. - /// public bool UsePreviewApi { get; set; } /// - /// Set to true if you want to wait for updated content. It should be used when you are acting upon a webhook call. + /// Gets or sets a value that determines if the client provides content that is always up-to-date. + /// We recommend to wait for new content when you have received a webhook notification. + /// However, the request might take longer than usual to complete. /// public bool WaitForLoadingNewContent { get; set; } /// - /// Gets or sets whether the production Delivery API will be accessed using an API key. + /// Gets or sets a value that determines if the client sends the secure access API key to retrieve content with the Production API. + /// This key is required to retrieve content when secure access is enabled. + /// To retrieve content when secure access is enabled the must be set. /// - /// - /// This property enables quick toggling between production and secured production configuration even with set. - /// - public bool UseSecuredProductionApi { get; set; } + public bool UseSecureAccess { get; set; } /// - /// Gets or sets the production Delivery API key. + /// Gets or sets the API key that is used to retrieve content with the Production API when secure access is enabled. /// - public string SecuredProductionApiKey { get; set; } + public string SecureAccessApiKey { get; set; } /// - /// Gets or sets whether a retry policy (fallback) will be used for HTTP requests. + /// Gets or sets a value that determines whether a retry policy is used to make HTTP requests. /// - public bool EnableResilienceLogic { get; set; } = true; + public bool EnableRetryPolicy { get; set; } = true; - // When changing the default value of max retry attempts, change it also in IDeliveryOptionsBuilder.WithMaxRetryAttempts documentation. /// - /// Gets or sets the maximum retry attempts. + /// Gets or sets configuration of the default retry policy. /// - public int MaxRetryAttempts { get; set; } = 5; + public DefaultRetryPolicyOptions DefaultRetryPolicyOptions { get; set; } = new DefaultRetryPolicyOptions(); } } diff --git a/Kentico.Kontent.Delivery/DeliveryClient.cs b/Kentico.Kontent.Delivery/DeliveryClient.cs index 37e8e9bb..110f0dd1 100644 --- a/Kentico.Kontent.Delivery/DeliveryClient.cs +++ b/Kentico.Kontent.Delivery/DeliveryClient.cs @@ -8,7 +8,7 @@ using Newtonsoft.Json.Linq; using Kentico.Kontent.Delivery.Extensions; using Kentico.Kontent.Delivery.InlineContentItems; -using Kentico.Kontent.Delivery.ResiliencePolicy; +using Kentico.Kontent.Delivery.RetryPolicy; namespace Kentico.Kontent.Delivery { @@ -23,7 +23,7 @@ internal sealed class DeliveryClient : IDeliveryClient internal readonly IModelProvider ModelProvider; internal readonly ITypeProvider TypeProvider; internal readonly IPropertyMapper PropertyMapper; - internal readonly IResiliencePolicyProvider ResiliencePolicyProvider; + internal readonly IRetryPolicyProvider RetryPolicyProvider; internal readonly HttpClient HttpClient; private DeliveryEndpointUrlBuilder _urlBuilder; @@ -39,7 +39,7 @@ private DeliveryEndpointUrlBuilder UrlBuilder /// An instance of an object that can resolve links in rich text elements /// An instance of an object that can resolve linked items in rich text elements /// An instance of an object that can JSON responses into strongly typed CLR objects - /// A provider of a resilience (retry) policy. + /// A provider of a retry policy. /// An instance of an object that can map Kentico Kontent content types to CLR types /// An instance of an object that can map Kentico Kontent content item fields to model properties public DeliveryClient( @@ -48,7 +48,7 @@ public DeliveryClient( IContentLinkUrlResolver contentLinkUrlResolver = null, IInlineContentItemsProcessor contentItemsProcessor = null, IModelProvider modelProvider = null, - IResiliencePolicyProvider retryPolicyProvider = null, + IRetryPolicyProvider retryPolicyProvider = null, ITypeProvider typeProvider = null, IPropertyMapper propertyMapper = null ) @@ -58,7 +58,7 @@ public DeliveryClient( ContentLinkUrlResolver = contentLinkUrlResolver; InlineContentItemsProcessor = contentItemsProcessor; ModelProvider = modelProvider; - ResiliencePolicyProvider = retryPolicyProvider; + RetryPolicyProvider = retryPolicyProvider; TypeProvider = typeProvider; PropertyMapper = propertyMapper; } @@ -469,21 +469,19 @@ public async Task GetTaxonomiesAsync(IEnumerabl private async Task GetDeliverResponseAsync(string endpointUrl, string continuationToken = null) { - if (DeliveryOptions.UsePreviewApi && DeliveryOptions.UseSecuredProductionApi) + if (DeliveryOptions.UsePreviewApi && DeliveryOptions.UseSecureAccess) { - throw new InvalidOperationException("Preview API and secured Delivery API must not be configured at the same time."); + throw new InvalidOperationException("Preview API and Production API with secured access enabled can't be used at the same time."); } - if (DeliveryOptions.EnableResilienceLogic) + if (DeliveryOptions.EnableRetryPolicy) { - // Use the resilience logic. - var policyResult = await ResiliencePolicyProvider?.Policy?.ExecuteAndCaptureAsync(() => - { - return SendHttpMessage(endpointUrl, continuationToken); - } - ); - - return await GetResponseContent(policyResult?.FinalHandledResult ?? policyResult?.Result); + var retryPolicy = RetryPolicyProvider.GetRetryPolicy(); + if (retryPolicy != null) + { + var response = await retryPolicy.ExecuteAsync(() => SendHttpMessage(endpointUrl, continuationToken)); + return await GetResponseContent(response); + } } // Omit using the resilience logic completely. @@ -501,9 +499,9 @@ private Task SendHttpMessage(string endpointUrl, string con message.Headers.AddWaitForLoadingNewContentHeader(); } - if (UseSecuredProductionApi()) + if (UseSecureAccess()) { - message.Headers.AddAuthorizationHeader("Bearer", DeliveryOptions.SecuredProductionApiKey); + message.Headers.AddAuthorizationHeader("Bearer", DeliveryOptions.SecureAccessApiKey); } if (UsePreviewApi()) @@ -519,9 +517,9 @@ private Task SendHttpMessage(string endpointUrl, string con return HttpClient.SendAsync(message); } - private bool UseSecuredProductionApi() + private bool UseSecureAccess() { - return DeliveryOptions.UseSecuredProductionApi && !string.IsNullOrEmpty(DeliveryOptions.SecuredProductionApiKey); + return DeliveryOptions.UseSecureAccess && !string.IsNullOrEmpty(DeliveryOptions.SecureAccessApiKey); } private bool UsePreviewApi() @@ -542,13 +540,13 @@ private async Task GetResponseContent(HttpResponseMessage httpRespo string faultContent = null; - // The null-coallescing operator causes tests to fail for NREs, hence the "if" statement. + // The null-coalescing operator causes tests to fail for NREs, hence the "if" statement. if (httpResponseMessage?.Content != null) { faultContent = await httpResponseMessage.Content.ReadAsStringAsync(); } - throw new DeliveryException(httpResponseMessage, "Either the retry policy was disabled or all retry attempts were depleted.\nFault content:\n" + faultContent); + throw new DeliveryException(httpResponseMessage, $"There was an error while fetching content:\nStatus:{httpResponseMessage.StatusCode}\nReason:{httpResponseMessage.ReasonPhrase}\n\n{faultContent}"); } private bool HasStaleContent(HttpResponseMessage httpResponseMessage) diff --git a/Kentico.Kontent.Delivery/Extensions/HttpResponseHeadersExtensions.cs b/Kentico.Kontent.Delivery/Extensions/HttpResponseHeadersExtensions.cs index b398208d..26df38da 100644 --- a/Kentico.Kontent.Delivery/Extensions/HttpResponseHeadersExtensions.cs +++ b/Kentico.Kontent.Delivery/Extensions/HttpResponseHeadersExtensions.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; using System.Net.Http.Headers; namespace Kentico.Kontent.Delivery.Extensions @@ -9,9 +10,28 @@ internal static class HttpResponseHeadersExtensions internal static string GetContinuationHeader(this HttpResponseHeaders headers) { - return headers.TryGetValues(ContinuationHeaderName, out var headerValues) - ? headerValues.FirstOrDefault() + return headers.TryGetValues(ContinuationHeaderName, out var headerValues) + ? headerValues.FirstOrDefault() : null; } + + internal static bool TryGetRetryHeader(this HttpResponseHeaders headers, out TimeSpan retryAfter) + { + TimeSpan GetPositiveOrZero(TimeSpan timeSpan) => timeSpan < TimeSpan.Zero ? TimeSpan.Zero : timeSpan; + + if (headers?.RetryAfter?.Date != null) + { + retryAfter = GetPositiveOrZero(headers.RetryAfter.Date.Value - DateTime.UtcNow); + return true; + } + + if (headers?.RetryAfter?.Delta != null) + { + retryAfter = GetPositiveOrZero(headers.RetryAfter.Delta.GetValueOrDefault(TimeSpan.Zero)); + return true; + } + + return false; + } } } diff --git a/Kentico.Kontent.Delivery/Extensions/ServiceCollectionExtensions.cs b/Kentico.Kontent.Delivery/Extensions/ServiceCollectionExtensions.cs index 1150fe81..a227ea8f 100644 --- a/Kentico.Kontent.Delivery/Extensions/ServiceCollectionExtensions.cs +++ b/Kentico.Kontent.Delivery/Extensions/ServiceCollectionExtensions.cs @@ -5,7 +5,7 @@ using Kentico.Kontent.Delivery.Builders.DeliveryOptions; using Kentico.Kontent.Delivery.ContentLinks; using Kentico.Kontent.Delivery.InlineContentItems; -using Kentico.Kontent.Delivery.ResiliencePolicy; +using Kentico.Kontent.Delivery.RetryPolicy; using Kentico.Kontent.Delivery.StrongTyping; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -114,7 +114,7 @@ private static IServiceCollection RegisterDependencies(this IServiceCollection s services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); return services; diff --git a/Kentico.Kontent.Delivery/Kentico.Kontent.Delivery.csproj b/Kentico.Kontent.Delivery/Kentico.Kontent.Delivery.csproj index e3c66bdb..32990af8 100644 --- a/Kentico.Kontent.Delivery/Kentico.Kontent.Delivery.csproj +++ b/Kentico.Kontent.Delivery/Kentico.Kontent.Delivery.csproj @@ -23,7 +23,6 @@ - diff --git a/Kentico.Kontent.Delivery/ResiliencePolicy/DefaultResiliencePolicyProvider.cs b/Kentico.Kontent.Delivery/ResiliencePolicy/DefaultResiliencePolicyProvider.cs deleted file mode 100644 index 4c7cc4da..00000000 --- a/Kentico.Kontent.Delivery/ResiliencePolicy/DefaultResiliencePolicyProvider.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using System.Linq; -using System.Net; -using System.Net.Http; -using Microsoft.Extensions.Options; -using Polly; - -namespace Kentico.Kontent.Delivery.ResiliencePolicy -{ - /// - /// Provides a default (fallback) retry policy for HTTP requests - /// - internal class DefaultResiliencePolicyProvider : IResiliencePolicyProvider - { - private static readonly HttpStatusCode[] HttpStatusCodesWorthRetrying = - { - HttpStatusCode.RequestTimeout, // 408 - HttpStatusCode.InternalServerError, // 500 - HttpStatusCode.BadGateway, // 502 - HttpStatusCode.ServiceUnavailable, // 503 - HttpStatusCode.GatewayTimeout // 504 - }; - - private readonly IOptions _deliveryOptions; - - private int MaxRetryOptions => _deliveryOptions.Value.MaxRetryAttempts; - - /// - /// Creates a default retry policy provider with a maximum number of retry attempts. - /// - /// Options containing maximum retry attempts for a request. - public DefaultResiliencePolicyProvider(IOptions deliveryOptions) - { - _deliveryOptions = deliveryOptions; - } - - /// - /// Gets the default (fallback) retry policy for HTTP requests. - /// - public IAsyncPolicy Policy - { - get - { - // Only HTTP status codes are handled with retries, not exceptions. - return Polly.Policy - .HandleResult(result => HttpStatusCodesWorthRetrying.Contains(result.StatusCode)) - .WaitAndRetryAsync( - MaxRetryOptions, - retryAttempt => TimeSpan.FromMilliseconds(Math.Pow(2, retryAttempt) * 100) - ); - } - } - } -} diff --git a/Kentico.Kontent.Delivery/ResiliencePolicy/IResiliencePolicyProvider.cs b/Kentico.Kontent.Delivery/ResiliencePolicy/IResiliencePolicyProvider.cs deleted file mode 100644 index df147880..00000000 --- a/Kentico.Kontent.Delivery/ResiliencePolicy/IResiliencePolicyProvider.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Net.Http; -using Polly; - -namespace Kentico.Kontent.Delivery.ResiliencePolicy -{ - /// - /// Provides a resilience policy. - /// - public interface IResiliencePolicyProvider - { - /// - /// Gets the resilience policy. - /// - IAsyncPolicy Policy { get; } - } -} \ No newline at end of file diff --git a/Kentico.Kontent.Delivery/RetryPolicy/DefaultRetryPolicy.cs b/Kentico.Kontent.Delivery/RetryPolicy/DefaultRetryPolicy.cs new file mode 100644 index 00000000..98fa9771 --- /dev/null +++ b/Kentico.Kontent.Delivery/RetryPolicy/DefaultRetryPolicy.cs @@ -0,0 +1,98 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Kentico.Kontent.Delivery.Extensions; + +namespace Kentico.Kontent.Delivery.RetryPolicy +{ + internal class DefaultRetryPolicy : IRetryPolicy + { + private static readonly Random Random = new Random(); + private static readonly WebExceptionStatus[] WebExceptionStatusesToRetry = + { + WebExceptionStatus.ConnectFailure, + WebExceptionStatus.ConnectionClosed, + WebExceptionStatus.KeepAliveFailure, + WebExceptionStatus.NameResolutionFailure, + WebExceptionStatus.ReceiveFailure, + WebExceptionStatus.SendFailure, + WebExceptionStatus.Timeout + }; + private static readonly HttpStatusCode[] StatusCodesToRetry = + { + HttpStatusCode.RequestTimeout, + (HttpStatusCode)429, // Too Many Requests + HttpStatusCode.InternalServerError, + HttpStatusCode.BadGateway, + HttpStatusCode.ServiceUnavailable, + HttpStatusCode.GatewayTimeout, + }; + private static readonly HttpStatusCode[] StatusCodesWithPossibleRetryHeader = + { + (HttpStatusCode)429, // Too Many Requests + HttpStatusCode.ServiceUnavailable, + }; + + private readonly DefaultRetryPolicyOptions _options; + + public DefaultRetryPolicy(DefaultRetryPolicyOptions options) + { + _options = options; + } + + public async Task ExecuteAsync(Func> sendRequest) + { + var waitTime = TimeSpan.Zero; + var cumulativeWaitTime = TimeSpan.Zero; + + for (var retryAttempts = 0; ; ++retryAttempts, cumulativeWaitTime += waitTime) + { + if (waitTime > TimeSpan.Zero) + { + await Task.Delay(waitTime); + } + + try + { + var response = await sendRequest(); + var shouldRetry = ShouldRetry(response); + + if (shouldRetry) + { + waitTime = StatusCodesWithPossibleRetryHeader.Contains(response.StatusCode) && response.Headers.TryGetRetryHeader(out var retryAfter) && retryAfter > TimeSpan.Zero + ? retryAfter + : GetNextWaitTime(retryAttempts); + } + + if (!shouldRetry || cumulativeWaitTime + waitTime > _options.MaxCumulativeWaitTime) + { + return response; + } + } + catch (Exception e) + { + var shouldRetry = ShouldRetry(e); + + if (shouldRetry) + { + waitTime = GetNextWaitTime(retryAttempts); + } + + if (!shouldRetry || cumulativeWaitTime + waitTime > _options.MaxCumulativeWaitTime) + { + throw; + } + } + } + } + + private TimeSpan GetNextWaitTime(int retryAttempts) + => TimeSpan.FromMilliseconds(Random.Next(Convert.ToInt32(0.8 * _options.DeltaBackoff.TotalMilliseconds), Convert.ToInt32(1.2 * _options.DeltaBackoff.TotalMilliseconds)) * (int)Math.Pow(2, retryAttempts)); + + private static bool ShouldRetry(Exception exception) => exception?.InnerException is WebException we && WebExceptionStatusesToRetry.Contains(we.Status); + + private static bool ShouldRetry(HttpResponseMessage response) => StatusCodesToRetry.Contains(response.StatusCode); + } +} \ No newline at end of file diff --git a/Kentico.Kontent.Delivery/RetryPolicy/DefaultRetryPolicyProvider.cs b/Kentico.Kontent.Delivery/RetryPolicy/DefaultRetryPolicyProvider.cs new file mode 100644 index 00000000..af4c59bd --- /dev/null +++ b/Kentico.Kontent.Delivery/RetryPolicy/DefaultRetryPolicyProvider.cs @@ -0,0 +1,17 @@ +using System; +using Microsoft.Extensions.Options; + +namespace Kentico.Kontent.Delivery.RetryPolicy +{ + internal class DefaultRetryPolicyProvider : IRetryPolicyProvider + { + private readonly DefaultRetryPolicyOptions _retryPolicyOptions; + + public DefaultRetryPolicyProvider(IOptions options) + { + _retryPolicyOptions = options.Value.DefaultRetryPolicyOptions ?? throw new ArgumentNullException(nameof(options)); + } + + public IRetryPolicy GetRetryPolicy() => new DefaultRetryPolicy(_retryPolicyOptions); + } +} \ No newline at end of file diff --git a/Kentico.Kontent.Delivery/RetryPolicy/IRetryPolicy.cs b/Kentico.Kontent.Delivery/RetryPolicy/IRetryPolicy.cs new file mode 100644 index 00000000..9fdaac10 --- /dev/null +++ b/Kentico.Kontent.Delivery/RetryPolicy/IRetryPolicy.cs @@ -0,0 +1,19 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Kentico.Kontent.Delivery.RetryPolicy +{ + /// + /// Represents a retry policy for HTTP requests. + /// + public interface IRetryPolicy + { + /// + /// Invokes the specified request delegate within policy. + /// + /// The request delegate to invoke. + /// A response message returned by . + Task ExecuteAsync(Func> sendRequest); + } +} \ No newline at end of file diff --git a/Kentico.Kontent.Delivery/RetryPolicy/IRetryPolicyProvider.cs b/Kentico.Kontent.Delivery/RetryPolicy/IRetryPolicyProvider.cs new file mode 100644 index 00000000..1d1e3b65 --- /dev/null +++ b/Kentico.Kontent.Delivery/RetryPolicy/IRetryPolicyProvider.cs @@ -0,0 +1,14 @@ +namespace Kentico.Kontent.Delivery.RetryPolicy +{ + /// + /// Provides a retry policy for . + /// + public interface IRetryPolicyProvider + { + /// + /// Returns a new instance of a retry policy. + /// + /// A new instance of a retry policy. + IRetryPolicy GetRetryPolicy(); + } +} \ No newline at end of file diff --git a/README.md b/README.md index fd24a54d..956c7671 100644 --- a/README.md +++ b/README.md @@ -40,18 +40,21 @@ We recommend creating the `DeliveryOptions` instance by using the `DeliveryOptio * `ProjectId` – sets the ID of your Kentico Kontent project. This parameter must always be set. * `UsePreviewApi` – determines whether to use the Delivery Preview API and sets the Delivery Preview API key. See [previewing unpublished content](#previewing-unpublished-content) to learn more. * `UseProductionApi` – determines whether to use the default production Delivery API. -* `UseSecuredProductionApi` – determines whether authenticate requests to the production Delivery API with an API key. See [retrieving secured content](https://developer.kenticoontent.com/docs/securing-public-access#section-retrieving-secured-content) to learn more. +* `UseSecureAccess` – determines whether authenticate requests to the production Delivery API with an API key. See [retrieving secured content](https://developer.kenticokontent.com/docs/securing-public-access#section-retrieving-secured-content) to learn more. * `WaitForLoadingNewContent` – forces the client instance to wait while fetching updated content, useful when acting upon [webhook calls](https://docs.kontent.ai/tutorials/develop-apps/integrate/using-webhooks-for-automatic-updates). -* `EnableResilienceLogic` – determines whether HTTP requests will use [retry logic](#resilience-capabilities). By default, the resilience logic is enabled. -* `MaxRetryAttempts` – sets a custom number of [retry attempts](#resilience-capabilities). By default, the SDK retries requests five times. +* `EnableRetryPolicy` – determines whether HTTP requests will use [retry policy](#retry-capabilities). By default, the retry policy is enabled. +* `DefaultRetryPolicyOptions` – sets a [custom parameters](#retry-capabilities) for the default retry policy. By default, the SDK retries for at most 30 seconds. * `WithCustomEndpoint` - sets a custom endpoint for the specific API (preview, production, or secured production). ```csharp IDeliveryClient client = DeliveryClientBuilder .WithOptions(builder => builder .WithProjectId("") - .UseProductionApi - .WithMaxRetryAttempts(maxRetryAttempts) + .UseProductionApi() + .WithDefaultRetryPolicyOptions(new DefaultRetryPolicyOptions { + DeltaBackoff = TimeSpan.FromSeconds(1), + MaxCumulativeWaitTime = TimeSpan.FromSeconds(10) + }) .Build()) .Build(); ``` @@ -117,13 +120,13 @@ See [Working with Strongly Typed Models](../../wiki/Working-with-strongly-typed- ## Enumerating all items -There are special use cases in which you need to get and process a larger amount of items in a project (e.g. cache initialization, project export, static website build). This is supported in `IDeliveryClient` by using `DeliveryItemsFeed` that can iterate over items in small batches. This approach has several advantages: -* You are guaranteed to retrieve all items (as opposed to `GetItemsAsync` and paging when the project is being worked on in Kentico Kontent application) -* You can start processing items right away and use less memory, due to a limited size of each batch -* Even larger projects can be retrieved in a timely manner +To retrieve a large amount of items, for example to warm a local cache, export content or build a static web site, the SDK provides a `DeliveryItemsFeed` to process items in a streaming fashion. With large projects feed has several advantages over fetching all items in a single API call: +* Processing can start as soon as the first item is received, there is no need to wait for all items. +* Memory consumption is reduced significantly. +* There is no risk of request timeouts. ```csharp -// Get items feed and iteratively process all content items in small batches. +// Process all content items in a streaming fashion. DeliveryItemsFeed feed = client.GetItemsFeed(); while(feed.HasMoreResults) { @@ -136,10 +139,10 @@ while(feed.HasMoreResults) ### Strongly-typed models -There is also a strongly-typed equivalent of the feed in `IDeliveryClient` to support enumerating into a custom model. +There is also a strongly-typed equivalent of the items feed. ```csharp -// Get strongly-typed items feed and iteratively fetch all content items in small batches. +// Process all strongly-typed content items in a streaming fashion. DeliveryItemsFeed
feed = client.GetItemsFeed
(); while(feed.HasMoreResults) { @@ -152,11 +155,10 @@ while(feed.HasMoreResults) ### Filtering and localization -Both filtering and language selection are very similar to `GetItems` method, except for `DepthParameter`, `LimitParameter`, and `SkipParameter` parameters. These are not supported in items feed. +Both filtering and language selection are identical to the `GetItems` method, except for `DepthParameter`, `LimitParameter`, and `SkipParameter` parameters that are not supported. ```csharp -// Get a filtered feed of the specified elements of -// the 'brewer' content type, ordered by the 'product_name' element value +// Process selected and projected content items in a streaming fashion. DeliveryItemsFeed feed = await client.GetItemsFeed( new LanguageParameter("es-ES"), new EqualsFilter("system.type", "brewer"), @@ -167,9 +169,8 @@ DeliveryItemsFeed feed = await client.GetItemsFeed( ### Limitations -Since this method has specific usage scenarios the response does not contain linked items, although, components are still included in the response. - -Due to not supported skip and limit parameters, the size of a single batch may vary and it is not recommended to dependend on it in any way. The only guaranteed outcome is that once `HasMoreResults` property is false, you will have retrieved all the filtered items. +* The response does not contain linked items, only components. +* Delivery API determines how many items will be returned in a single batch. ## Previewing unpublished content @@ -316,19 +317,30 @@ string transformedAssetUrl = builder.WithFocalPointCrop(560, 515, 2) For list of supported transformations and more information visit the Kentico Delivery API reference at . -## Resilience capabilities +## Retry capabilities -By default, the SDK uses a retry policy, asking for requested content again in case of an error. You can disable the retry policy by setting the `DeliveryOptions.EnableResilienceLogic` parameter to `false`. The default policy retries the HTTP requests if the following status codes are returned: +By default, the SDK uses a retry policy, asking for requested content again in case of an error. You can disable the retry policy by setting the `DeliveryOptions.EnableRetryPolicy` parameter to `false`. The default policy retries the HTTP requests if the following status codes are returned: * 408 - `RequestTimeout` +* 429 - `TooManyRequests` * 500 - `InternalServerError` * 502 - `BadGateway` * 503 - `ServiceUnavailable` * 504 - `GatewayTimeout` -The default policy retries requests 5 times, totaling 6 overall attempts to retrieve content before throwing a `DeliveryException`. You can configure the number of attempts using the `DeliveryOptions.MaxRetryAttempts` property. The consecutive attempts are delayed exponentially: 400 milliseconds, 800 milliseconds, 1600 milliseconds, etc. +or if there is one of the following connection problems: + +* `ConnectFailure` +* `ConnectionClosed` +* `KeepAliveFailure` +* `NameResolutionFailure` +* `ReceiveFailure` +* `SendFailure` +* `Timeout` + +The default retry policy performs retries using a randomized exponential back off scheme to determine the interval between retries. It can be customized by changing parameters in `DeliveryOptions.RetryPolicyOptions`. The `DeltaBackoff` parameter specifies the back-off interval between retries. The `MaxCumulativeWaitTime` parameter specifies the maximum cumulative wait time. If the cumulative wait time exceeds this value, the client will stop retrying and return the error to the application. The default retry policy also respects the `Retry-After` response header. -The default resilience policy is implemented using [Polly](https://github.com/App-vNext/Polly). You can also implement your own Polly policy wrapped in an `IResiliencePolicyProvider` instance. The instance can be set to `IDeliveryClient` implementation through the `DeliveryClientBuilder` class or by registering it to the `ServiceCollection`. +You can create your custom retry policy, for example with [Polly](https://github.com/App-vNext/Polly), by implementing `IRetryPolicy` and `IRetryPolicyProvider` interfaces. The custom retry policy provider can be registered with `DeliveryClientBuilder.WithRetryPolicyProvider` or with the `ServiceCollection`. ## Using the Kentico.Kontent.Delivery.Rx reactive library