Adding Resilience to Refit and your own code

|

You may be using Refit already today in your App or you want to do so. It is a great little REST Api client library where you quickly through interfaces can start communicating with an API and without having to write a bunch of client code yourself.

An example of this looks like so:

public interface IMyUserApi
{
    [Get("/users/{userId}")]
    Task<User> GetUser(string userId);
}

Then you can use the client like so:

var userApi = RestService.For<IMyUserApi>("https://api.myusers.com");
var user = await gitHubApi.GetUser("abcdefg123");

Super easy and no need to write any HttpClient code to call GetAsync.

Refit also nicely integrates with Microsoft.Extensions.DependencyInjection IServiceCollection leveraging HttpClientFactory which most modern Applications should be using. This also allows configuring additional HttpClientHandlers to allow more instrumentation and as I later describe, allows configuring some resiliency:

services
    .AddRefitClient<IMyUserApi>()
    .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://api.myusers.com"));

OK, so with a Refit client registered in the service collection, how can we add some resiliency to it? Perhaps you are already familiar with Polly directly or using Microsoft.Extensions.Http.Polly, this is not recommended anymore, so let me show you how you can set that up using the nice and shiny Microsoft.Extensions.Resilience and Microsoft.Extensions.Http.Resilience packages.

Adding the latter package Microsoft.Extensions.Http.Resilience gives you a nice extension method to add a Resilience Handler to your HttpClient. So the example above becomes:

services
    .AddRefitClient<IMyUserApi>()
    .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://api.myusers.com"))
    .AddStandardResilienceHandler();

Just by adding this one line you will get retries set up for you with the defaults described in the README for the Microsoft.Extensions.Http.Resilience package which as of writing this post are:

  • The total request timeout pipeline applies an overall timeout to the execution, ensuring that the request including hedging attempts, does not exceed the configured limit.
  • The retry pipeline retries the request in case the dependency is slow or returns a transient error.
  • The rate limiter pipeline limits the maximum number of requests being send to the dependency.
  • The circuit breaker blocks the execution if too many direct failures or timeouts are detected.
  • The attempt timeout pipeline limits each request attempt duration and throws if its exceeded.

If you want to configure any of these behaviors you can configure that with:

.AddStandardResilienceHandler(builder =>
{
    builder.Retry = new HttpRetryStrategyOptions
    {
        MaxRetryAttempts = 2,
        Delay = TimeSpan.FromSeconds(1),
        UseJitter = true,
        BackoffType = DelayBackoffType.Exponential
    };

    builder.TotalRequestTimeout = new HttpTimeoutStrategyOptions { Timeout = TimeSpan.FromSeconds(30) };
});

You are fully in control!

If you want to add resiliency to something else, you can also add resilience pipelines directly in your service collection and use the Microsoft.Extensions.Resilience package:

service.AddResiliencePipeline("install-apps", builder =>
{
    builder.AddTimeout(TimeSpan.FromMinutes(3));
});

Then resolve and use it with a construction looking something like this:

public sealed class AppInstaller(
    ILogger<AppInstaller> logger,
    ResiliencePipelineProvider<string> pipelineProvider)
{
    private readonly ILogger<AppInstaller> _logger = logger;
    private readonly ResiliencePipeline _pipeline = pipelineProvider.GetPipeline("install-apps");

    public async Task<InstallStatus> InstallApp(string downloadPath, CancellationToken cancellationToken)
    {
        // Get context for cancellation and passing along state
        ResilienceContext? context = ResilienceContextPool.Shared.Get(cancellationToken);

        // Execute async method passing state and cancellation token
        Outcome<InstallStatus> outcome = await _pipeline.ExecuteOutcomeAsync(
            async (ctx, state) =>
                Outcome.FromResult(
                    await InstallAppInternal(state, ctx.CancellationToken).ConfigureAwait(false)
                ),
                context,
                downloadPath)
            .ConfigureAwait(false);
        
        // Handle errors from outcome
        if (outcome.Exception != null)
        {
            _logger.LogError(outcome.Exception, "Something went wrong installing app from {DownloadPath}", downloadPath);
        }

        return outcome.Result ?? InstallStatus.Failed;
    }

    // more code here...
}

Hope this helps a bit understanding how the resilience libraries work, but just like Polly you can use it with anything you want to retry, with some nice defaults for HTTP requests.

If you want to read more about Resilience Milan also wrote a really nice blog post, which you might find interesting.