Skip to content

Commit 596d108

Browse files
committed
feat: add HttpClientFactory command resilience
1 parent 7eda0f0 commit 596d108

7 files changed

Lines changed: 494 additions & 7 deletions

File tree

Directory.Build.props

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727
<RepositoryUrl>https://github.com/managedcode/Communication</RepositoryUrl>
2828
<PackageProjectUrl>https://github.com/managedcode/Communication</PackageProjectUrl>
2929
<Product>Managed Code - Communication</Product>
30-
<Version>10.2.0</Version>
31-
<PackageVersion>10.2.0</PackageVersion>
30+
<Version>10.2.1</Version>
31+
<PackageVersion>10.2.1</PackageVersion>
3232

3333
</PropertyGroup>
3434
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true'">
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System;
2+
using System.Net.Http;
3+
using ManagedCode.Communication.Commands.Execution;
4+
5+
namespace ManagedCode.Communication.Extensions.Http;
6+
7+
/// <summary>
8+
/// Configures native command reliability for an <see cref="IHttpClientFactory"/> client.
9+
/// </summary>
10+
public sealed class CommandHttpClientOptions
11+
{
12+
/// <summary>Creates safe HTTP defaults backed by command retry and circuit breaking.</summary>
13+
public CommandHttpClientOptions()
14+
{
15+
Execution.Retry.Enabled = true;
16+
Execution.Timeout.Enabled = false;
17+
Execution.Idempotency.Enabled = false;
18+
Execution.CircuitBreaker.Enabled = true;
19+
Execution.CircuitBreaker.PartitionKeySelector = static command =>
20+
command.CorrelationId ?? command.CommandType;
21+
Execution.RateLimiter.Enabled = false;
22+
}
23+
24+
/// <summary>Native command execution settings used by the HTTP handler.</summary>
25+
public CommandExecutionOptions Execution { get; } = new();
26+
27+
/// <summary>
28+
/// Selects requests that may be replayed. The default accepts only content-free GET, HEAD, OPTIONS, and TRACE
29+
/// requests. Requests with content are always passed through once.
30+
/// </summary>
31+
public Func<HttpRequestMessage, bool> ShouldHandle { get; set; } = static request =>
32+
request.Method == HttpMethod.Get
33+
|| request.Method == HttpMethod.Head
34+
|| request.Method == HttpMethod.Options
35+
|| request.Method == HttpMethod.Trace;
36+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
using System;
2+
using System.Net;
3+
using System.Net.Http;
4+
using System.Runtime.ExceptionServices;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using ManagedCode.Communication.Commands;
8+
using ManagedCode.Communication.Commands.Execution;
9+
using ManagedCode.Communication.Constants;
10+
using Microsoft.Extensions.Logging;
11+
12+
namespace ManagedCode.Communication.Extensions.Http;
13+
14+
internal sealed class CommunicationResilienceHandler : DelegatingHandler
15+
{
16+
private readonly CommandExecutionRuntime _runtime;
17+
private readonly Func<HttpRequestMessage, bool> _shouldHandle;
18+
19+
public CommunicationResilienceHandler(CommandHttpClientOptions options, ILogger logger)
20+
{
21+
ArgumentNullException.ThrowIfNull(options);
22+
ArgumentNullException.ThrowIfNull(logger);
23+
ArgumentNullException.ThrowIfNull(options.ShouldHandle);
24+
25+
_shouldHandle = options.ShouldHandle;
26+
_runtime = new CommandExecutionRuntime(options.Execution, logger: logger);
27+
}
28+
29+
protected override async Task<HttpResponseMessage> SendAsync(
30+
HttpRequestMessage request,
31+
CancellationToken cancellationToken)
32+
{
33+
ArgumentNullException.ThrowIfNull(request);
34+
if (request.Content is not null || !_shouldHandle(request))
35+
{
36+
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
37+
}
38+
39+
var command = Command.Create(HttpCommandExecutionConstants.CommandType);
40+
command.CorrelationId = ResolveAuthority(request.RequestUri);
41+
42+
HttpResponseMessage? lastResponse = null;
43+
ExceptionDispatchInfo? lastException = null;
44+
var result = await CommandExecutor.ExecuteResultAsync<Command, HttpResponseMessage>(
45+
command,
46+
async (_, token) =>
47+
{
48+
lastResponse?.Dispose();
49+
lastResponse = null;
50+
lastException = null;
51+
52+
using var attempt = CloneRequest(request);
53+
try
54+
{
55+
var response = await base.SendAsync(attempt, token).ConfigureAwait(false);
56+
response.RequestMessage = request;
57+
lastResponse = response;
58+
if (response.IsSuccessStatusCode)
59+
{
60+
return Result<HttpResponseMessage>.Succeed(response);
61+
}
62+
63+
return Result<HttpResponseMessage>.Fail(CreateProblem(response));
64+
}
65+
catch (Exception exception) when (exception is not OperationCanceledException
66+
|| !cancellationToken.IsCancellationRequested)
67+
{
68+
lastException = ExceptionDispatchInfo.Capture(exception);
69+
throw;
70+
}
71+
},
72+
_runtime,
73+
cancellationToken)
74+
.ConfigureAwait(false);
75+
76+
if (result.IsSuccess)
77+
{
78+
return result.Value!;
79+
}
80+
81+
if (lastResponse is not null)
82+
{
83+
return lastResponse;
84+
}
85+
86+
lastException?.Throw();
87+
throw new HttpRequestException(
88+
result.Problem?.Detail ?? HttpCommandExecutionConstants.ExecutionFailedDetail,
89+
null,
90+
HttpStatusCode.ServiceUnavailable);
91+
}
92+
93+
private static HttpRequestMessage CloneRequest(HttpRequestMessage source)
94+
{
95+
var clone = new HttpRequestMessage(source.Method, source.RequestUri)
96+
{
97+
Version = source.Version,
98+
VersionPolicy = source.VersionPolicy
99+
};
100+
101+
foreach (var header in source.Headers)
102+
{
103+
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
104+
}
105+
106+
foreach (var option in source.Options)
107+
{
108+
clone.Options.Set(new HttpRequestOptionsKey<object?>(option.Key), option.Value);
109+
}
110+
111+
return clone;
112+
}
113+
114+
private static Problem CreateProblem(HttpResponseMessage response)
115+
{
116+
var problem = Problem.Create(
117+
response.ReasonPhrase ?? HttpCommandExecutionConstants.FailureTitle,
118+
HttpCommandExecutionConstants.FailureDetail,
119+
response.StatusCode);
120+
PromoteRetryAfter(response, problem);
121+
return problem;
122+
}
123+
124+
private static void PromoteRetryAfter(HttpResponseMessage response, Problem problem)
125+
{
126+
var retryAfter = response.Headers.RetryAfter;
127+
if (retryAfter?.Delta is { } delta && delta >= TimeSpan.Zero)
128+
{
129+
problem.Extensions[ProblemConstants.ExtensionKeys.RetryAfter] = delta;
130+
return;
131+
}
132+
133+
if (retryAfter?.Date is { } date)
134+
{
135+
var remaining = date - DateTimeOffset.UtcNow;
136+
problem.Extensions[ProblemConstants.ExtensionKeys.RetryAfter] =
137+
remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
138+
}
139+
}
140+
141+
private static string ResolveAuthority(Uri? requestUri)
142+
{
143+
return requestUri is { IsAbsoluteUri: true }
144+
? requestUri.GetLeftPart(UriPartial.Authority)
145+
: HttpCommandExecutionConstants.UnknownAuthority;
146+
}
147+
}
148+
149+
internal static class HttpCommandExecutionConstants
150+
{
151+
public const string CommandType = "http.client.send";
152+
public const string UnknownAuthority = "unknown-authority";
153+
public const string FailureTitle = "HTTP request failed";
154+
public const string FailureDetail = "The HTTP dependency returned a non-success status code.";
155+
public const string ExecutionFailedDetail = "HTTP command execution failed before a response was received.";
156+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using System;
2+
using System.Net.Http;
3+
using ManagedCode.Communication.Commands.Execution;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using Microsoft.Extensions.Http;
6+
using Microsoft.Extensions.Logging;
7+
8+
namespace ManagedCode.Communication.Extensions.Http;
9+
10+
/// <summary>Registers native command reliability in <see cref="IHttpClientFactory"/> pipelines.</summary>
11+
public static class HttpClientBuilderExtensions
12+
{
13+
/// <summary>Adds retry and circuit breaking backed by <see cref="CommandExecutor"/>.</summary>
14+
public static IHttpClientBuilder AddCommunicationResilienceHandler(
15+
this IHttpClientBuilder builder,
16+
Action<CommandHttpClientOptions>? configure = null)
17+
{
18+
ArgumentNullException.ThrowIfNull(builder);
19+
20+
return builder.AddHttpMessageHandler(serviceProvider =>
21+
{
22+
var options = new CommandHttpClientOptions();
23+
configure?.Invoke(options);
24+
var logger = serviceProvider
25+
.GetRequiredService<ILoggerFactory>()
26+
.CreateLogger<CommunicationResilienceHandler>();
27+
return new CommunicationResilienceHandler(options, logger);
28+
});
29+
}
30+
31+
/// <summary>
32+
/// Removes inherited Communication resilience handlers from this named client. A handler added after this call
33+
/// remains active, allowing a client to replace shared defaults with a specific policy.
34+
/// </summary>
35+
public static IHttpClientBuilder RemoveCommunicationResilienceHandler(this IHttpClientBuilder builder)
36+
{
37+
ArgumentNullException.ThrowIfNull(builder);
38+
39+
builder.ConfigureAdditionalHttpMessageHandlers(static (handlers, _) =>
40+
{
41+
for (var index = handlers.Count - 1; index >= 0; index--)
42+
{
43+
if (handlers[index] is CommunicationResilienceHandler)
44+
{
45+
handlers.RemoveAt(index);
46+
}
47+
}
48+
});
49+
return builder;
50+
}
51+
}

ManagedCode.Communication.Extensions/ManagedCode.Communication.Extensions.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
<PropertyGroup>
1010
<Title>ManagedCode.Communication.Extensions</Title>
1111
<PackageId>ManagedCode.Communication.Extensions</PackageId>
12-
<Description>Fluent extras for ManagedCode.Communication: railway-oriented composition (Bind, Map, Tap, Then, Ensure, Match, Compensate…) and HttpClient helpers that return Result. No ASP.NET Core dependency — usable from console, worker, Blazor WASM and MAUI.</Description>
12+
<Description>Fluent extras for ManagedCode.Communication: railway-oriented composition, HttpClient Result helpers, and native command resilience for IHttpClientFactory. No ASP.NET Core dependency.</Description>
1313
<PackageTags>managedcode;communication;result-pattern;railway-oriented;rop;commands;retry;rate-limiting</PackageTags>
1414
</PropertyGroup>
1515

1616
<ItemGroup>
1717
<ProjectReference Include="..\ManagedCode.Communication\ManagedCode.Communication.csproj" />
18+
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.11" />
1819
</ItemGroup>
1920

2021
</Project>

0 commit comments

Comments
 (0)