刷新令牌使用带有命名客户端的 Polly

12

我有一个像这样的政策

var retryPolicy = Policy
    .Handle<HttpRequestException>()
    .OrResult<HttpResponseMessage>(resp => resp.StatusCode == HttpStatusCode.Unauthorized)
    .WaitAndRetryAsync(3, 
        retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
        onRetry: (resp, timeSpan, context) =>
        {
            // not sure what to put here
        });

然后我有一个名为client的客户,看起来像这样

services.AddHttpClient("MyClient", client =>
    {
        client.BaseAddress = new Uri("http://some-url.com");
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken);
        client.Timeout = 30000;
    })
    .AddPolicyHandler(retryPolicy);

在收到401状态码时,我需要刷新HTTP客户端上的令牌。所以在完美的情况下,以下代码将完全实现我想要达到的目标。

var retryPolicy = Policy
    .Handle<HttpRequestException>()
    .OrResult<HttpResponseMessage>(resp => resp.StatusCode == HttpStatusCode.Unauthorized)
    .WaitAndRetryAsync(3, 
        retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
        onRetry: (resp, timeSpan, context) =>
        {
            var newToken = GetNewToken();
            
            //httpClient doesn't exists here so I need to grab it some how
            httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", newToken);
        });

我已阅读以下文章:

使用 Retry 重新建立身份验证

使用 Polly 和 Typed Client 刷新令牌

retry-to-refresh-authorization

还有其他几篇文章。然而,它们似乎都使用了 policy.ExecuteAsync(),而我不想使用该方法,因为这样我就必须更改解决方案中的所有 HttpClient 调用。我正试图找到一种只需在 StartUp.cs 中更改代码即可为每个请求添加此功能的方法。


我认为这里的问题可能是策略(大概)创建了一个 HttpMessageHandler,然后用它来创建 HttpClient 实例。 - ProgrammingLlama
@ OP - 你有没有找到解决办法? - bubbleking
@user3236794,您只需要为命名客户端寻找解决方案吗?您不能使用带类型的客户端吗? - Peter Csala
这个回答解决了你的问题吗?如何使用IHttpClientFactory刷新令牌 - zolty13
3个回答

9
TL;DR:您需要在RetryPolicy、DelegatingHandler和TokenService之间定义一种通信协议。

对于Typed Clients,您可以显式调用ExecuteAsync并使用Context在要装饰的方法和onRetry(Async)委托之间交换数据。

这个技巧不能在命名客户端的情况下使用。您需要做的是:

  • 将Token管理分离到专门的服务中
  • 使用DelegatingHandler拦截HttpClient的通信

此序列图描述了不同组件之间的通信

refreshing token in case of 401

Token Service

The DTO

public class Token
{
    public string Scheme { get; set; }
    public string AccessToken { get; set; }
}

界面

public interface ITokenService
{
    Token GetToken();
    Task RefreshToken();
}

虚拟实现
public class TokenService : ITokenService
{
    private DateTime lastRefreshed = DateTime.UtcNow;
    public Token GetToken()
        => new Token { Scheme = "Bearer", AccessToken = lastRefreshed.ToString("HH:mm:ss")}; 

    public Task RefreshToken()
    {
        lastRefreshed = DateTime.UtcNow;
        return Task.CompletedTask;
    }
}

将注册为DI的Singleton

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<ITokenService, TokenService>();
    ...
}

委托处理程序

自定义异常

public class OutdatedTokenException : Exception
{

}

拦截器(interceptor)
public class TokenFreshnessHandler : DelegatingHandler
{
    private readonly ITokenService tokenService;
    public TokenFreshnessHandler(ITokenService service)
    {
        tokenService = service;
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var token = tokenService.GetToken();
        request.Headers.Authorization = new AuthenticationHeaderValue(token.Scheme, token.AccessToken);

        var response = await base.SendAsync(request, cancellationToken);
        if (response.StatusCode == HttpStatusCode.Unauthorized)
        {
            throw new OutdatedTokenException();
        }
        return response;
    }
}
  • 它从TokenService检索当前令牌
  • 它设置了授权标头
  • 它执行基本方法
  • 它检查响应的状态
    • 如果是401,则抛出自定义异常
    • 如果不是401,则使用响应返回

将其注册为依赖注入的瞬时对象

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<ITokenService, TokenService>();
    services.AddTransient<TokenFreshnessHandler>();
    ...
}

重试策略

该策略的定义

public IAsyncPolicy<HttpResponseMessage> GetTokenRefresher(IServiceProvider provider)
{
    return Policy<HttpResponseMessage>
        .Handle<OutdatedTokenException>()
        .RetryAsync(async (_, __) => await provider.GetRequiredService<ITokenService>().RefreshToken());
}
  • 它接收一个 IServiceProvider 来访问 TokenService
  • 如果抛出 OutdatedTokenException,它会执行一次单独的重试
  • onRetryAsync 委托内,它调用 TokenServiceRefreshToken 方法

将所有内容放在一起

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<ITokenService, TokenService>();
    services.AddTransient<TokenFreshnessHandler>();
    services.AddHttpClient("TestClient")
        .AddPolicyHandler((provider, _) => GetTokenRefresher(provider))
        .AddHttpMessageHandler<TokenFreshnessHandler>();
    ...
}
  • 请记住,在使用 AddPolicyHandlerAddHttpMessageHandler的时候,它们的顺序很重要
  • 如果您先调用 AddHttpMessageHandler,然后再调用 AddPolicyHandler,则无法触发重试

在使用Typed Clients的情况下,您可以显式调用ExecuteAsync并使用上下文来交换数据。您能分享一个使用Typed Client这种方式的代码示例的参考吗?谢谢。 - Chris Harrington
如果使用Typed Clients,您可以显式调用ExecuteAsync并使用上下文来交换数据。您能分享一个使用Typed Client的代码示例吗?谢谢。 - undefined
@ChrisHarrington 这周我会尽量发布一个示例代码。 - Peter Csala
1
谢谢。这将是这个帖子的一个很好的补充! - Chris Harrington
1
谢谢。这将是这个帖子的一个很好的补充! - undefined
@ChrisHarrington 你能帮忙看一下这篇帖子吗:https://dev59.com/6FIH5IYBdhLWcg3wYtKr#76571613 - Peter Csala

5
这篇文章包含我之前提出的解决方案的另一种版本。 < p > < em > 我发布这篇单独的回答(而不是编辑之前的回答),因为两种解决方案都可行,而且另一篇文章已经很长了。


为什么我们需要替代版本?

因为TokenFreshnessHandler的责任过于繁重,而重试策略则不足

如果您查看了重写的SendAsync方法实现,就会发现它对requestresponse执行了一些操作。

如果我们能够分离出来:

  • 处理程序处理request
  • 并且策略对response进行评估

那么我们将得到一个更加简洁的解决方案(在我看来)。

我们如何实现这种分离?

如果我们可以使用Polly的Context作为重试尝试之间的中间存储,那么我们就可以实现这种分离。幸运的是,Microsoft.Extensions.Http.Polly包针对HttpRequestMessage定义了两个扩展方法:

这些都是未充分记录的功能。在docs.microsoft上,我甚至找不到相关页面。我只在dotnet-api-docs repo中找到了它们。

这些内容对我们很有用,如果我们知道AddPolicyHandler仅在请求没有上下文时才会附加新的Context。不幸的是,这又没有被记录文档,所以这是一个实现细节,可能会在未来发生改变。但目前我们可以依靠它。

这将如何改变协议?

refreshing token

正如您所看到的,这里唯一的区别在于使用了Context

我们应该如何更改处理程序?

public class TokenRetrievalHandler : DelegatingHandler
{
    private readonly ITokenService tokenService;
    private const string TokenRetrieval = nameof(TokenRetrieval);
    private const string TokenKey = nameof(TokenKey);
        
    public TokenRetrievalHandler(ITokenService service)
    {
        tokenService = service;
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var context = request.GetPolicyExecutionContext();
        if(context.Count == 0)
        {
            context = new Context(TokenRetrieval, new Dictionary<string, object> { { TokenKey, tokenService.GetToken() } });
            request.SetPolicyExecutionContext(context);
        }

        var token = (Token)context[TokenKey];
        request.Headers.Authorization = new AuthenticationHeaderValue(token.Scheme, token.AccessToken);

        return await base.SendAsync(request, cancellationToken);
    }
}
  • 由于处理程序的职责已经改变,我已经更改了处理程序的名称
  • 现在,处理程序的实现只关心request(而不关心response
  • 正如之前所说:如果没有任何内容,则PolicyHttpMessageHandler会创建一个新的Context
    • 因此,GetPolicyExecutionContext不会返回null(即使是第一次尝试),而是返回一个带有空上下文数据集合的Contextcontext.Count == 0

我们应该如何更改策略?

public IAsyncPolicy<HttpResponseMessage> GetTokenRefresher(IServiceProvider provider, HttpRequestMessage request)
{
    return Policy<HttpResponseMessage>
        .HandleResult(response => response.StatusCode == HttpStatusCode.Unauthorized)
        .RetryAsync(async (_, __) =>
        {
            await provider.GetRequiredService<ITokenService>().RefreshToken();
            request.SetPolicyExecutionContext(new Context());
        });
}
  • 现在,策略不再针对自定义异常触发,而是在出现401响应状态码时触发
  • onRetryAsync已经进行了修改,以清除request的附加上下文信息

注册代码也需要进行调整

services.AddHttpClient("TestClient")
    .AddPolicyHandler((sp, request) => GetTokenRefresher(sp, request))
    .AddHttpMessageHandler<TokenRetrievalHandler>()

现在,我们应该将GetTokenRefresher方法传递给IServiceProvider,以及HttpRequestMessage

我应该使用哪个解决方案?

  • 这个解决方案提供了更好的分离,但它依赖于一个实现细节
  • 另一个解决方案使处理程序聪明,而策略愚蠢

如果我们可以把所有的好东西都打包成 nupkg,那就太好了;-) - frankhommers
@frankhommers 对不起,我不确定我理解你的问题。你能否重新表述一下? - Peter Csala
好的:如果我知道我的令牌何时过期,我们仍然会发送一个请求来获取未经授权的响应。但在这种情况下,我想提前刷新令牌。你会把那个逻辑放在哪里? - frankhommers
1
但是我想要同时具备透明的 HttpClient。我猜我需要扩展 TokenService,添加一个名为 EnsureValidTokenAsync() 的方法或类似的东西。 - frankhommers
@frankhommers 是的,那可能是实现该功能的好地方。 - Peter Csala
显示剩余3条评论

2

正如Chris Harrington要求的那样,让我在这里介绍另一种变体:使用类型化的HttpClient

对于类型化客户端,您可以显式调用ExecuteAsync并使用Context在要修饰的方法和onRetry(Async)委托之间交换数据。

好消息是我们不需要使用Context。我们可以有一个解决方案,它不需要我们填充、传播和获取上下文数据。

像往常一样,我们从一个序列图开始: refresh token with typed client

  • 如您所见,这里没有DelegatingHandler组件
    • 因为重试是在类型化客户端内定义和使用的

令牌服务

这个组件与其他两个变体完全相同。
因此,我不会在这里复制虚拟实现。

输入的客户端

public interface IClient
{
    Task<string> GetAsync(string url);
}

public class Client : IClient
{
    private readonly HttpClient _client;
    private readonly ITokenService _tokenService;
    public Client(HttpClient client, ITokenService tokenService)
      => (_client, _tokenService) = (client, tokenService);

    public async Task<string> GetAsync(string url)
    {
        var response = await GetRetryPolicy().ExecuteAsync(() =>
        {
            var token = _tokenService.GetToken();
            var request = new HttpRequestMessage() { RequestUri = new Uri(url) };
            request.Headers.Authorization = new AuthenticationHeaderValue(token.Scheme, token.AccessToken);

            return _client.SendAsync(request);
        });
            
        return await response.Content.ReadAsStringAsync();
    }

    private IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
    => Policy<HttpResponseMessage>
        .HandleResult(res => res.StatusCode == HttpStatusCode.Unauthorized)
        .RetryAsync((dr, _) => _tokenService.RefreshToken());
}
  • 我们已经定义了一个重试策略 (GetRetryPolicy),它在遇到 401 错误时调用 RefreshToken 方法后再进行下一次尝试。
  • HttpClient 调用被包装在上述策略中。
    • 首先,我们从 TokenService 中获取当前令牌,然后将其设置为适当的请求头。
    • 我们对下游发起请求,并返回结果。

组件注册

这部分非常简单。

builder.Services.AddSingleton<ITokenService, TokenService>();
builder.Services.AddHttpClient<IClient, Client>();

就是这样。 IClient 的实现已经准备好可以使用了 :)

2
谢谢。我会说IClient“几乎”可以使用了。IClient和ITokenService都需要使用Polly来增强其可靠性。 - Chris Harrington
2
谢谢。我会说IClient“几乎”可以使用了。IClient和ITokenService都需要使用Polly来增强其弹性。 - undefined
1
无意冒犯。只是在心里思考接下来要做什么。我对您能够为此样本付出努力感兴趣,愿意提供赞助。 - Chris Harrington
1
没有冒犯的意思。只是在心里思考接下来要做什么。我对您能够为制作这样一个样本而提供支持很感兴趣。 - undefined
@ChrisHarrington Client的下一步操作:为幂等服务调用添加弹性策略(全局超时 > 重试 > 断路器 > 本地超时),为不可重试错误添加错误处理,不要将url作为参数传入,而是将其硬编码到类型化客户端中,利用跟踪ID支持分布式跟踪等。 - Peter Csala
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接