Resilienz-Pipelines mit Microsoft.Extensions.Resilience

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.