Webion

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

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

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

Quando l’integrazione HTTP è "semplice" (CRUD, filtri via query string, body tipizzati), scrivere client a mano è uno spreco: boilerplate per serializzazione, gestione degli errori, mapping query/route, header ripetitivi. Refit risolve il problema definendo l’API come interfaccia; il client viene generato a compile-time (source generator), funziona con HttpClientFactory e non richiede reflection invasiva.


Perimetro d’uso

Refit è efficace quando:

  • il provider espone API stabili con semantica chiara e statica;
  • le richieste hanno schema "piatto" (route, query, JSON body);
  • serve un contratto C# chiaro e condivisibile tra team.

Se l’API è iper-dinamica (query arbitraria, payload eterogenei), o se devi generare il client da uno schema OpenAPI complicato, conviene valutare un wrapper scritto a mano.


Struttura minima

  • Refit per il binding interfaccia→client.
  • HttpClientFactory per pooling socket e DI.
  • Resilienza con Microsoft.Extensions.Http.Resilience (Polly v8 sotto al cofano).
  • System.Text.Json per serializzazione (converter mirati, enum come stringhe).
  • DelegatingHandler per autenticazione/correlation-id.


Contratto: interfaccia prima del codice

Partiamo dal contratto. Definisci con precisione route, query, body, status attesi. Evita overload ambigui e mantieni i DTO nullability aware (nullable reference types attivi).


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);


Note:

  • [Query], [Body], "{id}" proiettano le proprietà → query string, payload in body, porzione della route, tutto senza scrivere builder manuali.
  • Per download grandi, usa Task<Stream> e propaga il CancellationToken; Refit non bufferizza l’intero corpo se la firma restituisce Stream.


Wiring: DI, serializer, resilienza

Una registrazione sensata evita problemi in produzione (timeout, retry aggressivi, circuit breaker rumorosi). Qui la versione “standard” che usiamo come 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 });

Dettagli che contano:

  • AddRefitClient<T> aggancia Refit al ciclo di vita di HttpClientFactory (socket pooling gestito, DNS refresh).
  • AddStandardResilienceHandler (nuget: Microsoft.Extensions.Http.Resilience) imposta retry/timeout totale e circuit breaker a livello handler.
  • Serializzazione: System.Text.Json va bene nel 99% dei casi; aggiungi converter mirati (ad es. JsonStringEnumConverter).


Autenticazione e cross-cutting

Non mescolare auth nei metodi Refit: introduci un DelegatingHandler. Questo consente rotazione token, correlazione, metriche uniformi.

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}


Gestione errori: ApiException, payload e diagnosi

Con ritorno Task<T> Refit lancia ApiException su 4xx/5xx.
Con Task<ApiResponse<T>> non lancia e ti consegna status + 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}

Puoi decidere di usare ciò che ti è più comodo in base alla situazione.


Versionamento e header

Se il provider utilizza header custom, puoi impostarli con un attributo.

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

Per esempio se la versione è via header puoi impostarlo così.

Se invece la versione è in path (/v2/...), modellala direttamente nelle route.


Gestire API complesse ed endpoint numerosi

Si può decidere anche di non avere direttamente una sola interfaccia a rappresentare l'intera API, ma di creare un'interfaccia wrapper per il client che venga suddivisa nelle diverse componenti dell'API, usando 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>();



Pitfall ricorrenti (e come evitarli)

  • Retry su POST: predefiniti = 3? Non farlo alla cieca. Accendi retry solo su errori transitori e operazioni idempotenti (o con idempotency key lato server).
  • Timeout "doppio": c’è il timeout totale dell’handler e il CancellationToken chiamante; decidi chi comanda per evitare cancellazioni premature.
  • Enum numerici: senza JsonStringEnumConverter rischi breaking change non visibili (server invia nuova stringa, tu leggi numero sbagliato).
  • Nullability: DTO con proprietà non-nullable ma assenti nel payload = exception in deserializzazione o default "silenziosi". Marca nullable dove il provider può omettere.
  • Query complesse: [Query] mappa proprietà → query; se i nomi lato server differiscono, usa [AliasAs].
  • AOT/trimming: Refit usa generator, quindi ok; i converter riflessivi possono essere tagliati: registra esplicitamente converter e mantieni il contratto "raggiungibile".


Quando NON usare Refit

  • Devi sfruttare schema OpenAPI per rigenerare client in CI al cambiare del contratto → preferisci codegen.
  • Il provider usa protocolli non-HTTP standard (websocket, SSE avanzato) → scrivi un wrapper dedicato.
  • Serve controllo totale su HttpRequestMessage (policy specifiche per singolo call-site) → typed client manuale.


Conclusione

Refit consente di codificare l’integrazione HTTP come contratto C# e delegare al runtime i dettagli meccanici: instradamento, serializzazione, mapping query/route. L’integrazione con HttpClientFactory e i resilience handler di .NET 8 copre i requisiti reali (timeout, retry con jitter, circuit breaker). Mantieni l’interfaccia piccola, definisci DTO solidi e presidia errori/telemetria.

Davide Messori

Fino alla bara s'impara