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

Client HTTP type-safe con Refit: resilienza da 10/10