From bd27aea09d8798ccbf536ca90bb947f5d8f23104 Mon Sep 17 00:00:00 2001 From: Vladimir Pecanac Date: Wed, 23 Sep 2026 14:31:53 +0200 Subject: [PATCH] HttpClient timeouts: retarget net10.0, add timeout-vs-cancellation and resilience endpoints - Both projects net8.0 to net10.0; web project Nullable enabled. - Test packages: Microsoft.NET.Test.Sdk 18.10.1, xunit 2.9.3, xunit.runner.visualstudio 4.0.0, coverlet.collector 10.0.1, Microsoft.AspNetCore.Mvc.Testing 10.0.12. - Add Microsoft.Extensions.Http.Resilience 10.10.0, a ResilientClient named client with AddStandardResilienceHandler, and /api/test-resilience-timeout. - Add /api/test-timeout-vs-cancellation, which uses an exception filter on the nested TimeoutException. - Two new tests in EndpointsLiveTests, so the CI Live filter still skips them. - Drop the unused container tools package and its Docker properties, AddControllers, MapControllers, UseAuthorization, UseHttpsRedirection and four unused using directives. - Dispose the three HttpResponseMessage locals with using var. --- ...tpClientDefaultAndPerRequestTimeOut.csproj | 8 +-- .../Program.cs | 69 +++++++++++++------ .../EndpointsLiveTests.cs | 26 +++++++ ...ntDefaultAndPerRequestTimeOut.Tests.csproj | 12 ++-- 4 files changed, 84 insertions(+), 31 deletions(-) diff --git a/aspnetcore-features/HttpClientDefaultAndPerRequestTimeOut/HttpClientDefaultAndPerRequestTimeOut/HttpClientDefaultAndPerRequestTimeOut.csproj b/aspnetcore-features/HttpClientDefaultAndPerRequestTimeOut/HttpClientDefaultAndPerRequestTimeOut/HttpClientDefaultAndPerRequestTimeOut.csproj index ef14247743..7126bc21cc 100644 --- a/aspnetcore-features/HttpClientDefaultAndPerRequestTimeOut/HttpClientDefaultAndPerRequestTimeOut/HttpClientDefaultAndPerRequestTimeOut.csproj +++ b/aspnetcore-features/HttpClientDefaultAndPerRequestTimeOut/HttpClientDefaultAndPerRequestTimeOut/HttpClientDefaultAndPerRequestTimeOut.csproj @@ -1,16 +1,14 @@  - net8.0 - disable + net10.0 + enable enable d0d8591a-b364-4f96-921f-d24a0e638079 - Linux - . - + diff --git a/aspnetcore-features/HttpClientDefaultAndPerRequestTimeOut/HttpClientDefaultAndPerRequestTimeOut/Program.cs b/aspnetcore-features/HttpClientDefaultAndPerRequestTimeOut/HttpClientDefaultAndPerRequestTimeOut/Program.cs index ff96ee4d51..0d931ddd1c 100644 --- a/aspnetcore-features/HttpClientDefaultAndPerRequestTimeOut/HttpClientDefaultAndPerRequestTimeOut/Program.cs +++ b/aspnetcore-features/HttpClientDefaultAndPerRequestTimeOut/HttpClientDefaultAndPerRequestTimeOut/Program.cs @@ -1,14 +1,5 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Mvc; -using System.Net.Http; -using System.Threading; - var builder = WebApplication.CreateBuilder(args); -// Add services to the container. - -builder.Services.AddControllers(); - builder.Services.AddHttpClient("TestClient", (sp, httpClient) => { var configuration = sp.GetRequiredService(); @@ -18,15 +9,17 @@ httpClient.Timeout = TimeSpan.FromSeconds(timeoutSeconds); }); -var app = builder.Build(); - -// Configure the HTTP request pipeline. - -app.UseHttpsRedirection(); - -app.UseAuthorization(); +builder.Services.AddHttpClient("ResilientClient", httpClient => +{ + httpClient.BaseAddress = new Uri("http://localhost:5000"); +}) +.AddStandardResilienceHandler(options => +{ + options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2); + options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(8); +}); -app.MapControllers(); +var app = builder.Build(); app.MapGet("/api/delay-4-seconds", async (CancellationToken cancellationToken) => await Task.Delay(TimeSpan.FromSeconds(4), cancellationToken)); @@ -36,7 +29,7 @@ try { - var response = await httpClient.GetAsync("/api/delay-4-seconds"); + using var response = await httpClient.GetAsync("/api/delay-4-seconds"); return Results.Ok(); } @@ -53,7 +46,7 @@ try { - var response = await httpClient.GetAsync($"/api/delay-4-seconds", cancellationToken); + using var response = await httpClient.GetAsync($"/api/delay-4-seconds", cancellationToken); return Results.Ok(); } @@ -71,7 +64,7 @@ try { - var response = await httpClient.GetAsync("/api/delay-4-seconds", tokenSource.Token); + using var response = await httpClient.GetAsync("/api/delay-4-seconds", tokenSource.Token); return Results.Ok(); } @@ -83,4 +76,40 @@ } }); +app.MapGet("/api/test-timeout-vs-cancellation", async (IHttpClientFactory httpClientFactory, CancellationToken cancellationToken) => +{ + var httpClient = httpClientFactory.CreateClient("TestClient"); + + try + { + using var response = await httpClient.GetAsync("/api/delay-4-seconds", cancellationToken); + + return Results.Ok(); + } + catch (OperationCanceledException ex) when (ex.InnerException is TimeoutException) + { + return Results.Text("The request timed out"); + } + catch (OperationCanceledException) + { + return Results.Text("The caller canceled the request"); + } +}); + +app.MapGet("/api/test-resilience-timeout", async (IHttpClientFactory httpClientFactory) => +{ + var httpClient = httpClientFactory.CreateClient("ResilientClient"); + + try + { + using var response = await httpClient.GetAsync("/api/delay-4-seconds"); + + return Results.Ok(); + } + catch (Exception ex) + { + return Results.Text($"{ex.GetType().Name} after {httpClient.Timeout}"); + } +}); + app.Run(); diff --git a/aspnetcore-features/HttpClientDefaultAndPerRequestTimeOut/Tests/HttpClientDefaultAndPerRequestTimeOut.Tests/EndpointsLiveTests.cs b/aspnetcore-features/HttpClientDefaultAndPerRequestTimeOut/Tests/HttpClientDefaultAndPerRequestTimeOut.Tests/EndpointsLiveTests.cs index b32d371271..d3bc20368b 100644 --- a/aspnetcore-features/HttpClientDefaultAndPerRequestTimeOut/Tests/HttpClientDefaultAndPerRequestTimeOut.Tests/EndpointsLiveTests.cs +++ b/aspnetcore-features/HttpClientDefaultAndPerRequestTimeOut/Tests/HttpClientDefaultAndPerRequestTimeOut.Tests/EndpointsLiveTests.cs @@ -55,4 +55,30 @@ public async Task GivenCustomTokenIsCombinedWithGlobalAndRequestToken_WhenTheReq // Assert await Assert.ThrowsAsync(() => client.GetAsync("/api/test-combined-timeout", cts.Token)); } + + [Fact] + public async Task WhenTheTimeoutElapses_ThenTheOperationCanceledExceptionNestsATimeoutException() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var responseMessage = await client.GetStringAsync("/api/test-timeout-vs-cancellation"); + + // Assert + Assert.Equal("The request timed out", responseMessage); + } + + [Fact] + public async Task WhenTheResilienceHandlerTimesOut_ThenItThrowsTimeoutRejectedExceptionAndDisablesHttpClientTimeout() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var responseMessage = await client.GetStringAsync("/api/test-resilience-timeout"); + + // Assert + Assert.Equal("TimeoutRejectedException after -00:00:00.0010000", responseMessage); + } } \ No newline at end of file diff --git a/aspnetcore-features/HttpClientDefaultAndPerRequestTimeOut/Tests/HttpClientDefaultAndPerRequestTimeOut.Tests/HttpClientDefaultAndPerRequestTimeOut.Tests.csproj b/aspnetcore-features/HttpClientDefaultAndPerRequestTimeOut/Tests/HttpClientDefaultAndPerRequestTimeOut.Tests/HttpClientDefaultAndPerRequestTimeOut.Tests.csproj index 7b06f7d1f5..87e4fe4bb1 100644 --- a/aspnetcore-features/HttpClientDefaultAndPerRequestTimeOut/Tests/HttpClientDefaultAndPerRequestTimeOut.Tests/HttpClientDefaultAndPerRequestTimeOut.Tests.csproj +++ b/aspnetcore-features/HttpClientDefaultAndPerRequestTimeOut/Tests/HttpClientDefaultAndPerRequestTimeOut.Tests/HttpClientDefaultAndPerRequestTimeOut.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable @@ -10,11 +10,11 @@ - - - - - + + + + +