From 122714d454b9343bbe8a88c904ee5b0d052527db Mon Sep 17 00:00:00 2001 From: Toka Date: Tue, 28 Jul 2026 18:21:01 +0400 Subject: [PATCH] Auth retry --- README.md | 50 +++- .../Extensions/ServiceCollectionExtensions.cs | 39 ++- .../OpenApiClientFactory.cs | 16 +- .../OAuthTokenCachingPipelineTests.cs | 252 ++++++++++++++++++ 4 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 tests/TBC.OpenAPI.SDK.Core.Tests/OAuthTokenCachingPipelineTests.cs diff --git a/README.md b/README.md index 7d2d5f6..b873ed8 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,55 @@ The handler **evicts, it does not refresh**: the failed request is not retried, never uses the `refresh_token` grant (no refresh token is stored) and never renews proactively. The only protection against sending an about-to-expire token is the 30-second grace period subtracted from `expires_in`, so treat a `401` as a normal failed -response — retry and backoff are the caller's responsibility. +response. + +Retry and backoff are the caller's responsibility — Core ships no retry logic and takes no dependency on Polly or any resilience library. It does, however, give you the hook needed to turn that eviction into a retry that actually recovers; see [Retrying on 401](#retrying-on-401). + +### Retrying on 401 + +Because the handler evicts the token on a `401` but only the *next* request for that scope picks up a fresh one, a retry only recovers if it **re-enters the OAuth handler** after the eviction. That means the retry has to sit *outside* `OAuthDelegatingHandler` in the `HttpClient` pipeline. `AddOAuthTokenCaching` therefore takes an optional `Action configurePipeline` hook: any handler it registers is placed outside the OAuth handler, so a retried attempt re-enters token handling and, because the `401` already evicted the token, acquires a fresh one. + +Core supplies no retry implementation — you plug in whichever mechanism you prefer. + +> [!IMPORTANT] +> A retry handler **must clone the request on every attempt**. The OAuth handler consumes the `X-TBC-OAuth-Scope` marker header, and the request content is consumed once it is sent, so re-sending the same `HttpRequestMessage` fails. `Microsoft.Extensions.Http.Resilience` clones automatically. Scope the retry to `401 Unauthorized` so genuinely failed requests are not re-issued. + +**With `Microsoft.Extensions.Http.Resilience` (Polly):** + +```c# +using Microsoft.Extensions.Http.Resilience; +using Polly; + +builder.Services + .AddOpenApiClient(options) + .AddOAuthTokenCaching(configurePipeline: pipeline => + pipeline.AddResilienceHandler("example-401-retry", b => + b.AddRetry(new HttpRetryStrategyOptions + { + MaxRetryAttempts = 1, + ShouldHandle = args => ValueTask.FromResult( + args.Outcome.Result?.StatusCode == System.Net.HttpStatusCode.Unauthorized) + }))) + .UseInMemoryCache(); +``` + +The same `configurePipeline` parameter is available on the factory builder's +`AddOAuthTokenCaching`: + +```c# +var factory = new OpenApiClientFactoryBuilder() + .AddClient(options) + .AddOAuthTokenCaching(configurePipeline: pipeline => + pipeline.AddResilienceHandler("example-401-retry", b => + b.AddRetry(new HttpRetryStrategyOptions + { + MaxRetryAttempts = 1, + ShouldHandle = args => ValueTask.FromResult( + args.Outcome.Result?.StatusCode == System.Net.HttpStatusCode.Unauthorized) + }))) + .UseInMemoryCache() + .Build(); +``` ### Cache behavior diff --git a/src/TBC.OpenAPI.SDK.Core/Extensions/ServiceCollectionExtensions.cs b/src/TBC.OpenAPI.SDK.Core/Extensions/ServiceCollectionExtensions.cs index 9db7abb..ae8a65b 100644 --- a/src/TBC.OpenAPI.SDK.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/TBC.OpenAPI.SDK.Core/Extensions/ServiceCollectionExtensions.cs @@ -64,6 +64,22 @@ public static IServiceCollection AddOpenApiClient /// + /// To turn that eviction into an actual retry, supply . + /// The it receives is the client's own pipeline, and any + /// handler it registers is placed outside the + /// - the only position from which a retried + /// attempt re-enters token handling and, because the 401 already evicted the token, + /// acquires a fresh one. This SDK ships no retry logic and takes no dependency on any + /// resilience library; you plug in your own (for example + /// Microsoft.Extensions.Http.Resilience's AddResilienceHandler, or a custom + /// ). A retry handler must clone the request per attempt: + /// the scope marker header is consumed by and + /// the request content is consumed when it is sent, so re-sending the same + /// fails. Microsoft.Extensions.Http.Resilience clones + /// automatically; a custom handler must do so itself. Scope the retry to + /// 401 Unauthorized responses to avoid re-issuing genuinely failed requests. + /// + /// /// Call this after /// for the same client, then select a cache on the returned builder with exactly one of /// , @@ -73,11 +89,20 @@ public static IServiceCollection AddOpenApiClient /// + /// The service collection to register the token handling into. + /// + /// Optional hook that configures the client's HTTP pipeline. Handlers it registers are placed + /// outside the OAuth handler, which is where a retry has to sit to benefit from the token + /// eviction a 401 triggers. When the behaviour is unchanged: a + /// 401 evicts the token and is returned to the caller without a retry. + /// /// A builder used to select where access tokens are cached. /// /// is an interface rather than a client implementation type. /// - public static OAuthTokenCachingBuilder AddOAuthTokenCaching(this IServiceCollection services) + public static OAuthTokenCachingBuilder AddOAuthTokenCaching( + this IServiceCollection services, + Action? configurePipeline = null) where TClient : class, IOpenApiClient { #if NET @@ -96,8 +121,16 @@ public static OAuthTokenCachingBuilder AddOAuthTokenCaching(th services.TryAddTransient>(); - services.AddHttpClient(typeof(TClient).FullName!) - .AddHttpMessageHandler>(); + var httpClientBuilder = services.AddHttpClient(typeof(TClient).FullName!); + + // Handlers are nested in registration order, outermost first. Registering the caller's + // pipeline before the OAuth handler puts any retry it adds outside token handling, so a + // retried attempt re-enters the OAuth handler and picks up a token freshly acquired after + // the 401 eviction. Registering it afterwards would trap the retry inside the OAuth + // handler, where the bearer is already set and no re-acquisition happens. + configurePipeline?.Invoke(httpClientBuilder); + + httpClientBuilder.AddHttpMessageHandler>(); return new OAuthTokenCachingBuilder(services); } diff --git a/src/TBC.OpenAPI.SDK.Core/OpenApiClientFactory.cs b/src/TBC.OpenAPI.SDK.Core/OpenApiClientFactory.cs index 7f0a2bf..f6b09f9 100644 --- a/src/TBC.OpenAPI.SDK.Core/OpenApiClientFactory.cs +++ b/src/TBC.OpenAPI.SDK.Core/OpenApiClientFactory.cs @@ -67,17 +67,29 @@ public OpenApiClientFactoryBuilder AddClient /// There is no token refresh: a 401 Unauthorized response evicts the cached token and /// is returned to the caller unchanged. The request that hit the 401 is not retried. + /// Supply to turn that eviction into a retry: any handler + /// it registers is placed outside the , + /// the only position from which a retried attempt re-enters token handling and acquires a fresh + /// token. This SDK ships no retry logic and depends on no resilience library; a retry handler + /// must clone the request per attempt (the scope marker header and the request content are + /// consumed when the request is sent). /// /// + /// + /// Optional hook that configures the client's HTTP pipeline. Handlers it registers are placed + /// outside the OAuth handler, which is where a retry has to sit to benefit from the token + /// eviction a 401 triggers. When the behaviour is unchanged. + /// /// A builder used to select where access tokens are cached. /// /// is the interface of a registered client instead of its /// implementation type. /// - public OpenApiClientOAuthTokenCachingBuilder AddOAuthTokenCaching() + public OpenApiClientOAuthTokenCachingBuilder AddOAuthTokenCaching( + Action? configurePipeline = null) where TClient : class, IOpenApiClient { - _serviceCollection.AddOAuthTokenCaching(); + _serviceCollection.AddOAuthTokenCaching(configurePipeline); return new OpenApiClientOAuthTokenCachingBuilder(_serviceCollection, this); } diff --git a/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthTokenCachingPipelineTests.cs b/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthTokenCachingPipelineTests.cs new file mode 100644 index 0000000..d0bb30a --- /dev/null +++ b/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthTokenCachingPipelineTests.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.Extensions.DependencyInjection; +using TBC.OpenAPI.SDK.Core.Authentication; +using TBC.OpenAPI.SDK.Core.Extensions; +using TBC.OpenAPI.SDK.Core.Models; +using WireMock; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace TBC.OpenAPI.SDK.Core.Tests +{ + /// + /// Covers the configurePipeline hook on AddOAuthTokenCaching<TClient>. Core + /// ships no retry logic and takes no dependency on Polly or any resilience library; the hook only + /// has to guarantee that a caller-supplied handler is placed outside + /// , which is the only position from which a retried + /// attempt re-enters token handling and benefits from the token eviction a 401 triggers. + /// + public class OAuthTokenCachingPipelineTests : IDisposable + { + private const string Scope = "read:some-resource"; + private const string AccessToken = "the-access-token"; + + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + private readonly WireMockServer _server = WireMockServer.Start(); + + [Fact] + public async Task AddOAuthTokenCaching_WhenConfigurePipelineAddsHandler_ShouldRunItOutsideOAuthHandler() + { + // Arrange - the server returns 401 once per scenario, then 200. A retry only recovers if + // it re-enters the OAuth handler after the 401 evicts the token, which only happens when + // the caller-supplied handler sits outside OAuthDelegatingHandler. + StubTokenEndpoint(); + StubResourceEndpoint_UnauthorizedThenSuccess(); + + var retryHandler = new SingleRetryOnUnauthorizedHandler(); + + var services = new ServiceCollection(); + services.AddOpenApiClient( + new BindingTestClientOptions + { + BaseUrl = $"{_server.Urls[0]}/", + ApiKey = "the-api-key" + }); + services + .AddOAuthTokenCaching(configurePipeline: pipeline => + pipeline.AddHttpMessageHandler(() => retryHandler)) + .UseInMemoryCache(); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + + // Act + var response = await client.GetResourceAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + + // Assert + using (new AssertionScope()) + { + response.IsSuccess.Should().BeTrue(); + retryHandler.RetryCount.Should().Be(1); + + // Two attempts reached the resource endpoint: the 401 and the retry that followed + // the token eviction. + RequestsTo("/some-resource").Should().HaveCount(2); + } + } + + [Fact] + public async Task AddOAuthTokenCaching_WhenConfigurePipelineIsNull_ShouldBehaveAsBeforeAndNotRetry() + { + // Arrange - default behaviour: a 401 is returned to the caller unchanged, with no retry. + StubTokenEndpoint(); + StubResourceEndpoint_AlwaysUnauthorized(); + + var services = new ServiceCollection(); + services.AddOpenApiClient( + new BindingTestClientOptions + { + BaseUrl = $"{_server.Urls[0]}/", + ApiKey = "the-api-key" + }); + services.AddOAuthTokenCaching().UseInMemoryCache(); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + + // Act + var response = await client.GetResourceAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + + // Assert + using (new AssertionScope()) + { + response.IsSuccess.Should().BeFalse(); + RequestsTo("/some-resource").Should().ContainSingle(); + } + } + + [Fact] + public async Task OpenApiClientFactoryBuilder_AddOAuthTokenCaching_WhenConfigurePipelineAddsHandler_ShouldRunItOutsideOAuthHandler() + { + // Arrange + StubTokenEndpoint(); + StubResourceEndpoint_UnauthorizedThenSuccess(); + + var retryHandler = new SingleRetryOnUnauthorizedHandler(); + + var factory = new OpenApiClientFactoryBuilder() + .AddClient( + new BindingTestClientOptions + { + BaseUrl = $"{_server.Urls[0]}/", + ApiKey = "the-api-key" + }) + .AddOAuthTokenCaching(configurePipeline: pipeline => + pipeline.AddHttpMessageHandler(() => retryHandler)) + .UseInMemoryCache() + .Build(); + + var client = factory.GetOpenApiClient(); + + // Act + var response = await client.GetResourceAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + + // Assert + using (new AssertionScope()) + { + response.IsSuccess.Should().BeTrue(); + retryHandler.RetryCount.Should().Be(1); + RequestsTo("/some-resource").Should().HaveCount(2); + } + } + + public void Dispose() + { + _server.Stop(); + _server.Dispose(); + GC.SuppressFinalize(this); + } + + private void StubTokenEndpoint() + { + _server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody($"{{\"access_token\":\"{AccessToken}\",\"token_type\":\"Bearer\",\"expires_in\":3600}}")); + } + + private void StubResourceEndpoint_UnauthorizedThenSuccess() + { + _server + .Given(Request.Create().WithPath("/some-resource").UsingGet()) + .InScenario("resource-token-refresh") + .WillSetStateTo("Refreshed") + .RespondWith(Response.Create().WithStatusCode(401)); + + _server + .Given(Request.Create().WithPath("/some-resource").UsingGet()) + .InScenario("resource-token-refresh") + .WhenStateIs("Refreshed") + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"id\":1}")); + } + + private void StubResourceEndpoint_AlwaysUnauthorized() + { + _server + .Given(Request.Create().WithPath("/some-resource").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(401)); + } + + private IEnumerable RequestsTo(string path) + => _server.LogEntries + .Select(x => x.RequestMessage) + .Where(x => string.Equals(x.Path, path, StringComparison.Ordinal)); + + /// + /// A minimal clone-capable retry handler standing in for a caller's own retry mechanism + /// (Polly or otherwise). Core does not ship this - it exists only to prove the pipeline hook + /// places handlers where a retry can actually recover. + /// + private sealed class SingleRetryOnUnauthorizedHandler : DelegatingHandler + { + private int _retryCount; + + public int RetryCount => _retryCount; + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var buffered = request.Content is null + ? null + : await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + + using (var firstAttempt = await CloneAsync(request, buffered).ConfigureAwait(false)) + { + var response = await base.SendAsync(firstAttempt, cancellationToken).ConfigureAwait(false); + + if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized) + { + return response; + } + + response.Dispose(); + } + + Interlocked.Increment(ref _retryCount); + + var retry = await CloneAsync(request, buffered).ConfigureAwait(false); + return await base.SendAsync(retry, cancellationToken).ConfigureAwait(false); + } + + private static Task CloneAsync(HttpRequestMessage request, byte[]? bufferedContent) + { + var clone = new HttpRequestMessage(request.Method, request.RequestUri) + { + Version = request.Version + }; + + if (bufferedContent is not null) + { + clone.Content = new ByteArrayContent(bufferedContent); + foreach (var header in request.Content!.Headers) + { + clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + foreach (var header in request.Headers) + { + clone.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + return Task.FromResult(clone); + } + } + } +}