Resilienz-Pipelines mit Microsoft.Extensions.Resilience
- Matt
Ein interner Dienst antwortete bei uns sporadisch mit 503, meist unter Last,
meist nach wenigen Sekunden wieder weg. Der klassische Reflex ist eine
Retry-Schleife von Hand, ein bisschen Task.Delay, ein Zaehler. Das funktioniert,
bis jemand fragt, warum bei einem Timeout nicht auch abgebrochen wird, und warum
der kaputte Dienst weiter mit Anfragen bombardiert wird, obwohl klar ist, dass er
gerade nichts liefert. Genau diese Kombination nimmt einem
Microsoft.Extensions.Resilience ab, aufgesetzt auf Polly.
Der Standard-Handler
Fuer HTTP gibt es ein eigenes Paket, Microsoft.Extensions.Http.Resilience. Es
haengt sich an den IHttpClientBuilder, den man ohnehin schon hat, wenn man den
HttpClient nicht selbst erzeugt:
builder.Services
.AddHttpClient<ExampleClient>()
.AddStandardResilienceHandler();
Das war es. Der Standard-Handler stapelt fuenf Strategien in fester Reihenfolge:
ein Rate Limiter, ein Gesamt-Timeout von 30 Sekunden, ein Retry mit drei
Versuchen und exponentiellem Backoff, ein Circuit Breaker und ein Timeout pro
Versuch von 10 Sekunden. Retry und Circuit Breaker greifen bei HTTP 408, 429 und
allem ab 500, dazu bei HttpRequestException und Pollys eigener
TimeoutRejectedException.
Der Haken mit den Voreinstellungen
Der Punkt, an dem ich haengengeblieben bin: Der Standard-Handler wiederholt standardmaessig alle HTTP-Methoden, auch POST und DELETE. Bei einem POST, das einen Datensatz anlegt, wird aus einem Timeout schnell ein Duplikat, weil der erste Versuch serverseitig durchlief und die Antwort nur unterwegs verloren ging. Das steht nicht im Weg, man muss es abschalten:
.AddStandardResilienceHandler(options =>
{
options.Retry.DisableForUnsafeHttpMethods();
});
DisableForUnsafeHttpMethods nimmt POST, PATCH, PUT, DELETE und CONNECT aus dem
Retry heraus. Und noch etwas: Man soll nur einen Resilienz-Handler pro Client
setzen. Stapelt man zwei, multiplizieren sich die Wiederholungen, und aus drei
Versuchen werden neun.
Ohne HttpClient
Nicht jeder Aufruf geht ueber HTTP. Fuer alles andere, etwa eine
Datenbankabfrage, gibt es das Basispaket Microsoft.Extensions.Resilience mit
einer benannten Pipeline:
services.AddResiliencePipeline("db-retry", builder =>
{
builder
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 2,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true
})
.AddTimeout(TimeSpan.FromSeconds(1.5));
});
Abgerufen wird sie ueber den ResiliencePipelineProvider<string> und dann mit
ExecuteAsync ausgefuehrt:
var pipeline = provider
.GetRequiredService<ResiliencePipelineProvider<string>>()
.GetPipeline("db-retry");
await pipeline.ExecuteAsync(async ct => await DoWorkAsync(ct));
Der Reiz ist, dass die Pipeline in der DI liegt und damit an einer Stelle konfiguriert und getestet wird, statt in jeder Methode neu. Wo ich vorsichtig bin: Ein Retry macht einen langsamen Dienst nicht schneller, sondern schiebt die Last nur nach hinten. Wenn die eigentliche Ursache eine ueberlastete Datenbank ist, verschlimmert ein aggressives Retry sie. Der Circuit Breaker ist da eher Freund als der Retry.
Details zu den Voreinstellungen und den Optionen stehen in der Dokumentation zu resilienten HTTP-Apps.