Search code examples
c#asp.net-coredrypollyrefit

Reusing a Polly retrial policy for multiple Refit endpoints without explicitly managing the HttpClient


I am trying to make Refit work with Polly in an ASP.NET Core 6 application. I have a working version, but I feel that there is too much code involved for each new method / consumed API endpoint.

I want to keep things simple for now by defining a retrial policy and using it for multiple endpoints. My code is as follows:

The retrial policy

private static IServiceCollection ConfigureResilience(this IServiceCollection services)
{
    var retryPolicy = Policy<IApiResponse>
        .Handle<ApiException>()
        .OrResult(x => x.StatusCode is >= HttpStatusCode.InternalServerError or HttpStatusCode.RequestTimeout)
        .WaitAndRetryAsync(Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromSeconds(1), RetryPolicyMaxCount));

    var register = new PolicyRegistry()
    {
        { "DefaultRetrialPolicy", retryPolicy }
    };

    services.AddPolicyRegistry(register);

    return services;
}

The interface to be used with Refit to generate the HTTP calls for the external application:

[Headers("Authorization: Bearer")]
public interface IBarIntegration
{
    [Get("/api/ext/Foo/GetFooBriefInfo")]
    Task<ApiResponse<GetFooBriefInfoForFooDto>> GetFooBriefInfo(GetFooBriefInfoForFooInputDto inputData);
}

Factory class to configure authentication for Refit. Authentication relies OnBehalfOf token generated based on the currently logged-in user access token.

internal sealed class BarApiClientHelper : IBarApiClientHelper
{
    private readonly IOptionsSnapshot<BarApiSettings> _BarSettings;
    private readonly IAccessTokenHelperService _accessTokenHelperService;

    public BarApiClientHelper(IOptionsSnapshot<BarApiSettings> BarSettings, IAccessTokenHelperService accessTokenHelperService)
    {
        _BarSettings = BarSettings;
        _accessTokenHelperService = accessTokenHelperService;
    }

    public async Task<TApiClient> CreateApiClient<TApiClient>(CancellationToken token)
    {
        string baseUrl = _BarSettings.Value.BaseUrl;
        string accessToken = await _accessTokenHelperService.GetAccessToken(token);

        var refitClient = RestService.For<TApiClient>(baseUrl, new RefitSettings
        {
            AuthorizationHeaderValueGetter = () => Task.FromResult(accessToken)
        });

        return refitClient;
    }
}

Example (infrastructure) method that will be called by the business layer.

public async Task<GetFooBriefInfoForFooDto> GetFooBriefInfo(string upn, CancellationToken token)
{
    var apiClient = await _clientHelper.CreateApiClient<IBarIntegration>(token);
    var retrialPolicy = _registry.Get<AsyncRetryPolicy<IApiResponse>>(DefaultRetrialPolicy);

    var func = async () => (IApiResponse) await apiClient.GetFooBriefInfo(new GetFooBriefInfoForFooInputDto { FooContactUpn = upn });
    var FooInfo = (ApiResponse<GetFooBriefInfoForFooDto>) await retrialPolicy.ExecuteAsync(func);
    await FooInfo.EnsureSuccessStatusCodeAsync();

    return FooInfo.Content!;
}

This approach seems to work fine, but I am unhappy with the amount of code required in each business-specific method (GetFooBriefInfo function). Is there any way to simplify this, I feel that I am kind of violating DRY by having each method get the retrial policy, executing and ensuring the success code.


Solution

  • Refit defines an extension method against IServiceCollection called AddRefitClient which returns an IHttpClientBuilder. This is good for us, since it is the same interface with which the AddHttpClient extension method returns. So, we can use the AddPolicyHandler, AddTransientHttpErrorPolicy or AddPolicyHandlerFromRegistry methods as well.

    Refit client + Polly policy

    Because we can chain the AddRefitClient, ConfigureHttpClient and AddPolicyHandler method calls that's why we can

    • avoid the usage of the PolicyRegistry
    • avoid the usage of the BarApiClientHelper
    • avoid the explicit usage of the policy itself

    Registration

    services
    .AddRefitClient(typeof(IBarIntegration), (sp) =>
    {
        var accessTokenHelperService = sp.GetRequiredService<IAccessTokenHelperService>();
        string accessToken = accessTokenHelperService.GetAccessToken(CancellationToken.None).GetAwaiter().GetResult();
        return new RefitSettings
        {
            AuthorizationHeaderValueGetter = () => Task.FromResult(accessToken)
        };
    })
    .ConfigureHttpClient((sp, client) =>
    {
        var barSettings = sp.GetRequiredService<IOptionsSnapshot<BarApiSettings>>();
        string baseUrl = barSettings.Value.BaseUrl;
        client.BaseAddress = new Uri(baseUrl);
    })
    .AddPolicyHandler(Policy<IApiResponse>
        .Handle<ApiException>()
        .OrResult(x => x.StatusCode is >= HttpStatusCode.InternalServerError or HttpStatusCode.RequestTimeout)
        .WaitAndRetryAsync(Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromSeconds(1), RetryPolicyMaxCount)));
    
    • The AddRefitClient receives a Type, which is used to register a typed client
      • It also anticipates a settingsAction function to setup a RefitSettings
        • Unfortunately this delegate is sync that's why I had to use .GetAwaiter().GetResult() instead of await
        • I've passed a CancellationToken.None to the GetAccessToken, because here we don't want to cancel this call
    • The ConfigureHttpClient is used to set the BaseAddress for the underlying HttpClient which will be used by the refit client
    • The AddPolicyHandler is used to set the resilience policy for the refit client

    Usage

    private readonly IBarIntegration apiClient; //injected via ctor
    public async Task<GetFooBriefInfoForFooDto> GetFooBriefInfo(string upn, CancellationToken token)
    {
        var fooInfo = await apiClient.GetFooBriefInfo(new GetFooBriefInfoForFooInputDto { FooContactUpn = upn });
        await fooInfo.EnsureSuccessStatusCodeAsync();
        return fooInfo.Content!;
    }
    
    • There is no need to use the IBarApiClientHelper because the IBarIntegration can be injected directly
    • There is no need to retrieve the Polly policy and manually decorate the GetFooBriefInfo method call, because we have already integrated the refit client and the policy during the DI registration

    UPDATE #1
    Based on the suggestion of Can Bilgin the registration code could be rewritten like this:

    services
    .AddRefitClient(typeof(IBarIntegration), (sp) =>
    {
        var accessTokenHelperService = sp.GetRequiredService<IAccessTokenHelperService>();
        return new RefitSettings
        {
            AuthorizationHeaderValueGetter = async () => await accessTokenHelperService.GetAccessToken(CancellationToken.None)
        };
    
    })
    .ConfigureHttpClient((sp, client) =>
    {
        var barSettings = sp.GetRequiredService<IOptionsSnapshot<BarApiSettings>>();
        client.BaseAddress = new Uri(barSettings.Value.BaseUrl);
    })
    ...