diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/HttpClientHedgingResiliencePredicates.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/HttpClientHedgingResiliencePredicates.cs
index 5955510faab..81358b4801d 100644
--- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/HttpClientHedgingResiliencePredicates.cs
+++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/HttpClientHedgingResiliencePredicates.cs
@@ -2,7 +2,10 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System;
+using System.Diagnostics.CodeAnalysis;
using System.Net.Http;
+using System.Threading;
+using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using Polly;
using Polly.CircuitBreaker;
@@ -17,13 +20,26 @@ public static class HttpClientHedgingResiliencePredicates
///
/// Determines whether an outcome should be treated by hedging as a transient failure.
///
+ /// The outcome of the user-specified callback.
/// if outcome is transient, if not.
- public static bool IsTransient(Outcome outcome) => outcome switch
- {
- { Result: { } response } when HttpClientResiliencePredicates.IsTransientHttpFailure(response) => true,
- { Exception: { } exception } when IsTransientHttpException(exception) => true,
- _ => false,
- };
+ public static bool IsTransient(Outcome outcome)
+ => outcome switch
+ {
+ { Result: { } response } when HttpClientResiliencePredicates.IsTransientHttpFailure(response) => true,
+ { Exception: { } exception } when IsTransientHttpException(exception) => true,
+ _ => false,
+ };
+
+ ///
+ /// Determines whether an should be treated by hedging as a transient failure.
+ ///
+ /// The outcome of the user-specified callback.
+ /// The associated with the execution.
+ /// if outcome is transient, if not.
+ [Experimental(diagnosticId: DiagnosticIds.Experiments.Resilience, UrlFormat = DiagnosticIds.UrlFormat)]
+ public static bool IsTransient(Outcome outcome, CancellationToken cancellationToken)
+ => HttpClientResiliencePredicates.IsHttpConnectionTimeout(outcome, cancellationToken)
+ || IsTransient(outcome);
///
/// Determines whether an exception should be treated by hedging as a transient failure.
@@ -35,8 +51,7 @@ internal static bool IsTransientHttpException(Exception exception)
return exception switch
{
BrokenCircuitException => true,
- _ when HttpClientResiliencePredicates.IsTransientHttpException(exception) => true,
- _ => false,
+ _ => HttpClientResiliencePredicates.IsTransientHttpException(exception),
};
}
}
diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/HttpHedgingStrategyOptions.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/HttpHedgingStrategyOptions.cs
index faa46375f65..07272cc9916 100644
--- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/HttpHedgingStrategyOptions.cs
+++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/HttpHedgingStrategyOptions.cs
@@ -16,11 +16,11 @@ public class HttpHedgingStrategyOptions : HedgingStrategyOptions class.
///
///
- /// By default the options is set to handle only transient failures,
+ /// By default, the options is set to handle only transient failures,
/// i.e. timeouts, 5xx responses and exceptions.
///
public HttpHedgingStrategyOptions()
{
- ShouldHandle = args => new ValueTask(HttpClientHedgingResiliencePredicates.IsTransient(args.Outcome));
+ ShouldHandle = args => new ValueTask(HttpClientHedgingResiliencePredicates.IsTransient(args.Outcome, args.Context.CancellationToken));
}
}
diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpClientResiliencePredicates.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpClientResiliencePredicates.cs
index 0990c779842..3345c75f11a 100644
--- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpClientResiliencePredicates.cs
+++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpClientResiliencePredicates.cs
@@ -2,8 +2,11 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System;
+using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Http;
+using System.Threading;
+using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using Polly;
using Polly.Timeout;
@@ -26,6 +29,17 @@ public static class HttpClientResiliencePredicates
_ => false
};
+ ///
+ /// Determines whether an should be treated by resilience strategies as a transient failure.
+ ///
+ /// The outcome of the user-specified callback.
+ /// The associated with the execution.
+ /// if outcome is transient, if not.
+ [Experimental(diagnosticId: DiagnosticIds.Experiments.Resilience, UrlFormat = DiagnosticIds.UrlFormat)]
+ public static bool IsTransient(Outcome outcome, CancellationToken cancellationToken)
+ => IsHttpConnectionTimeout(outcome, cancellationToken)
+ || IsTransient(outcome);
+
///
/// Determines whether an exception should be treated by resilience strategies as a transient failure.
///
@@ -33,10 +47,14 @@ internal static bool IsTransientHttpException(Exception exception)
{
_ = Throw.IfNull(exception);
- return exception is HttpRequestException ||
- exception is TimeoutRejectedException;
+ return exception is HttpRequestException or TimeoutRejectedException;
}
+ internal static bool IsHttpConnectionTimeout(in Outcome outcome, in CancellationToken cancellationToken)
+ => !cancellationToken.IsCancellationRequested
+ && outcome.Exception is OperationCanceledException { Source: "System.Private.CoreLib" }
+ && outcome.Exception.InnerException is TimeoutException;
+
///
/// Determines whether a response contains a transient failure.
///
@@ -52,7 +70,6 @@ internal static bool IsTransientHttpFailure(HttpResponseMessage response)
return statusCode >= InternalServerErrorCode ||
response.StatusCode == HttpStatusCode.RequestTimeout ||
statusCode == TooManyRequests;
-
}
private const int InternalServerErrorCode = (int)HttpStatusCode.InternalServerError;
diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptions.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptions.cs
index 2153862f8a4..afde7d0372d 100644
--- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptions.cs
+++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptions.cs
@@ -26,7 +26,7 @@ public class HttpRetryStrategyOptions : RetryStrategyOptions
public HttpRetryStrategyOptions()
{
- ShouldHandle = args => new ValueTask(HttpClientResiliencePredicates.IsTransient(args.Outcome));
+ ShouldHandle = args => new ValueTask(HttpClientResiliencePredicates.IsTransient(args.Outcome, args.Context.CancellationToken));
BackoffType = DelayBackoffType.Exponential;
ShouldRetryAfterHeader = true;
UseJitter = true;
diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Hedging/HedgingTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Hedging/HedgingTests.cs
index f3c132e7c67..6b81d189ee6 100644
--- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Hedging/HedgingTests.cs
+++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Hedging/HedgingTests.cs
@@ -35,6 +35,7 @@ public abstract class HedgingTests : IDisposable
private readonly Func _requestRoutingStrategyFactory;
private readonly IServiceCollection _services;
private readonly Queue _responses = new();
+ private ServiceProvider? _serviceProvider;
private bool _failure;
private protected HedgingTests(Func, TBuilder> createDefaultBuilder)
@@ -63,6 +64,11 @@ public void Dispose()
_requestRoutingStrategyMock.VerifyAll();
_cancellationTokenSource.Cancel();
_cancellationTokenSource.Dispose();
+ _serviceProvider?.Dispose();
+ foreach (var response in _responses)
+ {
+ response.Dispose();
+ }
}
[Fact]
@@ -93,7 +99,7 @@ public async Task SendAsync_EnsureContextFlows()
using var client = CreateClientWithHandler();
- await client.SendAsync(request, _cancellationTokenSource.Token);
+ using var _ = await client.SendAsync(request, _cancellationTokenSource.Token);
Assert.Equal(2, calls);
}
@@ -108,7 +114,7 @@ public async Task SendAsync_NoErrors_ShouldReturnSingleResponse()
AddResponse(HttpStatusCode.OK);
- var response = await client.SendAsync(request, _cancellationTokenSource.Token);
+ using var _ = await client.SendAsync(request, _cancellationTokenSource.Token);
AssertNoResponse();
Assert.Single(Requests);
@@ -164,7 +170,7 @@ public async Task SendAsync_NoRoutesLeftAndSomeResultPresent_ShouldReturn()
using var client = CreateClientWithHandler();
- var result = await client.SendAsync(request, _cancellationTokenSource.Token);
+ using var result = await client.SendAsync(request, _cancellationTokenSource.Token);
Assert.Equal(DefaultHedgingAttempts + 1, Requests.Count);
Assert.Equal(HttpStatusCode.ServiceUnavailable, result.StatusCode);
}
@@ -183,7 +189,7 @@ public async Task SendAsync_EnsureDistinctContextForEachAttempt()
using var client = CreateClientWithHandler();
- await client.SendAsync(request, _cancellationTokenSource.Token);
+ using var _ = await client.SendAsync(request, _cancellationTokenSource.Token);
RequestContexts.Distinct().OfType().Should().HaveCount(3);
}
@@ -204,7 +210,7 @@ public async Task SendAsync_EnsureContextReplacedInRequestMessage()
using var client = CreateClientWithHandler();
- await client.SendAsync(request, _cancellationTokenSource.Token);
+ using var _ = await client.SendAsync(request, _cancellationTokenSource.Token);
RequestContexts.Distinct().OfType().Should().HaveCount(3);
@@ -226,7 +232,7 @@ public async Task SendAsync_NoRoutesLeft_EnsureLessThanMaxHedgedAttempts()
using var client = CreateClientWithHandler();
- var result = await client.SendAsync(request, _cancellationTokenSource.Token);
+ using var _ = await client.SendAsync(request, _cancellationTokenSource.Token);
Assert.Equal(2, Requests.Count);
}
@@ -244,7 +250,7 @@ public async Task SendAsync_FailedExecution_ShouldReturnResponseFromHedging()
using var client = CreateClientWithHandler();
- var result = await client.SendAsync(request, _cancellationTokenSource.Token);
+ using var result = await client.SendAsync(request, _cancellationTokenSource.Token);
Assert.Equal(3, Requests.Count);
Assert.Equal(HttpStatusCode.OK, result.StatusCode);
Assert.Equal("https://enpoint-1:80/some-path?query", Requests[0]);
@@ -268,7 +274,12 @@ protected void AddResponse(HttpStatusCode statusCode, int count)
protected abstract void ConfigureHedgingOptions(Action configure);
- protected HttpClient CreateClientWithHandler() => _services.BuildServiceProvider().GetRequiredService().CreateClient(ClientId);
+ protected HttpClient CreateClientWithHandler()
+ {
+ _serviceProvider?.Dispose();
+ _serviceProvider = _services.BuildServiceProvider();
+ return _serviceProvider.GetRequiredService().CreateClient(ClientId);
+ }
private Task InnerHandlerFunction(HttpRequestMessage request, CancellationToken cancellationToken)
{
diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Hedging/OperationCanceledExceptionMock.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Hedging/OperationCanceledExceptionMock.cs
new file mode 100644
index 00000000000..7ea18c5782a
--- /dev/null
+++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Hedging/OperationCanceledExceptionMock.cs
@@ -0,0 +1,16 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+
+namespace Microsoft.Extensions.Http.Resilience.Test.Hedging;
+
+internal sealed class OperationCanceledExceptionMock : OperationCanceledException
+{
+ public OperationCanceledExceptionMock(Exception innerException)
+ : base(null, innerException)
+ {
+ }
+
+ public override string? Source { get => "System.Private.CoreLib"; set => base.Source = value; }
+}
diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Hedging/StandardHedgingTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Hedging/StandardHedgingTests.cs
index fb381f31e1b..debafe03127 100644
--- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Hedging/StandardHedgingTests.cs
+++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Hedging/StandardHedgingTests.cs
@@ -46,7 +46,7 @@ public void EnsureValidated_BasicValidation()
{
Builder.Configure(options => options.Hedging.MaxHedgedAttempts = -1);
- Assert.Throws(() => CreateClientWithHandler());
+ Assert.Throws(CreateClientWithHandler);
}
[Fact]
@@ -54,7 +54,7 @@ public void EnsureValidated_AdvancedValidation()
{
Builder.Configure(options => options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(1));
- Assert.Throws(() => CreateClientWithHandler());
+ Assert.Throws(CreateClientWithHandler);
}
[Fact]
@@ -62,7 +62,8 @@ public void Configure_Callback_Ok()
{
Builder.Configure(o => o.Hedging.MaxHedgedAttempts = 8);
- var options = Builder.Services.BuildServiceProvider().GetRequiredService>().Get(Builder.Name);
+ using var serviceProvider = Builder.Services.BuildServiceProvider();
+ var options = serviceProvider.GetRequiredService>().Get(Builder.Name);
Assert.Equal(8, options.Hedging.MaxHedgedAttempts);
}
@@ -76,7 +77,8 @@ public void Configure_CallbackWithServiceProvider_Ok()
o.Hedging.MaxHedgedAttempts = 8;
});
- var options = Builder.Services.BuildServiceProvider().GetRequiredService>().Get(Builder.Name);
+ using var serviceProvider = Builder.Services.BuildServiceProvider();
+ var options = serviceProvider.GetRequiredService>().Get(Builder.Name);
Assert.Equal(8, options.Hedging.MaxHedgedAttempts);
}
@@ -97,7 +99,8 @@ public void Configure_ValidConfigurationSection_ShouldInitialize()
Builder.Configure(section);
- var options = Builder.Services.BuildServiceProvider().GetRequiredService>().Get(Builder.Name);
+ using var serviceProvider = Builder.Services.BuildServiceProvider();
+ var options = serviceProvider.GetRequiredService>().Get(Builder.Name);
Assert.Equal(8, options.Hedging.MaxHedgedAttempts);
}
@@ -105,7 +108,8 @@ public void Configure_ValidConfigurationSection_ShouldInitialize()
[Fact]
public void ActionGenerator_Ok()
{
- var options = Builder.Services.BuildServiceProvider().GetRequiredService>().Get(Builder.Name);
+ using var serviceProvider = Builder.Services.BuildServiceProvider();
+ var options = serviceProvider.GetRequiredService>().Get(Builder.Name);
var generator = options.Hedging.ActionGenerator;
var primary = ResilienceContextPool.Shared.Get();
var secondary = ResilienceContextPool.Shared.Get();
@@ -133,9 +137,12 @@ public void Configure_InvalidConfigurationSection_ShouldThrow()
Builder.Configure(section);
Assert.Throws(() =>
- Builder.Services.BuildServiceProvider()
- .GetRequiredService>()
- .Get(Builder.Name));
+ {
+ using var serviceProvider = Builder.Services.BuildServiceProvider();
+ return serviceProvider
+ .GetRequiredService>()
+ .Get(Builder.Name);
+ });
}
#endif
@@ -163,7 +170,7 @@ public void Configure_EmptyConfigurationSection_ShouldThrow()
[Fact]
public void VerifyPipeline()
{
- var serviceProvider = Builder.Services.BuildServiceProvider();
+ using var serviceProvider = Builder.Services.BuildServiceProvider();
var pipelineProvider = serviceProvider.GetRequiredService>();
// primary handler
@@ -209,7 +216,7 @@ public async Task VerifyPipelineSelection(string? customKey)
using var request = new HttpRequestMessage(HttpMethod.Get, "https://key:80/discarded");
AddResponse(HttpStatusCode.OK);
- var response = await client.SendAsync(request, CancellationToken.None);
+ using var response = await client.SendAsync(request, CancellationToken.None);
provider.VerifyAll();
}
@@ -235,14 +242,14 @@ public async Task DynamicReloads_Ok()
// act && assert
AddResponse(HttpStatusCode.InternalServerError, 3);
using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "https://to-be-replaced:1234/some-path?query");
- await client.SendAsync(firstRequest);
+ using var _ = await client.SendAsync(firstRequest);
AssertNoResponse();
reloadAction(new() { { "standard:Hedging:MaxHedgedAttempts", "6" } });
AddResponse(HttpStatusCode.InternalServerError, 7);
using var secondRequest = new HttpRequestMessage(HttpMethod.Get, "https://to-be-replaced:1234/some-path?query");
- await client.SendAsync(secondRequest);
+ using var __ = await client.SendAsync(secondRequest);
AssertNoResponse();
}
@@ -257,11 +264,70 @@ public async Task NoRouting_Ok()
// act && assert
AddResponse(HttpStatusCode.InternalServerError, 3);
using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "https://some-endpoint:1234/some-path?query");
- await client.SendAsync(firstRequest);
+ using var _ = await client.SendAsync(firstRequest);
AssertNoResponse();
Requests.Should().AllSatisfy(r => r.Should().Be("https://some-endpoint:1234/some-path?query"));
}
+ [Fact]
+ public async Task SendAsync_FailedConnect_ShouldReturnResponseFromHedging()
+ {
+ const string FailingEndpoint = "www.failing-host.com";
+
+ var services = new ServiceCollection();
+ _ = services
+ .AddHttpClient(ClientId)
+ .ConfigurePrimaryHttpMessageHandler(() => new MockHttpMessageHandler(FailingEndpoint))
+ .AddStandardHedgingHandler(routing =>
+ routing.ConfigureOrderedGroups(g =>
+ {
+ g.Groups.Add(new UriEndpointGroup
+ {
+ Endpoints = [new WeightedUriEndpoint { Uri = new Uri($"https://{FailingEndpoint}:3000") }]
+ });
+
+ g.Groups.Add(new UriEndpointGroup
+ {
+ Endpoints = [new WeightedUriEndpoint { Uri = new Uri("https://microsoft.com") }]
+ });
+ }))
+ .Configure(opt =>
+ {
+ opt.Hedging.MaxHedgedAttempts = 10;
+ opt.Hedging.Delay = TimeSpan.FromSeconds(11);
+ opt.Endpoint.CircuitBreaker.FailureRatio = 0.99;
+ opt.Endpoint.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(900);
+ opt.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(200);
+ opt.Endpoint.Timeout.Timeout = TimeSpan.FromSeconds(200);
+ });
+
+ await using var provider = services.BuildServiceProvider();
+ var clientFactory = provider.GetRequiredService();
+ using var client = clientFactory.CreateClient(ClientId);
+
+ var ex = await Record.ExceptionAsync(async () =>
+ {
+ using var _ = await client.GetAsync($"https://{FailingEndpoint}:3000");
+ });
+
+ Assert.Null(ex);
+ }
+
protected override void ConfigureHedgingOptions(Action configure) => Builder.Configure(options => configure(options.Hedging));
+
+ private class MockHttpMessageHandler(string failingEndpoint) : HttpMessageHandler
+ {
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (request.RequestUri?.Host == failingEndpoint)
+ {
+ await Task.Delay(100, cancellationToken);
+ throw new OperationCanceledExceptionMock(new TimeoutException());
+ }
+
+ await Task.Delay(1000, cancellationToken);
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ }
+ }
}
diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Microsoft.Extensions.Http.Resilience.Tests.csproj b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Microsoft.Extensions.Http.Resilience.Tests.csproj
index 266794186c7..95e047fabb3 100644
--- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Microsoft.Extensions.Http.Resilience.Tests.csproj
+++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Microsoft.Extensions.Http.Resilience.Tests.csproj
@@ -4,11 +4,6 @@
Unit tests for Microsoft.Extensions.Http.Resilience.
-
-
- true
-
-
diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsTests.cs
index 56fb80c56f9..a9fac7d3e0b 100644
--- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsTests.cs
+++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsTests.cs
@@ -6,8 +6,10 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
+using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
+using Microsoft.Extensions.Http.Resilience.Test.Hedging;
using Polly;
using Polly.Retry;
using Xunit;
@@ -19,16 +21,15 @@ public class HttpRetryStrategyOptionsTests
#pragma warning disable S2330
public static readonly IEnumerable