Webion

Type-safe HTTP client with Refit: 10/10 resilience

Client HTTP tipizzati in .NET 8 con Refit: interfacce, resilienza e integrazione DI. Riduci il boilerplate per l'integrazione di API standardizzate.

D
Davide Messorion October 1, 2025
G26

Type-safe HTTP client with Refit: 10/10 resilience

When HTTP integration is “simple” (CRUD, query string filters, typed bodies), writing clients by hand is a waste of time: boilerplate for serialization, error handling, query/route mapping, repetitive headers. Refit solves this problem by defining the API as an interface; the client is generated at compile time (via source generator), works with HttpClientFactory, and doesn’t rely on invasive reflection.


Usage scope

Refit is effective when:

  • the provider exposes stable APIs with clear, static semantics;
  • requests have a “flat” structure (route, query, JSON body);
  • a clear, shareable C# contract is needed across teams.

If the API is highly dynamic (arbitrary queries, heterogeneous payloads), or if you need to generate the client from a complex OpenAPI schema, it’s better to consider a custom hand-written wrapper.


Minimal setup

  • Refit for interface→client binding.
  • HttpClientFactory for socket pooling and dependency injection.
  • Resilience with Microsoft.Extensions.Http.Resilience (Polly v8 under the hood).
  • System.Text.Json for serialization (targeted converters, enums as strings).
  • DelegatingHandler for authentication/correlation ID.


Contract: interface before code

Start from the contract. Define routes, queries, bodies, and expected status codes precisely. Avoid ambiguous overloads and keep DTOs nullability-aware (enable nullable reference types).


C#
1// contracts/IPaymentsApi.cs
2using Refit;
3
4public interface IPaymentsApi
5{
6 [Get("/v1/payments/{id}")]
7 Task<ApiResponse<PaymentDto>> GetAsync(string id, CancellationToken ct = default);
8
9 [Get("/v1/payments")]
10 Task<ApiResponse<Page<PaymentDto>>> ListAsync([Query] int Page, [Query] int PageSize, [Query] PaymentStatus? Status, CancellationToken ct = default);
11
12 [Post("/v1/payments")]
13 Task<ApiResponse<PaymentDto>> CreateAsync([Body] CreatePayment cmd, CancellationToken ct = default);
14
15 [Get("/v1/reports/{id}")]
16 Task<Stream> DownloadReportAsync(string id, CancellationToken ct = default);
17}
18
19public sealed record PaymentDto(string Id, string Status, decimal Amount, string Currency);
20public sealed record Page<T>(IReadOnlyList<T> Items, int Page, int PageSize, int Total);
21public sealed record CreatePayment(decimal Amount, string Currency, string CustomerId);


Notes:

  • [Query], [Body], and "{id}" project properties into the query string, request body payload, and route segments — all without manually writing builders.
  • For large downloads, use Task<Stream> and propagate the CancellationToken; Refit doesn’t buffer the entire response body when the method signature returns a Stream.


Wiring: DI, serializer, resilience

A sensible registration setup prevents production issues (timeouts, overly aggressive retries, noisy circuit breakers).
Here’s the “standard” version we use as our baseline.

C#
1// Program.cs
2var jsonOptions = new JsonSerializerOptions
3{
4 PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
5 DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
6};
7jsonOptions.Converters.Add(new JsonStringEnumConverter());
8
9var refitSettings = new RefitSettings
10{
11 ContentSerializer = new SystemTextJsonContentSerializer(jsonOptions)
12};
13
14builder.Services.AddTransient<AuthDelegatingHandler>(); // DelegatingHandler custom
15
16builder.Services
17 .AddRefitClient<IPaymentsApi>(refitSettings)
18 .ConfigureHttpClient(c =>
19 {
20 c.BaseAddress = new Uri(builder.Configuration["Payments:BaseUrl"]!);
21 c.DefaultRequestHeaders.UserAgent.ParseAdd("webion-payments/1.0");
22 })
23 .AddHttpMessageHandler<AuthDelegatingHandler>()
24 .AddStandardResilienceHandler(o =>
25 {
26 o.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(10);
27 o.Retry.MaxRetryAttempts = 3;
28 o.Retry.BackoffType = DelayBackoffType.Exponential;
29 o.Retry.UseJitter = true;
30 o.CircuitBreaker.MinimumThroughput = 10;
31 o.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
32 o.CircuitBreaker.FailureRatio = 0.5;
33 o.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(15);
34 });

Key details:

  • AddRefitClient<T> hooks Refit into the HttpClientFactory lifecycle (managed socket pooling, DNS refresh).
  • AddStandardResilienceHandler (NuGet: Microsoft.Extensions.Http.Resilience) configures total retry/timeout and a circuit breaker at the handler level.
  • Serialization: System.Text.Json works well in 99% of cases; add targeted converters when needed (e.g., JsonStringEnumConverter).


Authentication and cross-cutting concerns

Don’t mix authentication logic into Refit methods — introduce a DelegatingHandler instead. This allows for token rotation, request correlation, and consistent metrics across all calls.

C#
1public sealed class AuthDelegatingHandler : DelegatingHandler
2{
3 private readonly IExampleTokenGenerator _tokenGenerator;
4
5 public AuthDelegatingHandler(IExampleTokenGenerator tokenGenerator)
6 {
7 _tokenGenerator = tokenGenerator;
8 }
9
10 protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
11 {
12 // Your logic to retrieve auth token
13 var token = _tokenGenerator.GenerateTokenAsync(cancellationToken);
14
15 request.Headers.Add("Authorization", $"Bearer {token}");
16 return await base.SendAsync(request, cancellationToken);
17 }
18}


Error handling: ApiException, payload, and diagnostics

When returning Task<T>, Refit throws an ApiException on 4xx/5xx responses.
When using Task<ApiResponse<T>>, it doesn’t throw — instead, it gives you both the status code and the response body.

C#
1// Con Task<ApiResponse<T>>
2var res = await _api.GetAsync(id);
3if (!res.IsSuccessStatusCode)
4{
5 // Gestione errore
6}
7
8return res.Content;
9
10// Con Task<T>
11try
12{
13 var res = await _api.GetAsync(id, ct);
14 return res.Content!;
15}
16catch (ApiException ex)
17{
18 // Gestione errore
19}

You can choose whichever approach fits best for your scenario.


Versioning and headers

If the provider uses custom headers, you can set them via an attribute.

C#
1[Headers("Accept: application/json", "X-Api-Version: 1")]
2public interface IPaymentsApi { /* ... */ }

For example, if versioning is via header you can set it like this.
If instead the version is in the path (/v2/...), model it directly in the routes.


Handling complex APIs and numerous endpoints

You can choose not to have a single interface representing the entire API; instead, create a wrapper interface for the client that’s split into the API’s various components, using RestServices.For<T>.

C#
1// ApplicationManagerClient.cs
2public sealed class ApplicationManagerClient : IApplicationManagerClient
3{
4 private readonly HttpClient _client;
5
6 public ApplicationManagerClient(HttpClient client)
7 {
8 _client = client;
9 }
10
11 public IApplicationsApi Applications => RestService.For<IApplicationsApi>(_client);
12 public ISitesApi Sites => RestService.For<ISitesApi>(_client);
13 public IAppPoolsApi AppPools => RestService.For<IAppPoolsApi>(_client);
14}
15
16// IApplicationManagerClient.cs
17public interface IApplicationManagerClient
18{
19 public IApplicationsApi Applications { get; }
20 public ISitesApi Sites { get; }
21 public IAppPoolsApi AppPools { get; }
22}
23
24// program.cs
25services.AddHttpClient<IApplicationManagerClient, ApplicationManagerClient>();



Common pitfalls (and how to avoid them)

  • Retries on POST: Default retries = 3? Don’t apply them blindly. Enable retries only for transient errors and idempotent operations (or use idempotency keys on the server side).
  • Double timeout: There’s both the handler’s total timeout and the caller’s CancellationToken; decide which one governs to prevent premature cancellations.
  • Numeric enums: Without JsonStringEnumConverter, you risk silent breaking changes (the server sends a new string, you read the wrong number).
  • Nullability: DTOs with non-nullable properties missing in the payload cause deserialization exceptions or silent defaults. Mark as nullable where the provider might omit fields.
  • Complex queries: [Query] maps properties to query parameters; if server-side names differ, use [AliasAs].
  • AOT/trimming: Refit uses a source generator, so it’s safe; however, reflective converters may get trimmed out — explicitly register converters and ensure the contract remains reachable.


When not to use Refit

  • You need to leverage an OpenAPI schema to regenerate clients automatically in CI when the contract changes → prefer code generation.
  • The provider uses non-standard HTTP protocols (e.g., WebSockets, advanced SSE) → write a dedicated wrapper.
  • You need full control over HttpRequestMessage (custom policy per call site) → go with a manually written typed client.


Conclusion

Refit enables modeling HTTP integrations as C# contracts, offloading mechanical details like routing, serialization, and query/route mapping to the runtime. With HttpClientFactory and .NET 8’s resilience handlers, you get robust handling for real-world needs (timeouts, jittered retries, circuit breakers). Keep interfaces small, define solid DTOs, and stay on top of error handling and telemetry.

Davide Messori

Fino alla bara s'impara