Polly AddPolicyRegistry() with factory and registered dependency injection service instances?

Posted on

Problem

The Microsoft.Extensions method for Polly, to use policies via dependency injection,
serviceCollection.AddPolicyRegistry()
only allows to add already created policies, while still defining the ServiceCollection content, so other DI service instances, such as loggers, are not available.

To resolve the problem and also because I need some additional data on the policies, I created an interface, which provides a policy:

public interface IPolicySource
{
    /// <summary>
    /// Name of <see cref="IPolicySource"/> instance, also key in the <see cref="PolicyRegistry"/>.
    /// </summary>
    string GetName();

    /// <summary>
    /// The Polly policy, created by the <see cref="IPolicySource"/> implementation. 
    /// Shall be a singleton instance.
    /// </summary>
    IsPolicy PollyPolicy { get; }
}

Implementations of IPolicySource commonly have constructors with a logger as parameter. Later, different configurations per instance shall be possible, represented by different GetName() return values. The implementations can have additional members, like TimeSpan GetHttpClientRequestTimeout() (to have sufficient time for retrying via policy).

I then register the factories for implementations in the ServiceCollection:

public static IServiceCollection AddPolicySource(this IServiceCollection serviceCollection,
    Func<IServiceProvider, IPolicySource> policySourceFactory)
{
        return serviceCollection.AddSingleton<IPolicySource>(policySourceFactory);
}

which allows code like:

serviceCollection.AddPolicySource(sp => 
    new MyPolicySource(   // logger from dependency injection
        sp.GetRequiredService<ILogger<MyPolicySource>>()));

Multiple IPolicySource implementations, even of the same type and with the same GetName() results, can be registered this way.

Finally, the build of the PolicyRegistry is defined through a factory. The normal AddPolicyRegistry() from the Microsoft extensions registers for the interfaces IPolicyRegistry<string> and IReadOnlyPolicyRegistry<string>:

public static IServiceCollection AddPolicyRegistryUsingPolicySources(this IServiceCollection serviceCollection)
{
    serviceCollection.AddSingleton<IPolicyRegistry<string>>(sp => 
        sp.BuildPolicyRegistryWithPolicySources())  
    .AddSingleton<IReadOnlyPolicyRegistry<string>>(sp => 
        sp.GetRequiredService<IPolicyRegistry<string>>());
    return serviceCollection;
}

and the IServiceProvider extension method BuildPolicyRegistryWithPolicySources():

public static PolicyRegistry BuildPolicyRegistryWithPolicySources(this IServiceProvider serviceProvider)
{
    var policySourcesByName = new Dictionary<string, IPolicySource>();
    foreach (var policySource in serviceProvider.GetServices<IPolicySource>())
    {
        var policySourceName = policySource.GetName();
        policySourcesByName[policySourceName] = policySource; // use last, ignore previous with same name.
    }

    var policyRegistry = new PolicyRegistry();
    foreach (var policySourceByName in policySourcesByName)
    {
        policyRegistry.Add(policySourceByName.Key, policySourceByName.Value.PollyPolicy);
    }

    return policyRegistry;
}

As of now, it seems to work well. The registration of multiple identical IPolicySource instances follows the rule to always use the last, if multiple with the same name have been registered.

One caveat is, that there is no easy way to register the singleton IPolicySource instances multiple times, e.g. for implementation type or extended interfaces.

Solution

We have gone through the same problem (more or less) whenever we started to use Polly. But after a couple of months we have realized that this flexibility is really error-prone and it is not needed.

Let me give an example why is it error-prone. Let’s suppose you have two policies: a Timeout and a Retry. You can use them separately and can use them in a combined way. The problem arises whenever you want to combine the two policies, since the inner’s exception should be handled by the outer to trigger that.

Retry outer, Timeout inner:

private static IAsyncPolicy<HttpResponseMessage> TimeoutPolicy(ResilienceSettings settings)
    => Policy
        .TimeoutAsync<HttpResponseMessage>( 
            timeout: TimeSpan.FromMilliseconds(settings.HttpRequestTimeoutInMilliseconds));

private static IAsyncPolicy<HttpResponseMessage> RetryPolicy(ResilienceSettings settings)
    => HttpPolicyExtensions
        .HandleTransientHttpError() //Catches HttpRequestException or checks the status code: 5xx or 408
        .Or<TimeoutRejectedException>() //Catches TimeoutRejectedException, which can be thrown by an inner TimeoutPolicy
        .WaitAndRetryAsync( 
            retryCount: settings.HttpRequestRetryCount, 
            sleepDurationProvider: _ =>
                TimeSpan.FromMilliseconds(settings.HttpRequestRetrySleepDurationInMilliseconds)); 

With this each request (the initial attempt and the retried attempts) has a separate timeout.

Timeout outer, Retry inner:

private static IAsyncPolicy<HttpResponseMessage> TimeoutPolicy(ResilienceSettings settings)
    => Policy
        .TimeoutAsync<HttpResponseMessage>( 
            timeout: TimeSpan.FromMilliseconds(settings.HttpRequestTimeoutInMilliseconds));

private static IAsyncPolicy<HttpResponseMessage> RetryPolicy(ResilienceSettings settings)
    => HttpPolicyExtensions
        .HandleTransientHttpError()
        .WaitAndRetryAsync( 
            retryCount: settings.HttpRequestRetryCount, 
            sleepDurationProvider: _ =>
                TimeSpan.FromMilliseconds(settings.HttpRequestRetrySleepDurationInMilliseconds)); 

With this we have a global timeout. We have an overall threshold for requests (the initial attempt and the retried attempts).

As you can see depending on how you chain them you might need to declare them differently. The things can get even more complicated if you want to add a third policy as well, like a Circuit breaker (Retry >> Circuit Breaker >> Timeout).


We have ended up with a solution where we can register HttpClients with resilient strategies. Here is a simplified version of that:

  • With this class you can parameterise all properties of the combined policy:
public class ResilienceSettings
{
    public int HttpRequestTimeoutInMilliseconds { get; set; }
    
    public int HttpRequestRetrySleepDurationInMilliseconds { get; set; }

    public int HttpRequestRetryCount { get; set; }

    public int HttpRequestCircuitBreakerFailCountInCloseState { get; set; }

    public int HttpRequestCircuitBreakerDelayInMillisecondsBetweenOpenAndHalfOpenStates { get; set; }
}
  • We did not expose the combined policy (so called strategy) to our consumers:
internal static IAsyncPolicy<HttpResponseMessage> CombinedPolicy(ResilienceSettings settings)
    => Policy.WrapAsync(RetryPolicy(settings), CircuitBreakerPolicy(settings), TimeoutPolicy(settings))
  • Rather we have created a higher level API to register any typed HttpClient which should be decorated with this strategy:
public static class ResilientHttpClientRegister
{
    public static IServiceCollection AddResilientHttpClientProxy<TInterface, TImplementation>
        (this IServiceCollection services, ResilientHttpClientConfigurationSettings settings, Action<IServiceProvider, HttpClient> configureClient = null)
        where TInterface : class
        where TImplementation : class, TInterface
    {
        services.AddHttpClient<TInterface, TImplementation>(
                (serviceProvider, client) =>
                {
                    client.BaseAddress = settings.BaseAddress;
                    configureClient?.Invoke(serviceProvider, client);
                })
            .AddPolicyHandler(ResilientHttpPolicies.CombinedPolicy(settings.ResilienceSettings));

        return services;
    }
}
  • We have created yet another settings class ResilientHttpClientConfigurationSettings to capture the baseAddress as well:
public class ResilientHttpClientConfigurationSettings
{
    public Uri BaseAddress { get; set; }

    public ResilienceSettings ResilienceSettings { get; set; }
}

As you can see this solution tries to hide entirely the fact that we are using Polly under the hood. Because we are exposing a higher level API that’s why it is less error prone. It is less flexible for sure, but at the end you want to come up reliable resilience strategies.

We are aware of one known issue: You can register the typed client interface as implementation:

services.AddResilientHttpClientProxy<IXYZResilientClient, IXYZResilientClient>(xyzSvcSettings);

It causes runtime exception rather than compile time, but I think we can live with this.

Leave a Reply

Your email address will not be published. Required fields are marked *