Retry und Resilienz: Linearer Backoff für HTTP-Anfragen (Teil 12)
Netzwerkanfragen scheitern. Das ist keine Ausnahme, sondern die Realität. Ein kurzer Netzwerkausfall, ein überlasteter Server, ein Rate-Limiting-Response — all das kann passieren. Gute Software erkennt transiente Fehler und versucht es automatisch noch einmal. EchoPlay implementiert diese Retry-Logik im SpotifyHttpRetry-Modul — ohne externe Bibliotheken, in einer einzigen statischen Klasse.
Was ist ein transienter Fehler?
Ein transienter Fehler ist ein vorübergehender Fehler, der sich von alleine löst. Typische Beispiele sind ein kurzer DNS-Timeout, ein Verbindungsabbruch, Server-Überlastung (HTTP 503 Service Unavailable), Rate-Limiting (HTTP 429 Too Many Requests) oder ein interner Server-Fehler (HTTP 500). All diese Situationen können auftreten und verschwinden nach kurzer Zeit von selbst.
Davon zu unterscheiden sind permanente Fehler: HTTP 404 Not Found bedeutet, dass die Ressource nicht existiert. HTTP 401 Unauthorized heißt, die Credentials sind falsch. HTTP 400 Bad Request zeigt eine fehlerhafte Anfrage an. Nur transiente Fehler sollten wiederholt werden. Einen 401-Fehler bei jedem Retry zu wiederholen bringt nichts und verschwendet Ressourcen.
SpotifyHttpRetry: Linearer Backoff
internal static class SpotifyHttpRetry
{
// 500 ms vor Versuch 2, 1000 ms vor Versuch 3
private static readonly TimeSpan[] Delays =
[
TimeSpan.FromMilliseconds(500),
TimeSpan.FromMilliseconds(1000)
];
internal static async Task<HttpResponseMessage> SendWithRetryAsync(
Func<Task<HttpResponseMessage>> sendAsync,
CancellationToken cancellationToken = default)
{
int maxRetries = Delays.Length;
HttpRequestException? lastConnectionException = null;
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
HttpResponseMessage response = await sendAsync();
if (IsTransientStatusCode(response.StatusCode) && attempt < maxRetries)
{
response.Dispose(); // Response freigeben, nicht vergessen!
await Task.Delay(Delays[attempt], cancellationToken);
continue;
}
return response;
}
catch (HttpRequestException ex) when (ex.StatusCode is null && attempt < maxRetries)
{
// Verbindungsfehler ohne Status-Code (kein HTTP-Response) → Retry
lastConnectionException = ex;
await Task.Delay(Delays[attempt], cancellationToken);
}
}
throw lastConnectionException!;
}
private static bool IsTransientStatusCode(HttpStatusCode statusCode)
{
return statusCode == HttpStatusCode.TooManyRequests || (int)statusCode >= 500;
}
}
Die Klasse erlaubt maximal 3 Versuche: Versuch 1 sofort, Versuch 2 nach 500 ms, Versuch 3 nach weiteren 1000 ms. Das ergibt einen linearen Backoff — die Wartezeit nimmt linear zu. Exponentieller Backoff (500, 1000, 2000, 4000 ms) wäre für flüchtige Fehler theoretisch besser geeignet, aber für den Anwendungsfall von EchoPlay ist linearer Backoff ausreichend. Das IsTransientStatusCode-Predicate trennt sauber: HTTP 5xx und HTTP 429 sind transient, alle anderen 4xx-Codes sind permanente Fehler.
Die Factory-Funktion: Warum kein Request-Objekt übergeben?
Der Aufrufer übergibt eine Func<Task<HttpResponseMessage>>, keine HttpRequestMessage. Das ist kein Zufall. HttpRequestMessage ist in .NET einmalig verwendbar. Sobald eine Instanz gesendet wurde, ist sie „consumed“. Ein zweiter Aufruf mit derselben Instanz würde eine InvalidOperationException werfen.
// FALSCH: request kann nicht wiederverwendet werden
HttpRequestMessage request = new(HttpMethod.Get, "v1/search?q=...");
await SpotifyHttpRetry.SendWithRetryAsync(() => _httpClient.SendAsync(request));
// ^^^^^^^ Zweiter Retry: Exception!
// RICHTIG: Lambda erstellt bei jedem Aufruf eine neue Instanz
await SpotifyHttpRetry.SendWithRetryAsync(
() => _httpClient.GetAsync($"v1/search?q={query}"));
GetAsync erstellt intern eine neue HttpRequestMessage. Das Lambda wird bei jedem Retry erneut aufgerufen, und jedes Mal entsteht ein frisches Request-Objekt. Das ist der Grund, warum die Methode eine Factory-Funktion erwartet statt eines fertigen Request-Objekts.
Response.Dispose(): Speicherleck vermeiden
if (IsTransientStatusCode(response.StatusCode) && attempt < maxRetries)
{
response.Dispose(); // Response-Body wird freigegeben
await Task.Delay(Delays[attempt], cancellationToken);
continue;
}
HttpResponseMessage implementiert IDisposable. Wenn eine Response empfangen wird und anschließend nicht verwendet werden soll, weil ein Retry folgt, muss sie explizit freigegeben werden. Ohne Dispose würden die HTTP-Verbindung und der Response-Buffer für die Dauer des Retries im Speicher bleiben — ein klassisches Speicherleck bei wiederholten Anfragen.
Verbindungsfehler vs. HTTP-Fehler
Beim HTTP-Aufruf gibt es zwei grundverschiedene Fehlertypen. Ein HTTP-Fehler bedeutet: Der Server hat geantwortet, aber mit einem Fehler-Status-Code. HttpResponseMessage.StatusCode enthält den Code, und der Fehler wird über den Rückgabewert signalisiert. Ein Verbindungsfehler dagegen bedeutet: Kein HTTP-Response. Der Request kam nicht durch. Eine HttpRequestException wird geworfen, und ex.StatusCode ist null.
catch (HttpRequestException ex) when (ex.StatusCode is null && attempt < maxRetries)
{
lastConnectionException = ex;
await Task.Delay(Delays[attempt], cancellationToken);
}
Das Pattern when (ex.StatusCode is null) ist ein Exception-Filter. Er lässt nur Verbindungsfehler in diesen Catch-Block. Eine HttpRequestException mit einem Status-Code (z.B. 400 oder 404) wird nicht gefangen und propagiert sofort nach oben — wo der Aufrufer entscheiden kann, wie damit umgegangen wird.
Kein Retry für JsonException
Wenn Spotify einen erfolgreichen Response liefert, aber das JSON ungültig ist, ist das kein transienter Fehler. Ein Retry würde dasselbe JSON nochmal liefern — mit demselben Parsing-Fehler. JsonException wird deshalb nicht abgefangen — sie propagiert direkt zum Aufrufer. Der kann dann entscheiden, ob er das loggt, dem Nutzer eine Fehlermeldung zeigt oder den Import abbricht.
CancellationToken: Retry abbrechbar machen
await Task.Delay(Delays[attempt], cancellationToken);
Das cancellationToken wird an Task.Delay weitergegeben. Wenn der Nutzer die Aktion abbricht — etwa weil er das Suchfeld leert und neu tippt — wird der laufende Retry sofort beendet, statt auf den Ablauf des Delays zu warten. Ohne diesen Token würde der UI-Thread blockieren, bis alle Retries abgelaufen sind. Das async/await-Pattern sorgt zwar dafür, dass der Thread nicht wirklich blockiert, aber die Operation selbst würde unnötig weiterlaufen und Ressourcen verbrauchen.
Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu