| File: Resilience\HttpClientBuilderExtensionsTests.RetryBudget.cs | Web Access |
| Project: src\test\Libraries\Microsoft.Extensions.Http.Resilience.Tests\Microsoft.Extensions.Http.Resilience.Tests.csproj (Microsoft.Extensions.Http.Resilience.Tests) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.Metrics.Testing; using Microsoft.Extensions.Http.Resilience.Internal; using Microsoft.Extensions.Http.Resilience.Test.Helpers; using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; using Polly.Testing; using Xunit; namespace Microsoft.Extensions.Http.Resilience.Test; public sealed partial class HttpClientBuilderExtensionsTests { [Fact] public void UseBudgetedRetries_BuilderIsNull_Throws() { Assert.Throws<ArgumentNullException>(() => ((IHttpStandardResiliencePipelineBuilder)null!).UseBudgetedRetries()); } [Fact] public void AddStandardResilienceHandler_BudgetedRetries_OnlyChangesRetryOptions() { var builder = new ServiceCollection().AddLogging().AddMetrics().AddHttpClient("test"); var pipelineBuilder = builder.AddStandardResilienceHandler(); pipelineBuilder.UseBudgetedRetries().Should().BeSameAs(pipelineBuilder); var options = GetStandardOptions(builder.Services); var defaults = new HttpStandardResilienceOptions(); options.Retry.RetryBudget.Should().BeEquivalentTo(new HttpRetryBudgetOptions()); options.Retry.MaxRetryAttempts.Should().Be(defaults.Retry.MaxRetryAttempts); options.CircuitBreaker.Should().BeEquivalentTo(defaults.CircuitBreaker); options.RateLimiter.Should().BeEquivalentTo(defaults.RateLimiter); options.TotalRequestTimeout.Should().BeEquivalentTo(defaults.TotalRequestTimeout); options.AttemptTimeout.Should().BeEquivalentTo(defaults.AttemptTimeout); var descriptor = GetPipeline(builder.Services, "test-standard").GetPipelineDescriptor(); descriptor.Strategies.Should().HaveCount(6); descriptor.Strategies[0].Options.Should().BeOfType<HttpRateLimiterStrategyOptions>(); descriptor.Strategies[1].Options.Should().BeOfType<HttpTimeoutStrategyOptions>(); descriptor.Strategies[2].Options.Should().BeOfType<RetryBudgetStrategyOptions>(); descriptor.Strategies[3].Options.Should().BeOfType<HttpRetryStrategyOptions>(); descriptor.Strategies[4].Options.Should().BeOfType<HttpCircuitBreakerStrategyOptions>(); descriptor.Strategies[5].Options.Should().BeOfType<HttpTimeoutStrategyOptions>(); } [Fact] public void AddStandardResilienceHandler_BudgetedRetries_LaterConfigurationTakesPrecedence() { var builder = new ServiceCollection().AddLogging().AddMetrics().AddHttpClient("test"); builder.AddStandardResilienceHandler(o => { o.Retry.UseBudgetedRetries(); o.Retry.MaxRetryAttempts = 5; o.Retry.RetryBudget!.RetryRatio = 0.2; }); var options = GetStandardOptions(builder.Services); options.Retry.MaxRetryAttempts.Should().Be(5); options.Retry.RetryBudget!.RetryRatio.Should().Be(0.2); } [Fact] public void AddStandardResilienceHandler_BudgetedRetries_BudgetCanBeDisabled() { var builder = new ServiceCollection().AddLogging().AddMetrics().AddHttpClient("test"); builder.AddStandardResilienceHandler(o => o.Retry.UseBudgetedRetries()).Configure(o => o.Retry.RetryBudget = null); GetPipeline(builder.Services, "test-standard").GetPipelineDescriptor().Strategies.Should().HaveCount(5); } [Fact] public void AddStandardResilienceHandler_BudgetedRetries_ConfigurationOverridesBudget() { var section = ConfigurationStubFactory.Create( new Dictionary<string, string?> { { "standard:Retry:RetryBudget:RetryRatio", "0.25" }, { "standard:Retry:RetryBudget:MinimumThroughput", "20" }, }).GetSection("standard"); var builder = new ServiceCollection().AddLogging().AddMetrics().AddHttpClient("test"); builder.AddStandardResilienceHandler().UseBudgetedRetries().Configure(section); var budget = GetStandardOptions(builder.Services).Retry.RetryBudget!; budget.RetryRatio.Should().Be(0.25); budget.MinimumThroughput.Should().Be(20); budget.SamplingDuration.Should().Be(TimeSpan.FromSeconds(10)); } [Fact] public void AddStandardResilienceHandler_BudgetedRetriesAfterConfiguration_OverwritesBudgetOnly() { var section = ConfigurationStubFactory.Create( new Dictionary<string, string?> { { "standard:Retry:MaxRetryAttempts", "5" }, { "standard:Retry:RetryBudget:RetryRatio", "0.25" }, }).GetSection("standard"); var builder = new ServiceCollection().AddLogging().AddMetrics().AddHttpClient("test"); builder.AddStandardResilienceHandler(section).UseBudgetedRetries(); var retry = GetStandardOptions(builder.Services).Retry; retry.MaxRetryAttempts.Should().Be(5); retry.RetryBudget.Should().BeEquivalentTo(new HttpRetryBudgetOptions()); } [Fact] public void AddStandardResilienceHandler_ConfigurationEnablesBudget() { var section = ConfigurationStubFactory.Create( new Dictionary<string, string?> { { "standard:Retry:RetryBudget:SamplingDuration", "00:00:20" }, }).GetSection("standard"); var builder = new ServiceCollection().AddLogging().AddMetrics().AddHttpClient("test"); builder.AddStandardResilienceHandler(section); var budget = GetStandardOptions(builder.Services).Retry.RetryBudget!; budget.SamplingDuration.Should().Be(TimeSpan.FromSeconds(20)); budget.RetryRatio.Should().Be(0.1); GetPipeline(builder.Services, "test-standard").GetPipelineDescriptor().Strategies.Should().HaveCount(6); } [Fact] public void AddStandardResilienceHandler_InvalidBudget_Throws() { var builder = new ServiceCollection().AddLogging().AddMetrics().AddHttpClient("test"); builder.AddStandardResilienceHandler(o => o.Retry.RetryBudget = new() { MinimumThroughput = 0 }); Assert.Throws<OptionsValidationException>(() => GetPipeline(builder.Services, "test-standard")); } [Theory] #if NET6_0_OR_GREATER [CombinatorialData] #else [InlineData(true)] #endif public async Task AddStandardResilienceHandler_RetryBudget_LimitsRetries(bool asynchronous = true) { var requests = 0; var config = ConfigurationStubFactory.Create( new() { { "standard:Retry:RetryBudget:RetryRatio", "0.5" }, { "standard:Retry:RetryBudget:MinimumThroughput", "1" }, }, out var reloadAction).GetSection("standard"); _builder.AddStandardResilienceHandler(o => o.Retry.UseBudgetedRetries()).Configure(config).Configure(options => { options.Retry.MaxRetryAttempts = 2; options.Retry.Delay = TimeSpan.Zero; options.CircuitBreaker.MinimumThroughput = 1000; }); // A frozen clock keeps the sampling window from expiring when a slow agent stalls the test. _builder.Services.AddSingleton<TimeProvider>(new FakeTimeProvider()); _builder.AddHttpMessageHandler(() => new TestHandlerStub((_, _) => { requests++; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); })); var client = CreateClient(); // 1 request allows 1 retry (the floor), even though MaxRetryAttempts is 2. using (await SendRequest(client, "https://dummy", asynchronous)) { requests.Should().Be(2); } // 2 requests allow 1 retry, which is already used. requests = 0; using (await SendRequest(client, "https://dummy", asynchronous)) { requests.Should().Be(1); } // Reloading the options creates a new pipeline instance with a fresh budget. requests = 0; reloadAction(new() { { "standard:Retry:RetryBudget:RetryRatio", "0.5" }, { "standard:Retry:RetryBudget:MinimumThroughput", "2" }, }); using (await SendRequest(client, "https://dummy", asynchronous)) { requests.Should().Be(3); } } [Theory] #if NET6_0_OR_GREATER [CombinatorialData] #else [InlineData(true)] #endif public async Task AddStandardResilienceHandler_RetryBudgetExhausted_EmitsMetric(bool asynchronous = true) { const string ClientName = "retry-budget-metric"; using var metricCollector = new MetricCollector<int>(null, "Polly", "resilience.polly.strategy.events"); var client = new ServiceCollection() .AddSingleton<TimeProvider>(new FakeTimeProvider()) .AddHttpClient(ClientName) .ConfigurePrimaryHttpMessageHandler(() => new TestHandlerStub(HttpStatusCode.InternalServerError)) .AddStandardResilienceHandler() .Configure(options => { options.Retry.UseBudgetedRetries(); options.Retry.MaxRetryAttempts = 2; options.Retry.Delay = TimeSpan.Zero; options.Retry.RetryBudget!.RetryRatio = 0.5; options.Retry.RetryBudget.MinimumThroughput = 1; options.CircuitBreaker.MinimumThroughput = 1000; }) .Services.BuildServiceProvider() .GetRequiredService<IHttpClientFactory>() .CreateClient(ClientName); // The floor of 1 allows the first retry; the second retry (MaxRetryAttempts is 2) is denied by the budget. using (await SendRequest(client, "https://dummy", asynchronous)) { var measurement = GetBudgetExhaustedMeasurements().Should().ContainSingle().Subject; measurement.Value.Should().Be(1); measurement.Tags["event.severity"].Should().Be("Warning"); measurement.Tags["strategy.name"].Should().Be("Standard-Retry"); measurement.Tags["error.type"].Should().Be("500"); } // The budget is spent, so the next request is denied its first retry. using (await SendRequest(client, "https://dummy", asynchronous)) { GetBudgetExhaustedMeasurements().Should().HaveCount(2); } IEnumerable<CollectedMeasurement<int>> GetBudgetExhaustedMeasurements() => metricCollector.GetMeasurementSnapshot().Where(m => m.Tags.TryGetValue("pipeline.name", out var pipeline) && Equals(pipeline, $"{ClientName}-standard") && m.Tags.TryGetValue("event.name", out var name) && Equals(name, RetryBudgetStrategy.BudgetExhaustedEventName)); } private static HttpStandardResilienceOptions GetStandardOptions(IServiceCollection services) { using var serviceProvider = services.BuildServiceProvider(); return serviceProvider.GetRequiredService<IOptionsMonitor<HttpStandardResilienceOptions>>().Get("test-standard"); } }