Search code examples
c#.nettimeoutpollyretry-logic

Unable to retrieve RequestUri in onRetryAsync method after a TimeoutRejectedException


I am implementing a retry pattern for Http requests in C# using Polly.

This is the code I am using

return Policy<HttpResponseMessage>
   .Handle<HttpRequestException>()
   .OrTransientHttpStatusCode()                    
   .OrAdditionalTransientHttpStatusCode()
   .Or<TimeoutRejectedException>()
   .WaitAndRetryAsync(
        retryCount: configuration.MaxRetries,
        sleepDurationProvider: (retryIndex, result, context) =>
        {
           if (result.Result is { StatusCode: HttpStatusCode.ServiceUnavailable })
               return some wait time

           if (result.Result is { StatusCode: HttpStatusCode.TooManyRequests } || 
               result.Result is { StatusCode: HttpStatusCode.Forbidden}) 
               return some wait time
  
           return some wait time
        },
        onRetryAsync: (result, span, retryIndex, context) =>
        {
           string requestUrl = result.Result?.RequestMessage?.RequestUri?.AbsoluteUri ?? string.Empty;

           _logger.LogWarning("Transient type: {type} ; Retry index: {index}; Span: {seconds}; Uri: {uri}", type, retryIndex, span.TotalSeconds, requestUrl);
                    
           return Task.CompletedTask;
         });

When the TimeoutRejectedException is triggered, result.Result parameter received in the onRetryAsync method is null, and the result.Exception stores the timeout exception data. In this Exception there is not any information about the endpoint that led to the timeout. As a result, the logging does not have the requestUrl populated.

This is how this is set up for a named HttpClient in the Startup

services
   .AddHttpClient("#id")
   .AddPolicyHandler((sp, _) => new PolicyFactory(sp.GetRequiredService<ILogger<PolicyFactory>>()).Create())

Is there any way that I can set up my policy so that the incoming data provides such information? I want this defined policy to be associated to several named clients (thus the factory), but that the setting and logging is detailed with the values for each incoming request.


Solution

  • The good news is that the AddPolicyHandler has several overloads. You are using that one which receives an IServiceProvider (sp) and a HttpRequestMessage (_) object as parameter.

    So, all you need to do is to pass the HttpRequestMessage to the onRetry(Async) via closure

    services
       .AddHttpClient("#id")
       .AddPolicyHandler((sp, request) => Policy<HttpResponseMessage>
         ...
         .WaitAndRetryAsync(
            ...,
            onRetryAsync: (result, span, retryIndex, context) =>
            {
               string requestUrl = request.RequestUri.AbsoluteUri;
               ...
            });
    

    UPDATE #1

    however, it is too bad that with this solution I cannot extract the Policy creation to another class and thus reuse it

    You don't need to inline the policy definition in the AddPolicyHandler. You can pass the HttpRequestMessage object in the same way as you did with the logger. In the above example I've inlined the policy definition for the sake of simplicity.

    Here is a simple example how to pass the request:

    Named client decorated with a retry policy

    builder.Services
        .AddHttpClient("A")
        .AddPolicyHandler((sp, req) => PolicyHandlers.GetRetryPolicy(sp, req));
    

    The PolicyHandlers class

    public static class PolicyHandlers
    {
        public static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy(IServiceProvider sp, HttpRequestMessage msg)
            => Policy<HttpResponseMessage>
                .HandleResult(res => !res.IsSuccessStatusCode)
                .WaitAndRetryAsync(
                sleepDurations: new[] { TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2) },
                onRetry: (dr, ts) => Console.WriteLine(msg.RequestUri.AbsoluteUri));    
        
    }
    

    The Named client usage

    readonly HttpClient client;
    public XYZController(IHttpClientFactory factory)
    {
        client = factory.CreateClient("A");
    }
    
    [HttpGet]
    public async Task<string> Get()
    {
        await client.GetAsync("https://httpstat.us/500");
        await client.GetAsync("https://httpstat.us/428");
        return "Finished";
    }
    

    The console log

    https://httpstat.us/500
    https://httpstat.us/500
    https://httpstat.us/428
    https://httpstat.us/428
    

    This would work as well if you would use the GetRetryPolicy for multiple named clients

    builder.Services
        .AddHttpClient("A")
        .AddPolicyHandler((sp, req) => PolicyHandlers.GetRetryPolicy(sp, req));
    
    builder.Services
        .AddHttpClient("B")
        .AddPolicyHandler((sp, req) => PolicyHandlers.GetRetryPolicy(sp, req));
    

    Usage

    readonly HttpClient clientA;
    readonly HttpClient clientB;
    public XYZController(IHttpClientFactory factory)
    {
        clientA = factory.CreateClient("A");
        clientB = factory.CreateClient("B");
    }
    
    [HttpGet]
    public async Task<string> Get()
    {
        await clientA.GetAsync("https://httpstat.us/500");
        await clientB.GetAsync("https://httpstat.us/428");
        return "Finished";
    }