Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TClient>` in the `HttpClient` pipeline. `AddOAuthTokenCaching<TClient>` therefore takes an optional `Action<IHttpClientBuilder> 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<IExampleClient, ExampleClient, ExampleClientOptions>(options)
.AddOAuthTokenCaching<ExampleClient>(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<TClient>`:

```c#
var factory = new OpenApiClientFactoryBuilder()
.AddClient<IExampleClient, ExampleClient, ExampleClientOptions>(options)
.AddOAuthTokenCaching<ExampleClient>(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

Expand Down
39 changes: 36 additions & 3 deletions src/TBC.OpenAPI.SDK.Core/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ public static IServiceCollection AddOpenApiClient<TClientInterface, TClientImple
/// when they expire out of the cache.
/// </para>
/// <para>
/// To turn that eviction into an actual retry, supply <paramref name="configurePipeline"/>.
/// The <see cref="IHttpClientBuilder"/> it receives is the client's own pipeline, and any
/// handler it registers is placed <em>outside</em> the
/// <see cref="OAuthDelegatingHandler{TClient}"/> - the only position from which a retried
/// attempt re-enters token handling and, because the <c>401</c> 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
/// <c>Microsoft.Extensions.Http.Resilience</c>'s <c>AddResilienceHandler</c>, or a custom
/// <see cref="DelegatingHandler"/>). A retry handler <b>must clone the request per attempt</b>:
/// the scope marker header is consumed by <see cref="OAuthDelegatingHandler{TClient}"/> and
/// the request content is consumed when it is sent, so re-sending the same
/// <see cref="HttpRequestMessage"/> fails. <c>Microsoft.Extensions.Http.Resilience</c> clones
/// automatically; a custom handler must do so itself. Scope the retry to
/// <c>401 Unauthorized</c> responses to avoid re-issuing genuinely failed requests.
/// </para>
/// <para>
/// Call this after <see cref="AddOpenApiClient{TClientInterface,TClientImplementation,TOptions}"/>
/// for the same client, then select a cache on the returned builder with exactly one of
/// <see cref="OAuthTokenCachingBuilder{TClient}.UseInMemoryCache"/>,
Expand All @@ -73,11 +89,20 @@ public static IServiceCollection AddOpenApiClient<TClientInterface, TClientImple
/// cache is selected, resolving the client throws an error naming the available options.
/// </para>
/// </summary>
/// <param name="services">The service collection to register the token handling into.</param>
/// <param name="configurePipeline">
/// 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 <c>401</c> triggers. When <see langword="null"/> the behaviour is unchanged: a
/// <c>401</c> evicts the token and is returned to the caller without a retry.
/// </param>
/// <returns>A builder used to select where access tokens are cached.</returns>
/// <exception cref="InvalidOperationException">
/// <typeparamref name="TClient"/> is an interface rather than a client implementation type.
/// </exception>
public static OAuthTokenCachingBuilder<TClient> AddOAuthTokenCaching<TClient>(this IServiceCollection services)
public static OAuthTokenCachingBuilder<TClient> AddOAuthTokenCaching<TClient>(
this IServiceCollection services,
Action<IHttpClientBuilder>? configurePipeline = null)
where TClient : class, IOpenApiClient
{
#if NET
Expand All @@ -96,8 +121,16 @@ public static OAuthTokenCachingBuilder<TClient> AddOAuthTokenCaching<TClient>(th

services.TryAddTransient<OAuthDelegatingHandler<TClient>>();

services.AddHttpClient(typeof(TClient).FullName!)
.AddHttpMessageHandler<OAuthDelegatingHandler<TClient>>();
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<OAuthDelegatingHandler<TClient>>();

return new OAuthTokenCachingBuilder<TClient>(services);
}
Expand Down
16 changes: 14 additions & 2 deletions src/TBC.OpenAPI.SDK.Core/OpenApiClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,29 @@ public OpenApiClientFactoryBuilder AddClient<TClientInterface, TClientImplementa
/// <para>
/// There is no token refresh: a <c>401 Unauthorized</c> response evicts the cached token and
/// is returned to the caller unchanged. The request that hit the <c>401</c> is not retried.
/// Supply <paramref name="configurePipeline"/> to turn that eviction into a retry: any handler
/// it registers is placed outside the <see cref="Authentication.OAuthDelegatingHandler{TClient}"/>,
/// 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).
/// </para>
/// </summary>
/// <param name="configurePipeline">
/// 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 <c>401</c> triggers. When <see langword="null"/> the behaviour is unchanged.
/// </param>
/// <returns>A builder used to select where access tokens are cached.</returns>
/// <exception cref="InvalidOperationException">
/// <typeparamref name="TClient"/> is the interface of a registered client instead of its
/// implementation type.
/// </exception>
public OpenApiClientOAuthTokenCachingBuilder<TClient> AddOAuthTokenCaching<TClient>()
public OpenApiClientOAuthTokenCachingBuilder<TClient> AddOAuthTokenCaching<TClient>(
Action<IHttpClientBuilder>? configurePipeline = null)
where TClient : class, IOpenApiClient
{
_serviceCollection.AddOAuthTokenCaching<TClient>();
_serviceCollection.AddOAuthTokenCaching<TClient>(configurePipeline);
return new OpenApiClientOAuthTokenCachingBuilder<TClient>(_serviceCollection, this);
}

Expand Down
Loading
Loading