1

I have a predefined IAsyncPolicy<HttpResponseMessage> policy coming from an external assembly. I want to apply this policy to a Http Client, BUT I want to exclude one of the cases this policy typically handles.

Take the example below:

  • I cannot influence how the baseline policy is created (external library)
  • I need to wire things up so that this policy is NOT invoked if the http client returns StatusCode.Forbidden

How can I do this?

 void Configure(IHttpClientBuilder builder)
 {
   var policy = GetBaselinePolicy();

   // TODO: Do something here to *not* have policy handle HttpStatusCode.Forbidden.. only what?

   builder.AddPolicyHandler(policy);
 }

 // Assume you CANNOT change this function here
 IAsyncPolicy<HttpResponseMessage> GetBaselinePolicy() {
   return Policy<HttpResponseMessage>
     .HandleResult(x => x.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Gone)
     .WaitAndRetryForeverAsync(_ => TimeSpan.FromSeconds(1));
 }

I've found Policy.WrapAsync, but best from what I've seen so far, this unconitionally chains policies, i.e. one way or the other the call would find its way to the baseline policy and be retried there forever.

0

1 Answer 1

1

You can basically early exit with a fallback policy

IAsyncPolicy<HttpResponseMessage> GetEarlyExitPolicy()
=> Policy<HttpResponseMessage>
         .HandleResult(x => x.StatusCode is HttpStatusCode.Forbidden)
         .FallbackAsync(_ => Task.FromResult(new HttpResponseMessage((HttpStatusCode)0));

So, whenever you receive a Forbidden then you change the original result with a bogus one. The status code is intentionally out of the normal range to ease the detection.

Here is a working example:

public class Program
{
    public static async Task Main()
    {
        var x = Policy.WrapAsync(GetBaselinePolicy(), GetEarlyExitPolicy());
        var res = await x.ExecuteAsync(() => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden)));
        res.Dump();
    }
    
    static IAsyncPolicy<HttpResponseMessage> GetBaselinePolicy() {
       return Policy<HttpResponseMessage>
         .HandleResult(x => x.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Gone)
         .WaitAndRetryForeverAsync(_ => { "retry".Dump(); return TimeSpan.FromSeconds(1);});
    }
    
    static IAsyncPolicy<HttpResponseMessage> GetEarlyExitPolicy() {
       return Policy<HttpResponseMessage>
         .HandleResult(x => x.StatusCode is HttpStatusCode.Forbidden)
         .FallbackAsync(_ => { "fallback".Dump(); return Task.FromResult(new HttpResponseMessage((HttpStatusCode)0)); });
    }
}

UPDATE #1

The caveat here is of course that the response remains modified, and your caller sees status code 0 in the end. That wouldn't be ideal I think - I'd strongly refer responses that should be excluded from the retry policy to appear downstream in their original form. Eg in my case, while I may not want to retry certain kind of errors, I'd still want to log (or otherwise special case) them further downstream. Downstream should ideally not even be aware polly exists somewhere upstream.

In order to keep the original response and early exit in case of Forbidden response you could use Polly.Context. The fallback policy has several overloads. One of them allows you access the original response / exception and the context. So, we can save the outcome of the operation into the context and after the ExecuteAsync call we can examine it.

To keep all this logic hidden from the downstream you can simply wrap the code inside a DelegatingHandler:

public class YourHttpClientHandler : DelegatingHandler
{
    private const string ContextKey = "EarlyExitedResult";
    private const HttpStatusCode EarlyExitCode = (HttpStatusCode)0;
    
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var context = new Polly.Context();
        var strategy = Policy.WrapAsync(GetBaselinePolicy(), GetEarlyExitPolicy());
        
        var pollyResult = await strategy.ExecuteAsync(ctx => base.SendAsync(request, cancellationToken), context);
        
        if (pollyResult.StatusCode == EarlyExitCode)
        {       
            var result = (DelegateResult<HttpResponseMessage>)context[ContextKey];
            if (result.Result is not null) 
                return result.Result;
            throw result.Exception;
        }
        
        return pollyResult;
    }
    
    static IAsyncPolicy<HttpResponseMessage> GetBaselinePolicy() 
    {
       return Policy<HttpResponseMessage>
         .HandleResult(x => x.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Gone)
         .WaitAndRetryForeverAsync(_ => { "retry".Dump(); return TimeSpan.FromSeconds(1);});
    }
    
    static IAsyncPolicy<HttpResponseMessage> GetEarlyExitPolicy() 
    {
        return Policy<HttpResponseMessage>
         .HandleResult(x => x.StatusCode is HttpStatusCode.Forbidden)
         .FallbackAsync((res, ctx, ct) => 
         { 
            ctx[ContextKey] = res;
            return Task.FromResult(new HttpResponseMessage(EarlyExitCode)); 
         }, onFallbackAsync: (res, cxt) => Task.CompletedTask);
    }
}
  • The GetBaselinePolicy is your external retry policy
  • The GetEarlyExitPolicy is your fallback policy to capture the outcome in case of Forbidden status code.
    • The original response is saved into the context
  • The SendAsync combines the above two policies, executes the original request with the combined policy, and finally examines the outcome
    • If the status code is 0 then you fetch the outcome from the context and act accordingly
    • If the status code is other than 0 then you return that code

The usage of this DelegatingHandler is straightforward:

public class Program
{
    public static async Task Main()
    {
        ServiceCollection services = new();
        services.AddTransient<YourHttpClientHandler>();
        services.AddHttpClient(string.Empty)
                .AddHttpMessageHandler<YourHttpClientHandler>();

        var provider = services.BuildServiceProvider();
        var httpClientFactory = provider.GetRequiredService<IHttpClientFactory>();
        var httpClient = httpClientFactory.CreateClient();
        
        //var res = await httpClient.GetAsync("https://httpstat.us/410");
        var res = await httpClient.GetAsync("https://httpstat.us/403");
        res.Dump();
    }
}

Here you can give it a try on dotnetfiddle: https://dotnetfiddle.net/3Zbs53

Sign up to request clarification or add additional context in comments.

2 Comments

Thanks Peter, I think that is a sensible approach. The caveat here is of course that the response remains modified, and your caller sees status code 0 in the end. That wouldn't be ideal I think - I'd strongly refer responses that should be excluded from the retry policy to appear downstream in their original form. Eg in my case, while I may not want to retry certain kind of errors, I'd still want to log (or otherwise special case) them further downstream. Downstream should ideally not even be aware polly exists somewhere upstream
@Bogey I've updated my answer please check it again.

Your Answer

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

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.