Search code examples
c#dotnet-httpclientpollyretry-logichttpclientfactory

Polly retry request with different request-body


I have never used Polly before and am not sure if this is a good scenario for Polly.

I am calling an endpoint with a list of 1000 DTO in the POST body. Now the endpoint will perform some validations on each DTO and return a HTTP 400 Bad Request if any of those DTOs fail validation, and the response will also contain the id of all the DTOs that failed validation. So, even if one DTO fails validation, I get and HTTP 400 response.

Now I was wondering if I can handle this gracefully for the remaining DTOs that passed the validation.

So, whenever I get a HTTP 400 from the API, I want to change the request payload to remove the DTOs that caused validation failure and retry the request with the remaining DTOs.

How can I achieve this with Polly?

I am using a typed HttpClient using HttpClientFactory in .NET 5 to make my POST requests.


Solution

  • Polly's retry policy performs the exact same operation whenever it triggers. So, by the default you can't alter the request.

    But you can modify it in the onRetryAsync delegate, which is fired before the actual retry happens.


    In order to demonstrate this I will use the WireMock.NET library to mimic your downstream system.

    So, first let's create a webserver, which listens on the 40000 port on the localhost at the /api route:

    protected const string route = "/api";
    protected const int port = 40_000;
    
    protected static readonly WireMockServer server = WireMockServer.Start(port);
    protected static readonly IRequestBuilder endpointSetup = Request.Create().WithPath(route).UsingPost();
    

    Then setup a scenario to simulate that the first request fails with 400 and then the second succeeds with 200.

    protected const string scenario = "polly-retry-test";
    
    server.Reset();
    server
        .Given(endpointSetup)
        .InScenario(scenario)
        .WillSetStateTo(1)
        .WithTitle("Failed Request")
        .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.BadRequest));
    
    server
        .Given(endpointSetup)
        .InScenario(scenario)
        .WhenStateIs(1)
        .WithTitle("Succeeded Request")
        .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK));
    

    Let's test it

    protected static readonly HttpClient client = new HttpClient();
    
    var result = await client.PostAsync($"http://localhost:{port}{route}", new StringContent(""));
    Console.WriteLine(result.StatusCode);
    
    result = await client.PostAsync($"http://localhost:{port}{route}", new StringContent(""));
    Console.WriteLine(result.StatusCode);
    

    the output will be the following:

    BadRequest
    OK
    

    Okay, so now we have a downstream simulator, now it's time to focus on the retry policy. For the sake of simplicity I'll serialize a List<int> collection to post it as the payload.

    I setup the retry whenever it receives a 400 then in its onRetryAsync delegate I examine the response and remove the unwanted integers.

    AsyncRetryPolicy<HttpResponseMessage> retryInCaseOfPartialSuccess = Policy
        .HandleResult<HttpResponseMessage>(r => r.StatusCode == HttpStatusCode.BadRequest)
        .RetryAsync(1, onRetryAsync: (dr, _, __) => {
    
            //TODO: Process response from: `dr.Result.Content`
            //TODO: Replace the removal logic to appropriate one
            dtoIds.RemoveAt(0);
            return Task.CompletedTask;
        });
    

    Let's call the downstream API with the decorated retry policy:

    await retryInCaseOfPartialSuccess.ExecuteAsync(async (_) => {
        var payload = JsonSerializer.Serialize(dtoIds);
        Console.WriteLine(payload); //Only for debugging purposes
        return await client.PostAsync($"http://localhost:{port}{route}", new StringContent(payload));
    }, CancellationToken.None);
    

    Let's put all this together:

    protected const string scenario = "polly-retry-test";
    protected const string route = "/api";
    protected const int port = 40_000;
    protected static readonly WireMockServer server = WireMockServer.Start(port);
    protected static readonly IRequestBuilder endpointSetup = Request.Create().WithPath(route).UsingPost();
    protected static readonly HttpClient client = new HttpClient();
    
    private static async Task Main()
    {
        server.Reset();
        server
            .Given(endpointSetup)
            .InScenario(scenario)
            .WillSetStateTo(1)
            .WithTitle("Failed Request")
            .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.BadRequest));
    
        server
            .Given(endpointSetup)
            .InScenario(scenario)
            .WhenStateIs(1)
            .WithTitle("Succeeded Request")
            .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK));
    
        //var result = await client.PostAsync($"http://localhost:{port}{route}", new StringContent(""));
        //Console.WriteLine(result.StatusCode);
    
        //result = await client.PostAsync($"http://localhost:{port}{route}", new StringContent(""));
        //Console.WriteLine(result.StatusCode);
    
        await IssueRequestAgainstDownstream(new List<int> { 1, 2 });
    }
    
    private static async Task IssueRequestAgainstDownstream(List<int> dtoIds)
    {
        AsyncRetryPolicy<HttpResponseMessage> retryInCaseOfPartialSuccess = Policy
            .HandleResult<HttpResponseMessage>(response => response.StatusCode == HttpStatusCode.BadRequest)
            .RetryAsync(1, onRetryAsync: (dr, _, __) => {
                //TODO: Process response from: `dr.Result.Content`
                //TODO: Replace the removal logic to appropriate one
                dtoIds.RemoveAt(0);
                return Task.CompletedTask;
            });
    
        await retryInCaseOfPartialSuccess.ExecuteAsync(async (_) => {
            var payload = JsonSerializer.Serialize(dtoIds);
            Console.WriteLine(payload); //Only for debugging purposes
            return await client.PostAsync($"http://localhost:{port}{route}", new StringContent(payload));
        }, CancellationToken.None);
    }
    

    So, what we have done?

    • Created a downstream mock to simulate 400 and 200 subsequent responses
    • Created a retry policy which can amend the request's payload if it receives 400
    • Put the serialization logic and the http call inside the retry, so, that we can always serialize the most recent list of objects