Search code examples
c#.net-coredotnet-httpclientpolly

Polly Resilience context is empty


I'm implementing resilience for my .NET Core Web Api, and I need a way to pass a value using the ResilienceContext, but when I'm debugging, the args.Context.Properties is always empty. I don't know what I'm missing.

I'm using Microsoft.Extensions.Http.Resilience that uses Polly

Here's the code the I have:

public static class ResilienceKeys
{
    public static readonly ResiliencePropertyKey<string> LocationId = new("location-id");
}
// WordPressService.cs

public class WordPressService : IWordPressService
{
    private readonly HttpClient _httpClient;
    private readonly ResiliencePipelineProvider<string> _pipelineProvider;

    public WordPressService(HttpClient httpClient, ResiliencePipelineProvider<string> pipelineProvider)
    {
        _httpClient = httpClient;
        _httpClient.BaseAddress = new Uri("https://api.example.com/");

        _pipelineProvider = pipelineProvider;
    }

    public async Task<string> GetBannersByLocationAsync(int locationId, CancellationToken cancellationToken)
    {
        var pipeline = _pipelineProvider.GetPipeline<HttpResponseMessage>("my-pipeline");

        var context = ResilienceContextPool.Shared.Get(cancellationToken);

        context.Properties.Set(ResilienceKeys.LocationId, locationId.ToString());

        var response = await pipeline.ExecuteAsync<HttpResponseMessage, ResilienceContext>(
            async (context, cancellationToken) =>
            {
                // Simulate an error for testing purposes
                await Task.Delay(100, cancellationToken);
                var response = new HttpResponseMessage(HttpStatusCode.InternalServerError);
                return response;
            },
            context,
            cancellationToken);

        ResilienceContextPool.Shared.Return(context);

        return await response.Content.ReadAsStringAsync(cancellationToken);
    }
}

Also I'm using dependency injection to add the ResiliencePipeline, with a RetryStrategy and a FallbackStrategy to get content from a database in case of the WordPress server is not responding

// Program.cs

builder.Services.AddResiliencePipeline<string, HttpResponseMessage>("my-pipeline", (pipelineBuilder, context) =>
{
    var predicateBuilder = new PredicateBuilder<HttpResponseMessage>()
        .Handle<HttpRequestException>()
        .HandleResult(r => r.StatusCode >= HttpStatusCode.InternalServerError);

    pipelineBuilder
    .AddFallback(new FallbackStrategyOptions<HttpResponseMessage>()
    {
        ShouldHandle = predicateBuilder,
        FallbackAction = async (args) =>
        {
            if (args.Context.Properties.TryGetValue(ResilienceKeys.LocationId, out var data))
            {
                Console.WriteLine("FallbackAction, Location Id: {0}", data);
            }

            var fallbackResponse = new HttpResponseMessage()
            {
                StatusCode = HttpStatusCode.OK,
                Content = new StringContent("Fallback content")
            };

            return Outcome.FromResult(fallbackResponse);
        }
    })
    .AddRetry(new RetryStrategyOptions<HttpResponseMessage>()
    {
        ShouldHandle = predicateBuilder,
        Delay = TimeSpan.FromSeconds(2),
        MaxRetryAttempts = 2,
        BackoffType = DelayBackoffType.Exponential,
        UseJitter = true,
    });
});

Solution

  • The short answer is that you have used a wrong overload of ExecuteAsync. You should use this one:

    var response = await pipeline.ExecuteAsync<HttpResponseMessage>(
        async ctx =>
        {
            // Simulate an error for testing purposes
            await Task.Delay(100, ctx.CancellationToken);
            return new HttpResponseMessage(HttpStatusCode.InternalServerError);
        },
        context);
    

    The overload, what you have used, is not taking advantage of the Context. If you see the method signature then it becomes clear.

    public async ValueTask<TResult> ExecuteAsync<TResult, TState>(
        Func<TState, CancellationToken, ValueTask<TResult>> callback,
        TState state,
        CancellationToken cancellationToken = default)
    

    You have passed the Context as the state object. Here we have documented what is the difference between Context and State.

    Currently there is no such overload which anticipates both a CancellationToken and a Context. But you don't even need one.

    Whenever you request a Context from the ResilienceContextPool then the passed CancellationToken is attached to the retrieved Context. If you take a look at the suggested solution's code fragment then you can spot that the CancellationToken was accessed via the Context (ctx.CancellationToken).

    So, you should use the following overload:

    public async ValueTask<TResult> ExecuteAsync<TResult>(
        Func<ResilienceContext, ValueTask<TResult>> callback,
        ResilienceContext context)
    

    As a side note please don't forget to return back the borrowed resilience context to the pool:

    ResilienceContextPool.Shared.Return(context);