diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 62ac6ef..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Additional information** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 19f9e0e..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Problem description** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Additional information** -Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d42ed8c..cd6da90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,10 +4,12 @@ on: push: branches: - "*" + - "*/*" - "!master" pull_request: branches: - master + - milestone/v2.0.0 jobs: run-tests: @@ -16,8 +18,9 @@ jobs: strategy: matrix: dotnet: [ - { framework: netcoreapp2.1, version: 2.1.806 }, - { framework: netcoreapp3.1, version: 3.1.202 } + { framework: netcoreapp2.1, version: 2.1.x }, + { framework: netcoreapp3.1, version: 3.1.x }, + { framework: net5.0, version: 5.0.x }, ] name: ${{ matrix.dotnet.framework }} – run tests diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index f6ff6c3..a6d0209 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -12,8 +12,9 @@ jobs: strategy: matrix: dotnet: [ - { framework: netcoreapp2.1, version: 2.1.806 }, - { framework: netcoreapp3.1, version: 3.1.202 } + { framework: netcoreapp2.1, version: 2.1.x }, + { framework: netcoreapp3.1, version: 3.1.x }, + { framework: net5.0, version: 5.0.x }, ] name: ${{ matrix.dotnet.framework }} – run tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6243624..80d6ab7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,8 +11,9 @@ jobs: strategy: matrix: dotnet: [ - { framework: netcoreapp2.1, version: 2.1.806 }, - { framework: netcoreapp3.1, version: 3.1.202 } + { framework: netcoreapp2.1, version: 2.1.x }, + { framework: netcoreapp3.1, version: 3.1.x }, + { framework: net5.0, version: 5.0.x }, ] name: ${{ matrix.dotnet.framework }} – run tests @@ -62,7 +63,7 @@ jobs: - name: Setup dotnet uses: actions/setup-dotnet@v1 with: - dotnet-version: 3.1.202 + dotnet-version: 5.0.x - name: Build and publish library to NuGet run: | diff --git a/README.md b/README.md index 3de3eba..45f12cc 100644 --- a/README.md +++ b/README.md @@ -3,128 +3,110 @@ [![nuget](https://img.shields.io/nuget/v/Dodo.HttpClient.ResiliencePolicies?label=NuGet)](https://www.nuget.org/packages/Dodo.HttpClient.ResiliencePolicies) ![master](https://github.com/dodopizza/httpclient-resilience-policies/workflows/master/badge.svg) -The main goal of this library is to provide unified http request retrying policies for the HttpClient that just works. +Dodo.HttpClient.ResiliencePolicies library extends [IHttpClientBuilder](https://docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.ihttpclientbuilder) with easy to use resilience policies for the HttpClient. -Actually this library wraps awesome [Polly](https://github.com/App-vNext/Polly) library with the predefined settings to allow developers to use it as is without a deep dive to Polly. +In the world of microservices it is quite important to pay attention to resilience of communications between services. You have to think about things like retries, timeouts, circuit breakers, etc. +We already have a great library for this class of problems called [Polly](https://github.com/App-vNext/Polly). It is really powerful. Polly is like a Swiss knife gives you a lot of functionality, but you should know how and when to use it. It could be a complicated task. + +Main goal of our library is to hide this complexity from the end-users. It uses Polly under the hood and provides some pre-defined functionality with reasonable defaults and minimal settings to setup resilience policies atop of HttpClient. +You can just plug the with single line of code and your HttpClient will become much more robust than before. -The `DefaultPolicy` provided by this library combines `RetryPolicy`, `CircuitBreakerPolicy` and `TimeoutPolicy` under the hood. See the corresponding sections of the README. ## Functionality provided by the library -Library provides few methods which returns the IHttpClientBuilder and you may chain it with other HttpMessageHandler. +Library provides few methods which returns `IHttpClientBuilder` and you may chain it with other `HttpMessageHandler`. There are list of public methods to use: ```csharp -// Default policies for a single host environment using all defaults -IHttpClientBuilder AddDefaultPolicies(this IHttpClientBuilder clientBuilder); +// Pre-defined policies with defaults settings +IHttpClientBuilder AddResiliencePolicies(this IHttpClientBuilder clientBuilder); + +// Pre-defined policies with custom settings +IHttpClientBuilder AddResiliencePolicies(this IHttpClientBuilder clientBuilder, ResiliencePoliciesSettings settings) +``` -// Default policies for a single host environment with custom settings -IHttpClientBuilder AddDefaultPolicies(this IHttpClientBuilder clientBuilder, HttpClientSettings settings); +`AddResiliencePolicies` wraps HttpClient with four policies: -// Default policies for a multi host environments using all defaults -IHttpClientBuilder AddDefaultHostSpecificPolicies(this IHttpClientBuilder clientBuilder); +- Overall Timeout policy – timeout for entire request, after this time we are not interested in the result anymore. +- Retry policy – defines how much and how often we will attempt to send request again on failures. +- Circuit Breaker policy – defines when we should take a break in our retries if the upstream service doesn't respond. +- Timeout Per Try policy - timeout for each try (defined in Retry policy), after this time attempt considered as failure. -// Default policies for a multi host environments with custom settings -IHttpClientBuilder AddDefaultHostSpecificPolicies(this IHttpClientBuilder clientBuilder, HttpClientSettings settings); +Library also provides pre-configured HttpClient: -// Default JsonClient includes DefaultPolicies with custom settings +```csharp +// Pre-defined HttpClientFactory which is configured to work with `application/json` MIME media type and uses default ResiliencePolicies IHttpClientBuilder AddJsonClient( - this IServiceCollection sc, - Uri baseAddress, - HttpClientSettings settings, - string clientName = null) - where TClientInterface : class - where TClientImplementation : class, TClientInterface -``` + this IServiceCollection sc, + Uri baseAddress, + string clientName = null) -There are also available `HttpClientSettings`, `IRetrySettings` and `ICircuitBreakerSettings` to tune-in the default policies. See the corresponding sections of the README. +// Pre-defined HttpClientFactory which is configured to work with `application/json` MIME media type and uses ResiliencePolicies with custom settings +IHttpClientBuilder AddJsonClient( + this IServiceCollection sc, + Uri baseAddress, + ResiliencePoliciesSettings settings, + string clientName = null) +``` -## HttpClient configuration +Custom settings can be provided via `ResiliencePoliciesSettings` (see examples below). +Also you may check the [defaults](src/Dodo.HttpClient.ResiliencePolicies/Defaults.cs) provided by the library (all of this can be overriden in custom settings). -You have two options how to add HttpClient in your code. +## Usage examples -1. Just use default client provided by the library and add it to the `ServiceCollection` in the Startup like this: +1. Using default client provided by the library and add it to the `ServiceCollection` in the Startup like this: ```csharp - service // IServiceCollection - .AddJsonClient(...) // Default client with policies - ``` + using Dodo.HttpClientResiliencePolicies; + ... -2. You may add your own HttpClient and then add default policies. In this case it is important to configure Timeout property in the client: - - ```csharp - service // IServiceCollection - .AddHttpClient("named-client", - client => - { - client.Timeout = TimeSpan.FromMilliseconds(Defaults.Timeout.HttpClientTimeoutInMilliseconds); // Constant provided by the library - }) - .AddDefaultPolicies() // Default policies provided by the library + service // IServiceCollection + .AddJsonClient(...) // HttpClientFactory to build JsonClient provided by the library with all defaults ``` -Or if you use custom HttpClientSettings you may get client timeout value from the `HttpClientSettings.HttpClientTimeout` property instead of constant. - -Configure `HttpClient.Timeout` is important because HttpClient will use default value of 100 seconds without this configuration. `AddJsonClient` provided by the library is already pre-configured. - -More details about TimeoutPolicy in the corresponding section of the README. - -## Single host versus multi host environments - -You may notice that there are two group of methods: -`DefaultPolicy` for single host environment and `DefaultHostSpecificPolicy` for multi host environments. - -The single host environment means that our HttpClient send requests to a single host (the uri of host is never changed). It also means that if the CircuitBreaker will be opened, **all** requests to this host will be stopped for the duration of break. - -In the other hand in multi host environment we suppose that we use single client against multiple hosts. For example in the "country agnostic service" scenario when we use a single HttpClient to send requests against the several host for different countries with the same URL pattern like: `ru-host`, `us-host`, `ng-host`, etc. We can't use `DefaultPolicy` as with single host environment scenario. If the CircuitBreaker will be opened on the one host, ex. `ru-host`, all requests to all other hosts will be stopped too, because of the single HttpClient. `DefaultHostSpecificPolicy` handles this situation by "memorizing" the distinct hosts and policies will match requests to the specific hosts to avoid such situations. - -## Retry policy - -The retry policy handles the situation when the http request fails because of transient error and retries the attempt to complete the request. - -The library provides interface `IRetrySettings` to setup retry policy. There are two predefined implementations provided: +2. Add resilience policies with default settings to existing HttpClient -- `SimpleRetrySettings` which by default using [Exponential backoff](https://github.com/App-vNext/Polly/wiki/Retry#exponential-backoff) exponentially increase retry times for each attempt. -- `JitterRetrySettings` _(used by default)_ which is exponential too but used [JitterBackoff](https://github.com/App-vNext/Polly/wiki/Retry-with-jitter) to slightly modify retry times to prevent the situation when all of the requests will be attempt in the same time. - -The most important parameter in the retry policy is `RetryCount` which means each request may have at most `RetryCount + 1` attempts: initial request and all the retries in case of fail. - -You also may implement your own policy settings by implement the `IRetrySettings`. Also you may check the default values in the `Defaults` class. - -## CircuitBreaker Policy - -Circuit breaker's goal is to prevent requests to the server if it doesn't answer for a while to mostly of the requests. In practice the reason to have a circuit breaker is to prevent requests when server is down or overloaded. - -CircuitBreaker has several important parameters: - -- `FailureThreshold` means what percentage of failed requests should be for the CircuitBreaker to open. -- `MinimumThroughput` the minimum amount of the requests should be for the CircuitBreaker to open. -- `DurationOfBreak` amount of time when the CircuitBreaker prevents all the requests to the host. -- `SamplingDuration` during this amount of time CircuitBreaker will count success/failed requests and check two parameters above to make a decision should it opens or not. - -[More information about Circuit Breakers in the Polly documentation](https://github.com/App-vNext/Polly/wiki/Advanced-Circuit-Breaker). - -The library provides interface `ICircuitBreakerSettings` to setup circuit breaker policy and default implementation `CircuitBreakerSettings` which has a several constructors to tune-in parameters above. - -You also may implement your own policy settings by implement the `ICircuitBreakerSettings`. Also you may check the default values in the `Defaults` class. - -## Timeout policy - -The timeout policy cancels requests in case of long responses (server doesn't response for a long time). - -There are only two settings to configure the timeouts: - -- `HttpClientTimeout` which set the timeout to the whole HttpClient. -- `TimeoutPerTry` which set the timeout for a single request attempt. - -Understanding of the difference between this two parameters is very important to create robust policies. - -`HttpClientTimeout` is set to the whole HttpClient. Actually it set `HttpClient.Timeout` property. When this timeout exceeded the HttpClient throws `TaskCancelledException` which prevent all requests in the current session. Such a timeout will not be retried even if not all retry attempts have been made. + ```csharp + using Dodo.HttpClientResiliencePolicies; + ... + + service // IServiceCollection + .AddHttpClient(...) // Existing HttpClientFactory + .AddResiliencePolicies() // Pre-defined resilience policies with all defaults + ``` -`TimeoutPerTry` just setup the timeout for a single request. If this timeout exceeded request will be cancelled and retried even if the server worked correctly and finally response with 200 status code. +3. Define custom settings for resilience policies: -Notice that the `HttpClientTimeout` should be **greater** than `TimeoutPerRetry` otherwise you requests will never be retried. + ```csharp + using Dodo.HttpClientResiliencePolicies; + ... + + var settings = new ResiliencePoliciesSettings + { + OverallTimeout = TimeSpan.FromSeconds(50), + TimeoutPerTry = TimeSpan.FromSeconds(2), + RetryPolicySettings = RetryPolicySettings.Jitter(2, TimeSpan.FromMilliseconds(50)), + CircuitBreakerPolicySettings = new CircuitBreakerPolicySettings( + failureThreshold: 0.5, + minimumThroughput: 10, + durationOfBreak: TimeSpan.FromSeconds(5), + samplingDuration: TimeSpan.FromSeconds(30) + ), + OnRetry = (response, time) => { ... }, // Handle retry event. For example you may add logging here + OnBreak = (response, time) => { ... }, // Handle CircuitBreaker break event. For example you may add logging here + OnReset = () => {...}, // Handle CircuitBreaker reset event. For example you may add logging here + OnHalfOpen = () => {...}, // Handle CircuitBreaker reset event. For example you may add logging here + } + ``` -One more important thing is the order of the policies. `TimeoutPolicy` should always be **after** the RetryPolicy otherwise the `TimeoutPerRetry` parameter will play the same role as a `HttpClientTimeout`. [Clarification from the Polly documentation](https://github.com/App-vNext/Polly/wiki/Polly-and-HttpClientFactory#use-case-applying-timeouts). + You may provide only properties which you want to customize, the defaults will be used for the rest. + You may choose different retry strategies. RetryPolicySettings provides static methods to choose Constant, Linear, Exponential or Jitter (exponential with jitter backoff) strategies. Jitter is used as default strategy. + + You may provide settings as a parameter to `.AddJsonClient(...)` or `.AddResiliencePolicies()` to override default settings. + +## References -You may setup your own timeout parameters by providing it to the `HttpClientSettings` constructor. Also you may check the default values in the `Defaults` class. +- Check Polly [documentation](https://github.com/App-vNext/Polly/wiki) to learn more about each policy. +- [Use IHttpClientFactory to implement resilient HTTP requests](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests). +- [Cloud design patterns](https://docs.microsoft.com/en-us/azure/architecture/patterns/retry). \ No newline at end of file diff --git a/dodopizza-logo.png b/images/dodopizza-logo.png similarity index 100% rename from dodopizza-logo.png rename to images/dodopizza-logo.png diff --git a/lgtm.yml b/lgtm.yml new file mode 100644 index 0000000..1dfb3d3 --- /dev/null +++ b/lgtm.yml @@ -0,0 +1,5 @@ +extraction: + csharp: + index: + dotnet: + version: 5.0.100 diff --git a/src/Dodo.HttpClient.ResiliencePolicies.Tests/CircuitBreakerPolicyTests.cs b/src/Dodo.HttpClient.ResiliencePolicies.Tests/CircuitBreakerPolicyTests.cs index 31dab63..111f270 100644 --- a/src/Dodo.HttpClient.ResiliencePolicies.Tests/CircuitBreakerPolicyTests.cs +++ b/src/Dodo.HttpClient.ResiliencePolicies.Tests/CircuitBreakerPolicyTests.cs @@ -1,13 +1,13 @@ using System; using System.Net; using System.Threading.Tasks; -using Dodo.HttpClient.ResiliencePolicies.CircuitBreakerSettings; -using Dodo.HttpClient.ResiliencePolicies.RetrySettings; -using Dodo.HttpClient.ResiliencePolicies.Tests.DSL; +using Dodo.HttpClientResiliencePolicies.CircuitBreakerPolicy; +using Dodo.HttpClientResiliencePolicies.RetryPolicy; +using Dodo.HttpClientResiliencePolicies.Tests.DSL; using NUnit.Framework; using Polly.CircuitBreaker; -namespace Dodo.HttpClient.ResiliencePolicies.Tests +namespace Dodo.HttpClientResiliencePolicies.Tests { [TestFixture] public class CircuitBreakerTests @@ -15,38 +15,42 @@ public class CircuitBreakerTests [Test] public void Should_break_after_4_concurrent_calls() { + const int retryCount = 5; const int minimumThroughput = 2; - var retrySettings = new SimpleRetrySettings( - retryCount: 5, - sleepDurationProvider: i => TimeSpan.FromMilliseconds(50)); + var settings = new ResiliencePoliciesSettings + { + OverallTimeout = TimeSpan.FromSeconds(5), + RetryPolicySettings = RetryPolicySettings.Constant(retryCount, TimeSpan.FromMilliseconds(100)), + CircuitBreakerPolicySettings = BuildCircuitBreakerSettings(minimumThroughput), + }; var wrapper = Create.HttpClientWrapperWrapperBuilder .WithStatusCode(HttpStatusCode.ServiceUnavailable) - .WithHttpClientTimeout(TimeSpan.FromSeconds(5)) - .WithCircuitBreakerSettings(BuildCircuitBreakerSettings(minimumThroughput)) - .WithRetrySettings(retrySettings) + .WithResiliencePolicySettings(settings) .Please(); const int taskCount = 4; Assert.CatchAsync(async () => await Helper.InvokeMultipleHttpRequests(wrapper.Client, taskCount)); - Assert.AreEqual(minimumThroughput, wrapper.NumberOfCalls); + Assert.LessOrEqual(wrapper.NumberOfCalls, taskCount); } [Test] public async Task Should_Open_Circuit_Breaker_for_RU_and_do_not_affect_EE() { + const int retryCount = 5; const int minimumThroughput = 2; - var retrySettings = new SimpleRetrySettings( - retryCount: 5, - sleepDurationProvider: i => TimeSpan.FromMilliseconds(50)); + var settings = new ResiliencePoliciesSettings + { + OverallTimeout = TimeSpan.FromSeconds(5), + RetryPolicySettings =RetryPolicySettings.Constant(retryCount, TimeSpan.FromMilliseconds(50)), + CircuitBreakerPolicySettings = BuildCircuitBreakerSettings(minimumThroughput), + }; var wrapper = Create.HttpClientWrapperWrapperBuilder .WithHostAndStatusCode("ru-prod.com", HttpStatusCode.ServiceUnavailable) .WithHostAndStatusCode("ee-prod.com", HttpStatusCode.OK) - .WithHttpClientTimeout(TimeSpan.FromSeconds(5)) - .WithCircuitBreakerSettings(BuildCircuitBreakerSettings(minimumThroughput)) - .WithRetrySettings(retrySettings) - .PleaseHostSpecific(); + .WithResiliencePolicySettings(settings) + .Please(); const int taskCount = 4; Assert.CatchAsync(async () => @@ -59,13 +63,14 @@ public async Task Should_Open_Circuit_Breaker_for_RU_and_do_not_affect_EE() Assert.AreEqual(minimumThroughput + taskCount, wrapper.NumberOfCalls); } - private static ICircuitBreakerSettings BuildCircuitBreakerSettings(int throughput) + private static CircuitBreakerPolicySettings BuildCircuitBreakerSettings(int throughput) { - return new CircuitBreakerSettings.CircuitBreakerSettings( + return new CircuitBreakerPolicySettings( failureThreshold: 0.5, minimumThroughput: throughput, durationOfBreak: TimeSpan.FromMinutes(1), - samplingDuration: TimeSpan.FromMilliseconds(20)); + samplingDuration: TimeSpan.FromMilliseconds(20) + ); } } } diff --git a/src/Dodo.HttpClient.ResiliencePolicies.Tests/DSL/Create.cs b/src/Dodo.HttpClient.ResiliencePolicies.Tests/DSL/Create.cs index 84864f5..6f53d04 100644 --- a/src/Dodo.HttpClient.ResiliencePolicies.Tests/DSL/Create.cs +++ b/src/Dodo.HttpClient.ResiliencePolicies.Tests/DSL/Create.cs @@ -1,4 +1,4 @@ -namespace Dodo.HttpClient.ResiliencePolicies.Tests.DSL +namespace Dodo.HttpClientResiliencePolicies.Tests.DSL { public static class Create { diff --git a/src/Dodo.HttpClient.ResiliencePolicies.Tests/DSL/HttpClientWrapper.cs b/src/Dodo.HttpClient.ResiliencePolicies.Tests/DSL/HttpClientWrapper.cs index b113361..592d9d7 100644 --- a/src/Dodo.HttpClient.ResiliencePolicies.Tests/DSL/HttpClientWrapper.cs +++ b/src/Dodo.HttpClient.ResiliencePolicies.Tests/DSL/HttpClientWrapper.cs @@ -1,9 +1,8 @@ -using Dodo.HttpClient.ResiliencePolicies.Tests.Fakes; +using System.Net.Http; +using Dodo.HttpClientResiliencePolicies.Tests.Fakes; -namespace Dodo.HttpClient.ResiliencePolicies.Tests.DSL +namespace Dodo.HttpClientResiliencePolicies.Tests.DSL { - using HttpClient = System.Net.Http.HttpClient; - public class HttpClientWrapper { private readonly HttpClient _client; diff --git a/src/Dodo.HttpClient.ResiliencePolicies.Tests/DSL/HttpClientWrapperBuilder.cs b/src/Dodo.HttpClient.ResiliencePolicies.Tests/DSL/HttpClientWrapperBuilder.cs index 6d8b612..2f03184 100644 --- a/src/Dodo.HttpClient.ResiliencePolicies.Tests/DSL/HttpClientWrapperBuilder.cs +++ b/src/Dodo.HttpClient.ResiliencePolicies.Tests/DSL/HttpClientWrapperBuilder.cs @@ -2,22 +2,23 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; -using Dodo.HttpClient.ResiliencePolicies.CircuitBreakerSettings; -using Dodo.HttpClient.ResiliencePolicies.RetrySettings; -using Dodo.HttpClient.ResiliencePolicies.Tests.Fakes; +using Dodo.HttpClientResiliencePolicies.Tests.Fakes; using Microsoft.Extensions.DependencyInjection; -namespace Dodo.HttpClient.ResiliencePolicies.Tests.DSL +namespace Dodo.HttpClientResiliencePolicies.Tests.DSL { public sealed class HttpClientWrapperBuilder { private const string ClientName = "TestClient"; - private readonly Dictionary _hostsResponseCodes = new Dictionary(); - private IRetrySettings _retrySettings; - private ICircuitBreakerSettings _circuitBreakerSettings; - private TimeSpan _httpClientTimeout = TimeSpan.FromDays(1); - private TimeSpan _timeoutPerTry = TimeSpan.FromDays(1); + private readonly Uri _uri = new Uri("http://localhost"); + + private readonly Dictionary _hostsResponseCodes = + new Dictionary(); + + private ResiliencePoliciesSettings _resiliencePoliciesSettings; private TimeSpan _responseLatency = TimeSpan.Zero; + private TimeSpan? _retryAfterSpan; + private DateTime? _retryAfterDate; public HttpClientWrapperBuilder WithStatusCode(HttpStatusCode statusCode) { @@ -31,80 +32,55 @@ public HttpClientWrapperBuilder WithHostAndStatusCode(string host, HttpStatusCod return this; } - public HttpClientWrapperBuilder WithHttpClientTimeout(TimeSpan httpClientTimeout) - { - _httpClientTimeout = httpClientTimeout; - return this; - } - - public HttpClientWrapperBuilder WithTimeoutPerTry(TimeSpan timeoutPerTry) + public HttpClientWrapperBuilder WithResiliencePolicySettings(ResiliencePoliciesSettings resiliencePoliciesSettings) { - _timeoutPerTry = timeoutPerTry; + _resiliencePoliciesSettings = resiliencePoliciesSettings; return this; } - public HttpClientWrapperBuilder WithRetrySettings(IRetrySettings retrySettings) + public HttpClientWrapperBuilder WithResponseLatency(TimeSpan responseLatency) { - _retrySettings = retrySettings; + _responseLatency = responseLatency; return this; } - public HttpClientWrapperBuilder WithCircuitBreakerSettings(ICircuitBreakerSettings circuitBreakerSettings) + public HttpClientWrapperBuilder WithRetryAfterHeader(TimeSpan delay) { - _circuitBreakerSettings = circuitBreakerSettings; + _retryAfterSpan = delay; return this; } - public HttpClientWrapperBuilder WithResponseLatency(TimeSpan responseLatency) + public HttpClientWrapperBuilder WithRetryAfterHeader(DateTime date) { - _responseLatency = responseLatency; + _retryAfterDate = date; return this; } public HttpClientWrapper Please() { var handler = new MockHttpMessageHandler(_hostsResponseCodes, _responseLatency); - var services = new ServiceCollection(); - services - .AddHttpClient(ClientName, c => { c.Timeout = _httpClientTimeout; }) - .AddDefaultPolicies(BuildClientSettings()) - .ConfigurePrimaryHttpMessageHandler(() => handler); - var serviceProvider = services.BuildServiceProvider(); - var factory = serviceProvider.GetService(); - var client = factory.CreateClient(ClientName); - return new HttpClientWrapper(client, handler); - } + if (_retryAfterDate.HasValue) + { + handler.SetRetryAfterResponseHeader(_retryAfterDate.Value); + } - public HttpClientWrapper PleaseHostSpecific() - { - var handler = new MockHttpMessageHandler(_hostsResponseCodes, _responseLatency); + if (_retryAfterSpan.HasValue) + { + handler.SetRetryAfterResponseHeader(_retryAfterSpan.Value); + } + + var settings = _resiliencePoliciesSettings ?? new ResiliencePoliciesSettings(); var services = new ServiceCollection(); services - .AddHttpClient(ClientName, c => { c.Timeout = _httpClientTimeout; }) - .AddDefaultHostSpecificPolicies(BuildClientSettings()) + .AddJsonClient(_uri, settings, ClientName) .ConfigurePrimaryHttpMessageHandler(() => handler); var serviceProvider = services.BuildServiceProvider(); var factory = serviceProvider.GetService(); - var client = factory.CreateClient(ClientName); + var client = factory?.CreateClient(ClientName) ?? + throw new NullReferenceException($"\"{nameof(factory)}\" was not created properly"); return new HttpClientWrapper(client, handler); } - - private HttpClientSettings BuildClientSettings() - { - var defaultCircuitBreakerSettings = _circuitBreakerSettings ?? new CircuitBreakerSettings.CircuitBreakerSettings( - failureThreshold: 0.5, - minimumThroughput: int.MaxValue, - durationOfBreak: TimeSpan.FromMilliseconds(1), - samplingDuration: TimeSpan.FromMilliseconds(20) - ); - - return new HttpClientSettings( - _httpClientTimeout, - _timeoutPerTry, - _retrySettings ?? JitterRetrySettings.Default(), - defaultCircuitBreakerSettings); - } } } diff --git a/src/Dodo.HttpClient.ResiliencePolicies.Tests/Dodo.HttpClient.ResiliencePolicies.Tests.csproj b/src/Dodo.HttpClient.ResiliencePolicies.Tests/Dodo.HttpClient.ResiliencePolicies.Tests.csproj index dea7a65..567c048 100644 --- a/src/Dodo.HttpClient.ResiliencePolicies.Tests/Dodo.HttpClient.ResiliencePolicies.Tests.csproj +++ b/src/Dodo.HttpClient.ResiliencePolicies.Tests/Dodo.HttpClient.ResiliencePolicies.Tests.csproj @@ -1,18 +1,20 @@  - netcoreapp2.1;netcoreapp3.1 + netcoreapp2.1;netcoreapp3.1;net5.0 netcoreapp2.1 8.0 false + Dodo.HttpClientResiliencePolicies.Tests + true - - + + - - + + diff --git a/src/Dodo.HttpClient.ResiliencePolicies.Tests/Fakes/MockHttpMessageHandler.cs b/src/Dodo.HttpClient.ResiliencePolicies.Tests/Fakes/MockHttpMessageHandler.cs index 9f8ebfc..fc8cdd6 100644 --- a/src/Dodo.HttpClient.ResiliencePolicies.Tests/Fakes/MockHttpMessageHandler.cs +++ b/src/Dodo.HttpClient.ResiliencePolicies.Tests/Fakes/MockHttpMessageHandler.cs @@ -2,10 +2,11 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; -namespace Dodo.HttpClient.ResiliencePolicies.Tests.Fakes +namespace Dodo.HttpClientResiliencePolicies.Tests.Fakes { public class MockHttpMessageHandler : HttpMessageHandler { @@ -15,6 +16,9 @@ public class MockHttpMessageHandler : HttpMessageHandler public long NumberOfCalls => _numberOfCalls; private long _numberOfCalls = 0; + private DateTime? _retryAfterDate; + private TimeSpan? _retryAfterSpan; + public MockHttpMessageHandler(HttpStatusCode statusCode, TimeSpan latency) : this(new Dictionary {{string.Empty, statusCode}}, latency) { @@ -34,6 +38,15 @@ public MockHttpMessageHandler(Dictionary hostsResponseCo _latency = latency; } + public void SetRetryAfterResponseHeader(DateTime retryAfterDate) + { + _retryAfterDate = retryAfterDate; + } + + public void SetRetryAfterResponseHeader(TimeSpan retryAfterSpan) + { + _retryAfterSpan = retryAfterSpan; + } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) @@ -46,12 +59,24 @@ public MockHttpMessageHandler(Dictionary hostsResponseCo ? _hostsResponseCodes[request.RequestUri.Host] : _hostsResponseCodes[string.Empty]; - return await Task.FromResult( - new HttpResponseMessage - { - RequestMessage = request, - StatusCode = statusCode - }); + var result = new HttpResponseMessage + { + RequestMessage = request, + StatusCode = statusCode + }; + + if (_retryAfterDate.HasValue) + { + result.Headers.RetryAfter = new RetryConditionHeaderValue(_retryAfterDate.Value); + } + + if (_retryAfterSpan.HasValue) + { + result.Headers.RetryAfter + = new RetryConditionHeaderValue(_retryAfterSpan.Value); + } + + return await Task.FromResult(result); } } } diff --git a/src/Dodo.HttpClient.ResiliencePolicies.Tests/Fakes/MockJsonClient.cs b/src/Dodo.HttpClient.ResiliencePolicies.Tests/Fakes/MockJsonClient.cs index ba840d1..6fdccc7 100644 --- a/src/Dodo.HttpClient.ResiliencePolicies.Tests/Fakes/MockJsonClient.cs +++ b/src/Dodo.HttpClient.ResiliencePolicies.Tests/Fakes/MockJsonClient.cs @@ -1,12 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Dodo.HttpClient.ResiliencePolicies.Tests.Fakes +namespace Dodo.HttpClientResiliencePolicies.Tests.Fakes { public interface IMockJsonClient - { } + { + } public class MockJsonClient : IMockJsonClient - { } + { + } } diff --git a/src/Dodo.HttpClient.ResiliencePolicies.Tests/Helper.cs b/src/Dodo.HttpClient.ResiliencePolicies.Tests/Helper.cs index 1bc2ce2..377e9a0 100644 --- a/src/Dodo.HttpClient.ResiliencePolicies.Tests/Helper.cs +++ b/src/Dodo.HttpClient.ResiliencePolicies.Tests/Helper.cs @@ -1,10 +1,8 @@ using System.Net.Http; using System.Threading.Tasks; -namespace Dodo.HttpClient.ResiliencePolicies.Tests +namespace Dodo.HttpClientResiliencePolicies.Tests { - using HttpClient = System.Net.Http.HttpClient; - public static class Helper { public static async Task InvokeMultipleHttpRequests(HttpClient client, int taskCount, string uri = "http://localhost") diff --git a/src/Dodo.HttpClient.ResiliencePolicies.Tests/HttpClientBuilderExtensionsTests.cs b/src/Dodo.HttpClient.ResiliencePolicies.Tests/HttpClientBuilderExtensionsTests.cs deleted file mode 100644 index eaf4d8f..0000000 --- a/src/Dodo.HttpClient.ResiliencePolicies.Tests/HttpClientBuilderExtensionsTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Dodo.HttpClient.ResiliencePolicies.Tests.Fakes; -using Microsoft.Extensions.DependencyInjection; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; - -namespace Dodo.HttpClient.ResiliencePolicies.Tests -{ - [TestFixture] - public class HttpClientBuilderExtensionsTests - { - [Test] - public void AddJsonClient_WithNullClientName_ConfiguresDefaultJsonClient() - { - // Arrange - var serviceCollection = new ServiceCollection(); - - // Act1 - serviceCollection.AddJsonClient( - new Uri("http://example.com/"), - HttpClientSettings.Default()); - - var services = serviceCollection.BuildServiceProvider(); - - var factory = services.GetRequiredService(); - - // Act2 - var client = factory.CreateClient(nameof(IMockJsonClient)); - - // Assert - Assert.NotNull(client); - Assert.AreEqual("http://example.com/", client.BaseAddress.AbsoluteUri); - } - - [Test] - public void AddJsonClient_WithSpecificClientName_ConfiguresSpecificJsonClient() - { - // Arrange - var serviceCollection = new ServiceCollection(); - - // Act1 - serviceCollection.AddJsonClient( - new Uri("http://example.com/"), - HttpClientSettings.Default(), - "specificName"); - - var services = serviceCollection.BuildServiceProvider(); - - var factory = services.GetRequiredService(); - - // Act2 - var client = factory.CreateClient("specificName"); - - // Assert - Assert.NotNull(client); - Assert.AreEqual("http://example.com/", client.BaseAddress.AbsoluteUri); - } - } -} diff --git a/src/Dodo.HttpClient.ResiliencePolicies.Tests/HttpClientFactoryServiceCollectionExtensionsTests.cs b/src/Dodo.HttpClient.ResiliencePolicies.Tests/HttpClientFactoryServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..384983c --- /dev/null +++ b/src/Dodo.HttpClient.ResiliencePolicies.Tests/HttpClientFactoryServiceCollectionExtensionsTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Net.Http; +using Dodo.HttpClientResiliencePolicies.Tests.Fakes; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace Dodo.HttpClientResiliencePolicies.Tests +{ + [TestFixture] + public class HttpClientFactoryServiceCollectionExtensionsTests + { + [Test] + public void When_AddJsonClient_WithNullClientName_than_ConfiguresDefaultJsonClient() + { + // Arrange + var serviceCollection = new ServiceCollection(); + + // Act1 + serviceCollection.AddJsonClient( + new Uri("http://example.com/")); + + var services = serviceCollection.BuildServiceProvider(); + + var factory = services.GetRequiredService(); + + // Act2 + var client = factory.CreateClient(nameof(IMockJsonClient)); + + // Assert + Assert.NotNull(client); + Assert.AreEqual("http://example.com/", client.BaseAddress.AbsoluteUri); + } + + [Test] + public void When_AddJsonClient_WithSpecificClientName_than_ConfiguresSpecificJsonClient() + { + // Arrange + var serviceCollection = new ServiceCollection(); + + // Act1 + serviceCollection.AddJsonClient( + new Uri("http://example.com/"), + "specificName"); + + var services = serviceCollection.BuildServiceProvider(); + + var factory = services.GetRequiredService(); + + // Act2 + var client = factory.CreateClient("specificName"); + + // Assert + Assert.NotNull(client); + Assert.AreEqual("http://example.com/", client.BaseAddress.AbsoluteUri); + } + + [Test] + public void When_AddJsonClient_WithSpecificOverallTimeout_than_ConfiguresSpecificJsonClientTimeout() + { + // Arrange + var serviceCollection = new ServiceCollection(); + var overallTimeout = TimeSpan.FromSeconds(300); + + // Act1 + serviceCollection.AddJsonClient( + new Uri("http://example.com/"), + new ResiliencePoliciesSettings + { + OverallTimeout = overallTimeout, + }); + + var services = serviceCollection.BuildServiceProvider(); + + var factory = services.GetRequiredService(); + + // Act2 + var client = factory.CreateClient(nameof(IMockJsonClient)); + + // Assert + Assert.NotNull(client); + Assert.AreEqual(overallTimeout.Add(TimeSpan.FromMilliseconds(1000)), client.Timeout); + } + + [Test] + public void When_AddJsonClient_WithDefaultOverallTimeout_than_DefaultJsonClientTimeout() + { + // Arrange + var serviceCollection = new ServiceCollection(); + + // Act1 + serviceCollection.AddJsonClient( + new Uri("http://example.com/")); + + var services = serviceCollection.BuildServiceProvider(); + + var factory = services.GetRequiredService(); + + // Act2 + var client = factory.CreateClient(nameof(IMockJsonClient)); + + // Assert + Assert.NotNull(client); + var overallTimeout = TimeSpan.FromMilliseconds(Defaults.Timeout.TimeoutOverallInMilliseconds); + Assert.AreEqual(overallTimeout.Add(TimeSpan.FromMilliseconds(1000)), client.Timeout); + } + } +} diff --git a/src/Dodo.HttpClient.ResiliencePolicies.Tests/ResiliencePolicySettingsTests.cs b/src/Dodo.HttpClient.ResiliencePolicies.Tests/ResiliencePolicySettingsTests.cs new file mode 100644 index 0000000..a196d09 --- /dev/null +++ b/src/Dodo.HttpClient.ResiliencePolicies.Tests/ResiliencePolicySettingsTests.cs @@ -0,0 +1,50 @@ +using System.Net; +using System.Threading.Tasks; +using Dodo.HttpClientResiliencePolicies.RetryPolicy; +using Dodo.HttpClientResiliencePolicies.Tests.DSL; +using NUnit.Framework; + +namespace Dodo.HttpClientResiliencePolicies.Tests +{ + [TestFixture] + public class ResiliencePolicySettingsTests + { + [Test] + public async Task Should_catch_retry_in_OnRetry_handler_passed_after_RetryPolicySettings() + { + var retryCounter = 0; + var settings = new ResiliencePoliciesSettings + { + RetryPolicySettings = RetryPolicySettings.Constant(Defaults.Retry.RetryCount), + OnRetry = (_, __) => { retryCounter++; }, + }; + var wrapper = Create.HttpClientWrapperWrapperBuilder + .WithStatusCode(HttpStatusCode.ServiceUnavailable) + .WithResiliencePolicySettings(settings) + .Please(); + + await wrapper.Client.GetAsync("http://localhost"); + + Assert.AreEqual(Defaults.Retry.RetryCount, retryCounter); + } + + [Test] + public async Task Should_catch_retry_in_OnRetry_handler_passed_before_RetryPolicySettings() + { + var retryCounter = 0; + var settings = new ResiliencePoliciesSettings + { + OnRetry = (_, __) => { retryCounter++; }, + RetryPolicySettings = RetryPolicySettings.Constant(Defaults.Retry.RetryCount), + }; + var wrapper = Create.HttpClientWrapperWrapperBuilder + .WithStatusCode(HttpStatusCode.ServiceUnavailable) + .WithResiliencePolicySettings(settings) + .Please(); + + await wrapper.Client.GetAsync("http://localhost"); + + Assert.AreEqual(Defaults.Retry.RetryCount, retryCounter); + } + } +} diff --git a/src/Dodo.HttpClient.ResiliencePolicies.Tests/RetryPolicyTests.cs b/src/Dodo.HttpClient.ResiliencePolicies.Tests/RetryPolicyTests.cs index 65d224f..2e11897 100644 --- a/src/Dodo.HttpClient.ResiliencePolicies.Tests/RetryPolicyTests.cs +++ b/src/Dodo.HttpClient.ResiliencePolicies.Tests/RetryPolicyTests.cs @@ -1,15 +1,17 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; -using Dodo.HttpClient.ResiliencePolicies.RetrySettings; -using Dodo.HttpClient.ResiliencePolicies.Tests.DSL; +using Dodo.HttpClientResiliencePolicies.RetryPolicy; +using Dodo.HttpClientResiliencePolicies.Tests.DSL; using NUnit.Framework; using Polly; +using Polly.Timeout; -namespace Dodo.HttpClient.ResiliencePolicies.Tests +namespace Dodo.HttpClientResiliencePolicies.Tests { [TestFixture] public class RetryPolicyTests @@ -18,10 +20,13 @@ public class RetryPolicyTests public async Task Should_retry_3_times_when_client_returns_503() { const int retryCount = 3; - var retrySettings = new SimpleRetrySettings(retryCount); + var settings = new ResiliencePoliciesSettings + { + RetryPolicySettings = RetryPolicySettings.Constant(retryCount, TimeSpan.FromMilliseconds(1)), + }; var wrapper = Create.HttpClientWrapperWrapperBuilder .WithStatusCode(HttpStatusCode.ServiceUnavailable) - .WithRetrySettings(retrySettings) + .WithResiliencePolicySettings(settings) .Please(); var result = await wrapper.Client.GetAsync("http://localhost"); @@ -34,11 +39,13 @@ public async Task Should_retry_3_times_when_client_returns_503() public async Task Should_retry_6_times_for_two_threads_when_client_returns_503() { const int retryCount = 3; - var retrySettings = - new JitterRetrySettings(retryCount, medianFirstRetryDelay: TimeSpan.FromMilliseconds(50)); + var settings = new ResiliencePoliciesSettings + { + RetryPolicySettings = RetryPolicySettings.Jitter(retryCount, TimeSpan.FromMilliseconds(50)), + }; var wrapper = Create.HttpClientWrapperWrapperBuilder .WithStatusCode(HttpStatusCode.ServiceUnavailable) - .WithRetrySettings(retrySettings) + .WithResiliencePolicySettings(settings) .Please(); const int taskCount = 2; @@ -52,13 +59,15 @@ public async Task Should_separately_distribute_retry_attempts_for_multiple_tasks { const int retryCount = 3; var retryAttempts = new Dictionary>(); - var retrySettings = new JitterRetrySettings( - retryCount, - medianFirstRetryDelay: TimeSpan.FromMilliseconds(50), - onRetry: BuildOnRetryAction(retryAttempts)); + var settings = new ResiliencePoliciesSettings + { + RetryPolicySettings = RetryPolicySettings.Jitter(retryCount, TimeSpan.FromMilliseconds(50)), + OnRetry = BuildOnRetryAction(retryAttempts), + }; + var wrapper = Create.HttpClientWrapperWrapperBuilder .WithStatusCode(HttpStatusCode.ServiceUnavailable) - .WithRetrySettings(retrySettings) + .WithResiliencePolicySettings(settings) .Please(); const int taskCount = 2; @@ -71,10 +80,13 @@ public async Task Should_separately_distribute_retry_attempts_for_multiple_tasks public async Task Should_retry_when_client_returns_500() { const int retryCount = 3; - var retrySettings = new SimpleRetrySettings(retryCount); + var settings = new ResiliencePoliciesSettings + { + RetryPolicySettings = RetryPolicySettings.Constant(retryCount, TimeSpan.FromMilliseconds(1)), + }; var wrapper = Create.HttpClientWrapperWrapperBuilder .WithStatusCode(HttpStatusCode.InternalServerError) - .WithRetrySettings(retrySettings) + .WithResiliencePolicySettings(settings) .Please(); await wrapper.Client.GetAsync("http://localhost"); @@ -82,7 +94,47 @@ public async Task Should_retry_when_client_returns_500() Assert.AreEqual(retryCount + 1, wrapper.NumberOfCalls); } - private Action, TimeSpan> BuildOnRetryAction( + [Test] + public async Task Should_retry_sleep_longer_when_RetryAfterDecorator_is_on() + { + const int retryCount = 3; + var settings = new ResiliencePoliciesSettings + { + RetryPolicySettings = RetryPolicySettings.Constant(retryCount), + }; + var wrapper = Create.HttpClientWrapperWrapperBuilder + .WithRetryAfterHeader(TimeSpan.FromSeconds(1)) + .WithStatusCode(HttpStatusCode.InternalServerError) + .WithResiliencePolicySettings(settings) + .Please(); + + var stopWatch = Stopwatch.StartNew(); + await wrapper.Client.GetAsync("http://localhost"); + stopWatch.Stop(); + + Assert.That(3.0d, Is.GreaterThanOrEqualTo(stopWatch.Elapsed.TotalSeconds).Within(0.1)); + } + + [Test] + public void Should_catch_timeout_because_of_overall_less_then_sleep_duration_of_RetryAfterDecorator() + { + const int retryCount = 3; + var settings = new ResiliencePoliciesSettings + { + OverallTimeout = TimeSpan.FromSeconds(2), + RetryPolicySettings = RetryPolicySettings.Constant(retryCount), + }; + var wrapper = Create.HttpClientWrapperWrapperBuilder + .WithRetryAfterHeader(TimeSpan.FromSeconds(1)) + .WithStatusCode(HttpStatusCode.InternalServerError) + .WithResiliencePolicySettings(settings) + .Please(); + + Assert.CatchAsync(async () => + await wrapper.Client.GetAsync("http://localhost")); + } + + private static Action, TimeSpan> BuildOnRetryAction( IDictionary> retryAttempts) { return (result, span) => @@ -94,7 +146,7 @@ public async Task Should_retry_when_client_returns_500() } else { - retryAttempts[taskId] = new List {span}; + retryAttempts[taskId] = new List { span }; } }; } diff --git a/src/Dodo.HttpClient.ResiliencePolicies.Tests/TimeoutPolicyTests.cs b/src/Dodo.HttpClient.ResiliencePolicies.Tests/TimeoutPolicyTests.cs index cfb2d37..81b627b 100644 --- a/src/Dodo.HttpClient.ResiliencePolicies.Tests/TimeoutPolicyTests.cs +++ b/src/Dodo.HttpClient.ResiliencePolicies.Tests/TimeoutPolicyTests.cs @@ -1,12 +1,11 @@ using System; using System.Net; -using System.Threading.Tasks; -using Dodo.HttpClient.ResiliencePolicies.RetrySettings; -using Dodo.HttpClient.ResiliencePolicies.Tests.DSL; +using Dodo.HttpClientResiliencePolicies.RetryPolicy; +using Dodo.HttpClientResiliencePolicies.Tests.DSL; using NUnit.Framework; using Polly.Timeout; -namespace Dodo.HttpClient.ResiliencePolicies.Tests +namespace Dodo.HttpClientResiliencePolicies.Tests { [TestFixture] public class TimeoutPolicyTests @@ -15,14 +14,15 @@ public class TimeoutPolicyTests public void Should_retry_5_times_200_status_code_because_of_per_try_timeout() { const int retryCount = 5; - var retrySettings = new SimpleRetrySettings( - retryCount, - sleepDurationProvider: i => TimeSpan.FromMilliseconds(200)); + var settings = new ResiliencePoliciesSettings + { + TimeoutPerTry = TimeSpan.FromMilliseconds(100), + RetryPolicySettings = RetryPolicySettings.Constant(retryCount, TimeSpan.FromMilliseconds(200)), + }; var wrapper = Create.HttpClientWrapperWrapperBuilder .WithStatusCode(HttpStatusCode.OK) .WithResponseLatency(TimeSpan.FromMilliseconds(200)) - .WithTimeoutPerTry(TimeSpan.FromMilliseconds(100)) - .WithRetrySettings(retrySettings) + .WithResiliencePolicySettings(settings) .Please(); Assert.CatchAsync(async () => @@ -31,40 +31,80 @@ public void Should_retry_5_times_200_status_code_because_of_per_try_timeout() Assert.AreEqual(retryCount + 1, wrapper.NumberOfCalls); } + [Test] + public void Should_fail_on_HttpClient_timeout_with_retry() + { + const int retryCount = 5; + var settings = new ResiliencePoliciesSettings + { + OverallTimeout = TimeSpan.FromMilliseconds(200), + RetryPolicySettings = RetryPolicySettings.Constant(retryCount, TimeSpan.FromMilliseconds(1)), + }; + var wrapper = Create.HttpClientWrapperWrapperBuilder + .WithStatusCode(HttpStatusCode.ServiceUnavailable) + .WithResponseLatency(TimeSpan.FromMilliseconds(100)) + .WithResiliencePolicySettings(settings) + .Please(); + + Assert.CatchAsync(async () => + await wrapper.Client.GetAsync("http://localhost")); + Assert.AreEqual(2, wrapper.NumberOfCalls); + } [Test] - public void Should_fail_on_HttpClient_timeout() + public void Should_catch_timeout_because_of_overall_timeout() { + var settings = new ResiliencePoliciesSettings + { + OverallTimeout = TimeSpan.FromMilliseconds(100), + }; var wrapper = Create.HttpClientWrapperWrapperBuilder + .WithStatusCode(HttpStatusCode.OK) .WithResponseLatency(TimeSpan.FromMilliseconds(200)) - .WithHttpClientTimeout(TimeSpan.FromMilliseconds(100)) + .WithResiliencePolicySettings(settings) .Please(); - Assert.CatchAsync(async () => + Assert.CatchAsync(async () => await wrapper.Client.GetAsync("http://localhost")); - Assert.AreEqual(1, wrapper.NumberOfCalls); } - [Test] - public void Should_fail_on_HttpClient_timeout_with_retry() + public void Should_catch_timeout_1_times_because_of_overall_timeout_less_than_per_try_timeout() { const int retryCount = 5; - var retrySettings = new SimpleRetrySettings( - retryCount, - sleepDurationProvider: i => TimeSpan.FromMilliseconds(1)); + var settings = new ResiliencePoliciesSettings + { + OverallTimeout = TimeSpan.FromMilliseconds(100), + TimeoutPerTry = TimeSpan.FromMilliseconds(200), + RetryPolicySettings = RetryPolicySettings.Constant(retryCount, TimeSpan.FromMilliseconds(200)), + }; var wrapper = Create.HttpClientWrapperWrapperBuilder - .WithStatusCode(HttpStatusCode.ServiceUnavailable) - .WithResponseLatency(TimeSpan.FromMilliseconds(50)) - .WithHttpClientTimeout(TimeSpan.FromMilliseconds(100)) - .WithRetrySettings(retrySettings) + .WithStatusCode(HttpStatusCode.OK) + .WithResponseLatency(TimeSpan.FromMilliseconds(300)) + .WithResiliencePolicySettings(settings) .Please(); - Assert.CatchAsync(async () => + Assert.CatchAsync(async () => await wrapper.Client.GetAsync("http://localhost")); - Assert.AreEqual(2, wrapper.NumberOfCalls); + Assert.AreEqual(1, wrapper.NumberOfCalls); + } + + [Test] + public void Should_set_HttpClient_Timeout_property_to_overall_timeout_plus_delta_1000ms() + { + const int overallTimeoutInMilliseconds = 200; + var settings = new ResiliencePoliciesSettings + { + OverallTimeout = + TimeSpan.FromMilliseconds(overallTimeoutInMilliseconds), + }; + var wrapper = Create.HttpClientWrapperWrapperBuilder + .WithResiliencePolicySettings(settings) + .Please(); + + Assert.AreEqual(overallTimeoutInMilliseconds + 1000, wrapper.Client.Timeout.TotalMilliseconds); } } } diff --git a/src/Dodo.HttpClient.ResiliencePolicies/CircuitBreakerPolicy/CircuitBreakerPolicySettings.cs b/src/Dodo.HttpClient.ResiliencePolicies/CircuitBreakerPolicy/CircuitBreakerPolicySettings.cs new file mode 100644 index 0000000..41630f4 --- /dev/null +++ b/src/Dodo.HttpClient.ResiliencePolicies/CircuitBreakerPolicy/CircuitBreakerPolicySettings.cs @@ -0,0 +1,47 @@ +using System; +using System.Net.Http; +using Polly; + +namespace Dodo.HttpClientResiliencePolicies.CircuitBreakerPolicy +{ + public sealed class CircuitBreakerPolicySettings + { + public double FailureThreshold { get; } + public int MinimumThroughput { get; } + public TimeSpan DurationOfBreak { get; } + public TimeSpan SamplingDuration { get; } + + internal Action, TimeSpan> OnBreak { get; set; } + internal Action OnReset { get; set; } + internal Action OnHalfOpen { get; set; } + + public CircuitBreakerPolicySettings() + : this( + Defaults.CircuitBreaker.FailureThreshold, + Defaults.CircuitBreaker.MinimumThroughput, + TimeSpan.FromMilliseconds(Defaults.CircuitBreaker.DurationOfBreakInMilliseconds), + TimeSpan.FromMilliseconds(Defaults.CircuitBreaker.SamplingDurationInMilliseconds)) + { + } + + public CircuitBreakerPolicySettings( + double failureThreshold, + int minimumThroughput, + TimeSpan durationOfBreak, + TimeSpan samplingDuration) + { + FailureThreshold = failureThreshold; + MinimumThroughput = minimumThroughput; + DurationOfBreak = durationOfBreak; + SamplingDuration = samplingDuration; + + OnBreak = DoNothingOnBreak; + OnReset = DoNothingOnReset; + OnHalfOpen = DoNothingOnHalfOpen; + } + + private static readonly Action, TimeSpan> DoNothingOnBreak = (_, __) => { }; + private static readonly Action DoNothingOnReset = () => { }; + private static readonly Action DoNothingOnHalfOpen = () => { }; + } +} diff --git a/src/Dodo.HttpClient.ResiliencePolicies/CircuitBreakerSettings/CircuitBreakerSettings.cs b/src/Dodo.HttpClient.ResiliencePolicies/CircuitBreakerSettings/CircuitBreakerSettings.cs deleted file mode 100644 index e1266b3..0000000 --- a/src/Dodo.HttpClient.ResiliencePolicies/CircuitBreakerSettings/CircuitBreakerSettings.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.Net.Http; -using Polly; - -namespace Dodo.HttpClient.ResiliencePolicies.CircuitBreakerSettings -{ - public class CircuitBreakerSettings : ICircuitBreakerSettings - { - public double FailureThreshold { get; } - public int MinimumThroughput { get; } - public TimeSpan DurationOfBreak { get; } - public TimeSpan SamplingDuration { get; } - public Action, TimeSpan> OnBreak { get; set; } - public Action OnReset { get; set; } - public Action OnHalfOpen { get; set; } - - public CircuitBreakerSettings( - double failureThreshold, - int minimumThroughput, - TimeSpan durationOfBreak, - TimeSpan samplingDuration) : this(failureThreshold, minimumThroughput, durationOfBreak, samplingDuration, - _defaultOnBreak, _defaultOnReset, _defaultOnHalfOpen) - { - } - - public CircuitBreakerSettings( - double failureThreshold, - int minimumThroughput, - TimeSpan durationOfBreak, - TimeSpan samplingDuration, - Action, TimeSpan> onBreak, - Action onReset, - Action onHalfOpen) - { - FailureThreshold = failureThreshold; - MinimumThroughput = minimumThroughput; - DurationOfBreak = durationOfBreak; - SamplingDuration = samplingDuration; - OnBreak = onBreak; - OnReset = onReset; - OnHalfOpen = onHalfOpen; - } - - public static ICircuitBreakerSettings Default() => - new CircuitBreakerSettings( - Defaults.CircuitBreaker.FailureThreshold, - Defaults.CircuitBreaker.MinimumThroughput, - TimeSpan.FromMilliseconds(Defaults.CircuitBreaker.DurationOfBreakInMilliseconds), - TimeSpan.FromMilliseconds(Defaults.CircuitBreaker.SamplingDurationInMilliseconds) - ); - - private static readonly Action, TimeSpan> _defaultOnBreak = (_, __) => { }; - private static readonly Action _defaultOnReset = () => { }; - private static readonly Action _defaultOnHalfOpen = () => { }; - } -} diff --git a/src/Dodo.HttpClient.ResiliencePolicies/CircuitBreakerSettings/ICircuitBreakerSettings.cs b/src/Dodo.HttpClient.ResiliencePolicies/CircuitBreakerSettings/ICircuitBreakerSettings.cs deleted file mode 100644 index df14099..0000000 --- a/src/Dodo.HttpClient.ResiliencePolicies/CircuitBreakerSettings/ICircuitBreakerSettings.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using System.Net.Http; -using Polly; - -namespace Dodo.HttpClient.ResiliencePolicies.CircuitBreakerSettings -{ - public interface ICircuitBreakerSettings - { - double FailureThreshold { get; } - int MinimumThroughput { get; } - TimeSpan DurationOfBreak { get; } - TimeSpan SamplingDuration { get; } - Action, TimeSpan> OnBreak { get; set; } - Action OnReset { get; set; } - Action OnHalfOpen { get; set; } - } -} diff --git a/src/Dodo.HttpClient.ResiliencePolicies/Defaults.cs b/src/Dodo.HttpClient.ResiliencePolicies/Defaults.cs index 10413d5..25407b1 100644 --- a/src/Dodo.HttpClient.ResiliencePolicies/Defaults.cs +++ b/src/Dodo.HttpClient.ResiliencePolicies/Defaults.cs @@ -1,16 +1,19 @@ -namespace Dodo.HttpClient.ResiliencePolicies +using System; + +namespace Dodo.HttpClientResiliencePolicies { public static class Defaults { public static class Timeout { - public const int HttpClientTimeoutInMilliseconds = 10000; + public const int TimeoutOverallInMilliseconds = 50000; public const int TimeoutPerTryInMilliseconds = 2000; } public static class Retry { public const int RetryCount = 2; + public const int InitialDelayMilliseconds = 20; public const int MedianFirstRetryDelayInMilliseconds = 2000; } diff --git a/src/Dodo.HttpClient.ResiliencePolicies/Dodo.HttpClient.ResiliencePolicies.csproj b/src/Dodo.HttpClient.ResiliencePolicies/Dodo.HttpClient.ResiliencePolicies.csproj index 8091274..231d342 100644 --- a/src/Dodo.HttpClient.ResiliencePolicies/Dodo.HttpClient.ResiliencePolicies.csproj +++ b/src/Dodo.HttpClient.ResiliencePolicies/Dodo.HttpClient.ResiliencePolicies.csproj @@ -1,19 +1,22 @@ - netstandard2.0;netcoreapp3.1 + netstandard2.0;netcoreapp3.1;net5.0 netstandard2.0 8.0 - 1.0.3 + 2.0.0 + Dodo.HttpClient.ResiliencePolicies + Dodo.HttpClientResiliencePolicies + true - - - - - + + + + + - + \ true dodopizza-logo.png diff --git a/src/Dodo.HttpClient.ResiliencePolicies/HttpClientBuilderExtensions.cs b/src/Dodo.HttpClient.ResiliencePolicies/HttpClientBuilderExtensions.cs deleted file mode 100644 index 269b5a0..0000000 --- a/src/Dodo.HttpClient.ResiliencePolicies/HttpClientBuilderExtensions.cs +++ /dev/null @@ -1,161 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using Dodo.HttpClient.ResiliencePolicies.CircuitBreakerSettings; -using Dodo.HttpClient.ResiliencePolicies.RetrySettings; -using Microsoft.Extensions.DependencyInjection; -using Polly; -using Polly.CircuitBreaker; -using Polly.Extensions.Http; -using Polly.Registry; -using Polly.Timeout; - -namespace Dodo.HttpClient.ResiliencePolicies -{ - /// - /// Extension methods for configuring with Polly retry, timeout, circuit breaker policies. - /// - public static class HttpClientBuilderExtensions - { - /// - /// Adds the and related services to the - /// with pre-configured JSON headers, client Timeout and default policies. - /// - /// An that can be used to configure the client. - public static IHttpClientBuilder AddJsonClient( - this IServiceCollection sc, - Uri baseAddress, - HttpClientSettings settings, - string clientName = null) where TClientInterface : class - where TClientImplementation : class, TClientInterface - { - Action defaultClient = (client) => - { - client.BaseAddress = baseAddress; - client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - client.Timeout = settings.HttpClientTimeout; - }; - - var httpClientBuilder = string.IsNullOrEmpty(clientName) - ? sc.AddHttpClient(defaultClient) - : sc.AddHttpClient(clientName, defaultClient); - - httpClientBuilder.AddDefaultPolicies(settings); - - return httpClientBuilder; - } - - /// - /// Adds pre-configured default policies. - /// - /// Configured HttpClient builder. - /// An that can be used to configure the client. - public static IHttpClientBuilder AddDefaultPolicies( - this IHttpClientBuilder clientBuilder) - { - return clientBuilder - .AddDefaultPolicies(HttpClientSettings.Default()); - } - - /// - /// Adds and configures custom policies. - /// - /// Configured HttpClient builder. - /// Custom policy settings. - /// An that can be used to configure the client. - public static IHttpClientBuilder AddDefaultPolicies( - this IHttpClientBuilder clientBuilder, - HttpClientSettings settings) - { - return clientBuilder - .AddRetryPolicy(settings.RetrySettings) - .AddCircuitBreakerPolicy(settings.CircuitBreakerSettings) - .AddTimeoutPolicy(settings.TimeoutPerTry); - } - - /// - /// Adds pre-configured default policies to use single HttpClient against multiple hosts. - /// - /// Configured HttpClient builder. - /// An that can be used to configure the client. - public static IHttpClientBuilder AddDefaultHostSpecificPolicies( - this IHttpClientBuilder clientBuilder) - { - return clientBuilder - .AddDefaultHostSpecificPolicies(HttpClientSettings.Default()); - } - - /// - /// Adds and configures custom policies to use single HttpClient against multiple hosts. - /// - /// Configured HttpClient builder. - /// Custom policy settings. - /// An that can be used to configure the client. - public static IHttpClientBuilder AddDefaultHostSpecificPolicies( - this IHttpClientBuilder clientBuilder, - HttpClientSettings settings) - { - return clientBuilder - .AddRetryPolicy(settings.RetrySettings) - .AddHostSpecificCircuitBreakerPolicy(settings.CircuitBreakerSettings) - .AddTimeoutPolicy(settings.TimeoutPerTry); - } - - private static IHttpClientBuilder AddRetryPolicy( - this IHttpClientBuilder clientBuilder, - IRetrySettings settings) - { - return clientBuilder - .AddPolicyHandler(HttpPolicyExtensions - .HandleTransientHttpError() - .Or() - .WaitAndRetryAsync( - settings.RetryCount, - settings.SleepDurationProvider, - settings.OnRetry)); - } - - private static IHttpClientBuilder AddCircuitBreakerPolicy( - this IHttpClientBuilder clientBuilder, - ICircuitBreakerSettings settings) - { - return clientBuilder.AddPolicyHandler(BuildCircuitBreakerPolicy(settings)); - } - - private static IHttpClientBuilder AddHostSpecificCircuitBreakerPolicy( - this IHttpClientBuilder clientBuilder, - ICircuitBreakerSettings settings) - { - var registry = new PolicyRegistry(); - return clientBuilder.AddPolicyHandler(message => - { - var policyKey = message.RequestUri.Host; - var policy = registry.GetOrAdd(policyKey, BuildCircuitBreakerPolicy(settings)); - return policy; - }); - } - - private static AsyncCircuitBreakerPolicy BuildCircuitBreakerPolicy( - ICircuitBreakerSettings settings) - { - return HttpPolicyExtensions - .HandleTransientHttpError() - .Or() - .OrResult(r => r.StatusCode == (HttpStatusCode) 429) // Too Many Requests - .AdvancedCircuitBreakerAsync( - settings.FailureThreshold, - settings.SamplingDuration, - settings.MinimumThroughput, - settings.DurationOfBreak, - settings.OnBreak, - settings.OnReset, - settings.OnHalfOpen); - } - - private static IHttpClientBuilder AddTimeoutPolicy(this IHttpClientBuilder httpClientBuilder, TimeSpan timeout) - { - return httpClientBuilder.AddPolicyHandler(Policy.TimeoutAsync(timeout)); - } - } -} diff --git a/src/Dodo.HttpClient.ResiliencePolicies/HttpClientFactoryServiceCollectionExtensions.cs b/src/Dodo.HttpClient.ResiliencePolicies/HttpClientFactoryServiceCollectionExtensions.cs new file mode 100644 index 0000000..9672c87 --- /dev/null +++ b/src/Dodo.HttpClient.ResiliencePolicies/HttpClientFactoryServiceCollectionExtensions.cs @@ -0,0 +1,55 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using Microsoft.Extensions.DependencyInjection; + +namespace Dodo.HttpClientResiliencePolicies +{ + public static class HttpClientFactoryServiceCollectionExtensions + { + /// + /// Adds the and related services to the + /// with pre-configured JSON headers, HttpClient Timeout and resilience policies. + /// + /// An that can be used to configure the client. + public static IHttpClientBuilder AddJsonClient( + this IServiceCollection sc, + Uri baseAddress, + string clientName = null) where TClientInterface : class + where TClientImplementation : class, TClientInterface + { + return AddJsonClient( + sc, baseAddress, new ResiliencePoliciesSettings(), clientName); + } + + /// + /// Adds the and related services to the + /// with pre-configured JSON headers and custom resilience policies. + /// + /// An that can be used to configure the client. + public static IHttpClientBuilder AddJsonClient( + this IServiceCollection sc, + Uri baseAddress, + ResiliencePoliciesSettings settings, + string clientName = null) where TClientInterface : class + where TClientImplementation : class, TClientInterface + { + var delta = TimeSpan.FromMilliseconds(1000); + + void DefaultClient(HttpClient client) + { + client.BaseAddress = baseAddress; + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + client.Timeout = settings.OverallTimeout + delta; + } + + var httpClientBuilder = string.IsNullOrEmpty(clientName) + ? sc.AddHttpClient(DefaultClient) + : sc.AddHttpClient(clientName, DefaultClient); + + httpClientBuilder.AddResiliencePolicies(settings); + + return httpClientBuilder; + } + } +} diff --git a/src/Dodo.HttpClient.ResiliencePolicies/HttpClientSettings.cs b/src/Dodo.HttpClient.ResiliencePolicies/HttpClientSettings.cs deleted file mode 100644 index ee5a4aa..0000000 --- a/src/Dodo.HttpClient.ResiliencePolicies/HttpClientSettings.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using Dodo.HttpClient.ResiliencePolicies.CircuitBreakerSettings; -using Dodo.HttpClient.ResiliencePolicies.RetrySettings; - -namespace Dodo.HttpClient.ResiliencePolicies -{ - public class HttpClientSettings - { - public TimeSpan HttpClientTimeout { get; } - public TimeSpan TimeoutPerTry { get; } - public IRetrySettings RetrySettings { get; } - public ICircuitBreakerSettings CircuitBreakerSettings { get; } - - - public HttpClientSettings( - TimeSpan httpClientTimeout, - TimeSpan timeoutPerTry, - int retryCount) : this(httpClientTimeout, timeoutPerTry, - new JitterRetrySettings(retryCount), - ResiliencePolicies.CircuitBreakerSettings.CircuitBreakerSettings.Default()) - { - } - - public HttpClientSettings( - IRetrySettings retrySettings, - ICircuitBreakerSettings circuitBreakerSettings) : this( - TimeSpan.FromMilliseconds(Defaults.Timeout.HttpClientTimeoutInMilliseconds), - TimeSpan.FromMilliseconds(Defaults.Timeout.TimeoutPerTryInMilliseconds), - retrySettings, - circuitBreakerSettings) - { - } - - public HttpClientSettings( - TimeSpan httpClientTimeout, - TimeSpan timeoutPerTry, - IRetrySettings retrySettings, - ICircuitBreakerSettings circuitBreakerSettings) - { - HttpClientTimeout = httpClientTimeout; - TimeoutPerTry = timeoutPerTry; - RetrySettings = retrySettings; - CircuitBreakerSettings = circuitBreakerSettings; - } - - public static HttpClientSettings Default() => - new HttpClientSettings( - JitterRetrySettings.Default(), - ResiliencePolicies.CircuitBreakerSettings.CircuitBreakerSettings.Default() - ); - } -} diff --git a/src/Dodo.HttpClient.ResiliencePolicies/PoliciesHttpClientBuilderExtensions.cs b/src/Dodo.HttpClient.ResiliencePolicies/PoliciesHttpClientBuilderExtensions.cs new file mode 100644 index 0000000..77c468f --- /dev/null +++ b/src/Dodo.HttpClient.ResiliencePolicies/PoliciesHttpClientBuilderExtensions.cs @@ -0,0 +1,94 @@ +using System; +using System.Net; +using System.Net.Http; +using Dodo.HttpClientResiliencePolicies.CircuitBreakerPolicy; +using Dodo.HttpClientResiliencePolicies.RetryPolicy; +using Microsoft.Extensions.DependencyInjection; +using Polly; +using Polly.Extensions.Http; +using Polly.Registry; +using Polly.Timeout; + +namespace Dodo.HttpClientResiliencePolicies +{ + /// + /// Extension methods for configuring with Polly retry, timeout, circuit breaker policies. + /// + public static class PoliciesHttpClientBuilderExtensions + { + /// + /// Adds pre-configured resilience policies. + /// + /// Configured HttpClient builder. + /// An that can be used to configure the client. + public static IHttpClientBuilder AddResiliencePolicies( + this IHttpClientBuilder clientBuilder) + { + return clientBuilder + .AddResiliencePolicies(new ResiliencePoliciesSettings()); + } + + /// + /// Adds and configures custom resilience policies. + /// + /// Configured HttpClient builder. + /// Custom resilience policy settings. + /// An that can be used to configure the client. + public static IHttpClientBuilder AddResiliencePolicies( + this IHttpClientBuilder clientBuilder, + ResiliencePoliciesSettings settings) + { + return clientBuilder + .AddTimeoutPolicy(settings.OverallTimeout) + .AddRetryPolicy(settings.RetryPolicySettings) + .AddCircuitBreakerPolicy(settings.CircuitBreakerPolicySettings) + .AddTimeoutPolicy(settings.TimeoutPerTry); + } + + private static IHttpClientBuilder AddRetryPolicy( + this IHttpClientBuilder clientBuilder, + RetryPolicySettings settings) + { + return clientBuilder + .AddPolicyHandler(HttpPolicyExtensions + .HandleTransientHttpError() + .Or() + .WaitAndRetryAsync(settings)); + } + + private static IHttpClientBuilder AddCircuitBreakerPolicy( + this IHttpClientBuilder clientBuilder, + CircuitBreakerPolicySettings settings) + { + // This implementation takes into consideration situations + // when you use the only HttpClient against different hosts. + // In this case we want to have separate CircuitBreaker metrics for each host. + // It allows us avoid situations when all requests to all hosts + // will be stopped by CircuitBreaker due to single host is not available. + var registry = new PolicyRegistry(); + return clientBuilder.AddPolicyHandler(message => + { + var policyKey = message.RequestUri.Host; + var policy = registry.GetOrAdd(policyKey, BuildCircuitBreakerPolicy(settings)); + return policy; + }); + } + + private static IAsyncPolicy BuildCircuitBreakerPolicy( + CircuitBreakerPolicySettings settings) + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .Or() + .OrResult(r => r.StatusCode == (HttpStatusCode) 429) // Too Many Requests + .AdvancedCircuitBreakerAsync(settings); + } + + private static IHttpClientBuilder AddTimeoutPolicy( + this IHttpClientBuilder httpClientBuilder, + TimeSpan timeout) + { + return httpClientBuilder.AddPolicyHandler(Policy.TimeoutAsync(timeout)); + } + } +} diff --git a/src/Dodo.HttpClient.ResiliencePolicies/ResiliencePoliciesSettings.cs b/src/Dodo.HttpClient.ResiliencePolicies/ResiliencePoliciesSettings.cs new file mode 100644 index 0000000..29afa2a --- /dev/null +++ b/src/Dodo.HttpClient.ResiliencePolicies/ResiliencePoliciesSettings.cs @@ -0,0 +1,79 @@ +using System; +using System.Net.Http; +using Dodo.HttpClientResiliencePolicies.CircuitBreakerPolicy; +using Dodo.HttpClientResiliencePolicies.RetryPolicy; +using Polly; + +namespace Dodo.HttpClientResiliencePolicies +{ + public sealed class ResiliencePoliciesSettings + { + private RetryPolicySettings _retryPolicySettings = new RetryPolicySettings(); + private CircuitBreakerPolicySettings _circuitBreakerPolicySettings = new CircuitBreakerPolicySettings(); + + public TimeSpan OverallTimeout { get; set; } = TimeSpan.FromMilliseconds(Defaults.Timeout.TimeoutOverallInMilliseconds); + public TimeSpan TimeoutPerTry { get; set; } = TimeSpan.FromMilliseconds(Defaults.Timeout.TimeoutPerTryInMilliseconds); + + public RetryPolicySettings RetryPolicySettings + { + get => _retryPolicySettings; + set + { + if (value == null) + { + throw new ArgumentNullException($"{nameof(RetryPolicySettings)} cannot be set to null."); + } + + var onRetryHandler = OnRetry; + + _retryPolicySettings = value; + _retryPolicySettings.OnRetry = onRetryHandler; + } + } + + public CircuitBreakerPolicySettings CircuitBreakerPolicySettings + { + get => _circuitBreakerPolicySettings; + set + { + if (value == null) + { + throw new ArgumentNullException($"{nameof(CircuitBreakerPolicySettings)} cannot be set to null."); + } + + var onBreakHandler = OnBreak; + var onResetHandler = OnReset; + var onHalfOpenHandler = OnHalfOpen; + + _circuitBreakerPolicySettings = value; + _circuitBreakerPolicySettings.OnBreak = onBreakHandler; + _circuitBreakerPolicySettings.OnReset = onResetHandler; + _circuitBreakerPolicySettings.OnHalfOpen = onHalfOpenHandler; + } + } + + public Action, TimeSpan> OnRetry + { + get => RetryPolicySettings.OnRetry; + set => RetryPolicySettings.OnRetry = value; + } + + public Action, TimeSpan> OnBreak + { + get => CircuitBreakerPolicySettings.OnBreak; + set => CircuitBreakerPolicySettings.OnBreak = value; + } + + public Action OnReset + { + get => CircuitBreakerPolicySettings.OnReset; + set => CircuitBreakerPolicySettings.OnReset = value; + } + + public Action OnHalfOpen + { + get => CircuitBreakerPolicySettings.OnHalfOpen; + set => CircuitBreakerPolicySettings.OnHalfOpen = value; + } + } +} diff --git a/src/Dodo.HttpClient.ResiliencePolicies/RetryPolicy/ISleepDurationProvider.cs b/src/Dodo.HttpClient.ResiliencePolicies/RetryPolicy/ISleepDurationProvider.cs new file mode 100644 index 0000000..27fc440 --- /dev/null +++ b/src/Dodo.HttpClient.ResiliencePolicies/RetryPolicy/ISleepDurationProvider.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; + +namespace Dodo.HttpClientResiliencePolicies.RetryPolicy +{ + public interface ISleepDurationProvider + { + int RetryCount { get; } + IEnumerable Durations { get; } + } +} diff --git a/src/Dodo.HttpClient.ResiliencePolicies/RetryPolicy/PolicyBuilderExtension.cs b/src/Dodo.HttpClient.ResiliencePolicies/RetryPolicy/PolicyBuilderExtension.cs new file mode 100644 index 0000000..a9dc198 --- /dev/null +++ b/src/Dodo.HttpClient.ResiliencePolicies/RetryPolicy/PolicyBuilderExtension.cs @@ -0,0 +1,37 @@ +using System.Net.Http; +using Dodo.HttpClientResiliencePolicies.CircuitBreakerPolicy; +using Polly; + +namespace Dodo.HttpClientResiliencePolicies.RetryPolicy +{ + internal static class PolicyBuilderExtension + { + public static IAsyncPolicy WaitAndRetryAsync( + this PolicyBuilder policyBuilder, + RetryPolicySettings settings) + { + var handler = new RetryPolicyHandler(settings); + return policyBuilder + .WaitAndRetryAsync( + handler.RetryCount, + handler.SleepDurationProvider, + handler.OnRetry); + } + + public static IAsyncPolicy AdvancedCircuitBreakerAsync( + this PolicyBuilder policyBuilder, + CircuitBreakerPolicySettings settings) + { + return policyBuilder + .AdvancedCircuitBreakerAsync( + settings.FailureThreshold, + settings.SamplingDuration, + settings.MinimumThroughput, + settings.DurationOfBreak, + settings.OnBreak, + settings.OnReset, + settings.OnHalfOpen); + } + + } +} diff --git a/src/Dodo.HttpClient.ResiliencePolicies/RetryPolicy/RetryPolicyHandler.cs b/src/Dodo.HttpClient.ResiliencePolicies/RetryPolicy/RetryPolicyHandler.cs new file mode 100644 index 0000000..8177b64 --- /dev/null +++ b/src/Dodo.HttpClient.ResiliencePolicies/RetryPolicy/RetryPolicyHandler.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Polly; + +namespace Dodo.HttpClientResiliencePolicies.RetryPolicy +{ + internal sealed class RetryPolicyHandler + { + private readonly RetryPolicySettings _retryPolicySettings; + + internal RetryPolicyHandler(RetryPolicySettings retryPolicySettings) + { + _retryPolicySettings = retryPolicySettings; + } + + public int RetryCount => _retryPolicySettings.SleepProvider.RetryCount; + + public TimeSpan SleepDurationProvider(int retryCount, DelegateResult response, Context context) + { + var serverWaitDuration = GetServerWaitDuration(response); + // ReSharper disable once PossibleMultipleEnumeration + return serverWaitDuration ?? _retryPolicySettings.SleepProvider.Durations.ToArray()[retryCount-1]; + } + + public Task OnRetry(DelegateResult response, TimeSpan span, int retryCount, Context context) + { + _retryPolicySettings.OnRetry?.Invoke(response, span); + // TODO: Async method turned into sync one here + return Task.CompletedTask; + } + + private static TimeSpan? GetServerWaitDuration(DelegateResult response) + { + var retryAfter = response?.Result?.Headers?.RetryAfter; + if (retryAfter == null) + { + return null; + } + + if (retryAfter.Delta.HasValue) // Delta priority check, because its simple TimeSpan value + { + return retryAfter.Delta.Value; + } + + if (retryAfter.Date.HasValue) + { + return retryAfter.Date.Value - DateTime.UtcNow; + } + + return null; // when nothing was found + } + } +} diff --git a/src/Dodo.HttpClient.ResiliencePolicies/RetryPolicy/RetryPolicySettings.cs b/src/Dodo.HttpClient.ResiliencePolicies/RetryPolicy/RetryPolicySettings.cs new file mode 100644 index 0000000..9a74e73 --- /dev/null +++ b/src/Dodo.HttpClient.ResiliencePolicies/RetryPolicy/RetryPolicySettings.cs @@ -0,0 +1,83 @@ +using System; +using System.Net.Http; +using Polly; + +namespace Dodo.HttpClientResiliencePolicies.RetryPolicy +{ + public sealed class RetryPolicySettings + { + internal ISleepDurationProvider SleepProvider { get; } + + internal Action, TimeSpan> OnRetry { get; set; } + + public RetryPolicySettings( + ISleepDurationProvider provider) + { + SleepProvider = provider ?? throw new ArgumentNullException(nameof(provider)); + OnRetry = DoNothingOnRetry; + } + + public RetryPolicySettings() + : this(SleepDurationProvider.Jitter( + Defaults.Retry.RetryCount, + TimeSpan.FromMilliseconds(Defaults.Retry.MedianFirstRetryDelayInMilliseconds))) + { + } + + private static readonly Action, TimeSpan> DoNothingOnRetry = (_, __) => { }; + + public static RetryPolicySettings Constant(int retryCount) + { + return Constant(retryCount, + TimeSpan.FromMilliseconds(Defaults.Retry.InitialDelayMilliseconds)); + } + + public static RetryPolicySettings Constant(int retryCount, TimeSpan initialDelay) + { + return new RetryPolicySettings( + SleepDurationProvider.Constant(retryCount,initialDelay)); + } + + public static RetryPolicySettings Linear(int retryCount) + { + return Linear(retryCount, + TimeSpan.FromMilliseconds(Defaults.Retry.InitialDelayMilliseconds)); + } + + public static RetryPolicySettings Linear(int retryCount, TimeSpan initialDelay) + { + return new RetryPolicySettings( + SleepDurationProvider.Linear(retryCount, initialDelay)); + } + + public static RetryPolicySettings Exponential(int retryCount) + { + return Exponential(retryCount, + TimeSpan.FromMilliseconds(Defaults.Retry.InitialDelayMilliseconds)); + } + + public static RetryPolicySettings Exponential(int retryCount, TimeSpan initialDelay) + { + return new RetryPolicySettings( + SleepDurationProvider.Exponential(retryCount, initialDelay)); + } + + public static RetryPolicySettings Jitter() + { + return Jitter(Defaults.Retry.RetryCount, + TimeSpan.FromMilliseconds(Defaults.Retry.MedianFirstRetryDelayInMilliseconds)); + } + + public static RetryPolicySettings Jitter(int retryCount) + { + return Jitter(retryCount, + TimeSpan.FromMilliseconds(Defaults.Retry.MedianFirstRetryDelayInMilliseconds)); + } + + public static RetryPolicySettings Jitter(int retryCount, TimeSpan medianFirstRetryDelay) + { + return new RetryPolicySettings( + SleepDurationProvider.Jitter(retryCount, medianFirstRetryDelay)); + } + } +} diff --git a/src/Dodo.HttpClient.ResiliencePolicies/RetryPolicy/SleepDurationProvider.cs b/src/Dodo.HttpClient.ResiliencePolicies/RetryPolicy/SleepDurationProvider.cs new file mode 100644 index 0000000..2b1fdbc --- /dev/null +++ b/src/Dodo.HttpClient.ResiliencePolicies/RetryPolicy/SleepDurationProvider.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using Polly.Contrib.WaitAndRetry; + +namespace Dodo.HttpClientResiliencePolicies.RetryPolicy +{ + public sealed class SleepDurationProvider : ISleepDurationProvider + { + public int RetryCount { get; } + public IEnumerable Durations { get; } + + public SleepDurationProvider(int retryCount, IEnumerable durations) + { + if (retryCount < 0) throw new ArgumentOutOfRangeException(nameof(retryCount), retryCount, "should be >= 0"); + + Durations = durations ?? throw new ArgumentNullException(nameof(durations)); + RetryCount = retryCount; + } + + public static SleepDurationProvider Constant(int retryCount, TimeSpan initialDelay) + { + if (retryCount < 0) throw new ArgumentOutOfRangeException(nameof(retryCount), retryCount, "should be >= 0"); + if (initialDelay < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(initialDelay), initialDelay, "should be >= 0ms"); + + return new SleepDurationProvider(retryCount, Backoff.ConstantBackoff(initialDelay, retryCount)); + } + + public static SleepDurationProvider Linear(int retryCount, TimeSpan initialDelay) + { + if (retryCount < 0) throw new ArgumentOutOfRangeException(nameof(retryCount), retryCount, "should be >= 0"); + if (initialDelay < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(initialDelay), initialDelay, "should be >= 0ms"); + + return new SleepDurationProvider(retryCount, Backoff.LinearBackoff(initialDelay, retryCount)); + } + + public static SleepDurationProvider Exponential(int retryCount, TimeSpan initialDelay) + { + if (retryCount < 0) throw new ArgumentOutOfRangeException(nameof(retryCount), retryCount, "should be >= 0"); + if (initialDelay < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(initialDelay), initialDelay, "should be >= 0ms"); + + return new SleepDurationProvider(retryCount, Backoff.ExponentialBackoff(initialDelay, retryCount)); + } + + public static SleepDurationProvider Jitter(int retryCount, TimeSpan medianFirstRetryDelay) + { + if (retryCount < 0) throw new ArgumentOutOfRangeException(nameof(retryCount), retryCount, "should be >= 0"); + if (medianFirstRetryDelay < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(medianFirstRetryDelay), medianFirstRetryDelay, + "should be >= 0ms"); + + return new SleepDurationProvider(retryCount, Backoff.DecorrelatedJitterBackoffV2(medianFirstRetryDelay, retryCount)); + } + } +} diff --git a/src/Dodo.HttpClient.ResiliencePolicies/RetrySettings/IRetrySettings.cs b/src/Dodo.HttpClient.ResiliencePolicies/RetrySettings/IRetrySettings.cs deleted file mode 100644 index c63f015..0000000 --- a/src/Dodo.HttpClient.ResiliencePolicies/RetrySettings/IRetrySettings.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using System.Net.Http; -using Polly; - -namespace Dodo.HttpClient.ResiliencePolicies.RetrySettings -{ - public interface IRetrySettings - { - int RetryCount { get; } - Func SleepDurationProvider { get; } - Action, TimeSpan> OnRetry { get; set; } - } -} diff --git a/src/Dodo.HttpClient.ResiliencePolicies/RetrySettings/JitterRetrySettings.cs b/src/Dodo.HttpClient.ResiliencePolicies/RetrySettings/JitterRetrySettings.cs deleted file mode 100644 index 7b5bd7c..0000000 --- a/src/Dodo.HttpClient.ResiliencePolicies/RetrySettings/JitterRetrySettings.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.Linq; -using System.Net.Http; -using Polly; -using Polly.Contrib.WaitAndRetry; - -namespace Dodo.HttpClient.ResiliencePolicies.RetrySettings -{ - public class JitterRetrySettings : IRetrySettings - { - public int RetryCount { get; } - public TimeSpan MedianFirstRetryDelay { get; } - public Func SleepDurationProvider { get; } - public Action, TimeSpan> OnRetry { get; set; } - - public JitterRetrySettings(int retryCount) : this(retryCount, _defaultMedianFirstRetryDelay) - { - } - - public JitterRetrySettings(int retryCount, Action, TimeSpan> onRetry) : - this(retryCount, _defaultMedianFirstRetryDelay, onRetry) - { - } - - public JitterRetrySettings(int retryCount, TimeSpan medianFirstRetryDelay) : this(retryCount, - medianFirstRetryDelay, _defaultOnRetry) - { - } - - public JitterRetrySettings( - int retryCount, - TimeSpan medianFirstRetryDelay, - Action, TimeSpan> onRetry) - { - RetryCount = retryCount; - SleepDurationProvider = _defaultSleepDurationProvider(retryCount, medianFirstRetryDelay); - OnRetry = onRetry; - } - - public static IRetrySettings Default() => new JitterRetrySettings(Defaults.Retry.RetryCount); - - private static readonly TimeSpan _defaultMedianFirstRetryDelay = - TimeSpan.FromMilliseconds(Defaults.Retry.MedianFirstRetryDelayInMilliseconds); - - // i - retry attempt - private static readonly Func> _defaultSleepDurationProvider = - (retryCount, medianFirstRetryDelay) => i => - Backoff.DecorrelatedJitterBackoffV2(medianFirstRetryDelay, retryCount).ToArray()[i - 1]; - - private static readonly Action, TimeSpan> _defaultOnRetry = (_, __) => { }; - } -} diff --git a/src/Dodo.HttpClient.ResiliencePolicies/RetrySettings/SimpleRetrySettings.cs b/src/Dodo.HttpClient.ResiliencePolicies/RetrySettings/SimpleRetrySettings.cs deleted file mode 100644 index e9da168..0000000 --- a/src/Dodo.HttpClient.ResiliencePolicies/RetrySettings/SimpleRetrySettings.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Net.Http; -using Polly; - -namespace Dodo.HttpClient.ResiliencePolicies.RetrySettings -{ - public class SimpleRetrySettings : IRetrySettings - { - public int RetryCount { get; } - public Func SleepDurationProvider { get; } - public Action, TimeSpan> OnRetry { get; set; } - - public SimpleRetrySettings(int retryCount) : this(retryCount, _defaultSleepDurationProvider) - { - } - - public SimpleRetrySettings( - int retryCount, - Func sleepDurationProvider) : this(retryCount, sleepDurationProvider, _defaultOnRetry) - { - } - - public SimpleRetrySettings( - int retryCount, - Action, TimeSpan> onRetry) : this(retryCount, - _defaultSleepDurationProvider, onRetry) - { - } - - public SimpleRetrySettings( - int retryCount, - Func sleepDurationProvider, - Action, TimeSpan> onRetry) - { - RetryCount = retryCount; - SleepDurationProvider = sleepDurationProvider; - OnRetry = onRetry; - } - - public static IRetrySettings Default() => new SimpleRetrySettings(Defaults.Retry.RetryCount); - - private static readonly Func _defaultSleepDurationProvider = - i => TimeSpan.FromMilliseconds(20 * Math.Pow(2, i)); - - private static readonly Action, TimeSpan> _defaultOnRetry = (_, __) => { }; - } -}