2
\$\begingroup\$

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.

\$\endgroup\$
4
  • \$\begingroup\$ How are you planning to use the registered policies? Do you want to use them to decorate typed HttpClients? \$\endgroup\$ Commented Aug 11, 2021 at 7:57
  • \$\begingroup\$ @PeterCsala I have the HttpClient added to DI via serviceCollection.AddHttpClient(...) and add the policy with httpClientBuilder.AddPolicyHandlerFromRegistry(MyPolicySource.GetInstanceName()), so in this case a previously registered policy. I also set httpClient.Timeout from an additional property on MyPolicyFactory (not in the IPolicySource interface). \$\endgroup\$
    – Erik Hart
    Commented Aug 11, 2021 at 12:24
  • \$\begingroup\$ And are you using the registered policies exclusive for HttpClients? Or are there any policy which applied for something other than a HttpClient? \$\endgroup\$ Commented Aug 11, 2021 at 12:36
  • \$\begingroup\$ As of now, it's HttpClient only, but the interface and PolicyRegistry allow Polly policies for any purpose (future option). But it is planned only for key-value selection, by name. \$\endgroup\$
    – Erik Hart
    Commented Aug 11, 2021 at 13:32

1 Answer 1

1
\$\begingroup\$

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.

\$\endgroup\$
3
  • \$\begingroup\$ Thank you for the detailed answer! I didn't even have combining of policies, upon adding a policy handler to HttpClient, in mind. Our current policies in the registry already combine policies, retry and timeout per-request (defined locally), and are selected from the registry with a single httpClientBuilder.AddPolicyHandlerFromRegistry(key). Others may follow in the future. They were, in fact, called "strategy" in the previous, self-made implementation, before Polly was introduced on top level (so Polly usage is exposed, but much simpler than with a top-layer. \$\endgroup\$
    – Erik Hart
    Commented Aug 12, 2021 at 16:57
  • \$\begingroup\$ @ErikHart According to my understanding there is no guarantee that the consumer of your API calls the BuildPolicyRegistryWithPolicySources method. The consumer can also call the AddPolicyHandlerFromRegistry as many times as (s)he want and in any order. So, my opinion still stands, this flexibility can cause problems. \$\endgroup\$ Commented Aug 12, 2021 at 19:21
  • 1
    \$\begingroup\$ In the task I want to achieve, I basically want to provide an exposed Polly usage with a few utilities, rather than a fool-proof layer on top (we had sth. like this before). So I can expect people to know at least a bit about nesting and ordering. We would have preferred even plain Polly over this IPolicySource, but had the above problems with policy creation and DI. And the IPolicySource offers a chance for more complex policies/strategies, which the user can just select by name. It's also possible to make the implementation configurable (represented in name), much like with your config. \$\endgroup\$
    – Erik Hart
    Commented Aug 17, 2021 at 16:20

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.