using System.Net; using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Api.Models; using ClawdDotNet.Core.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Core.Tests.Api; /// /// B12 aus der Bestandsaufnahme: Der Client warf bei jedem Nicht-2xx sofort. Ein /// einzelnes HTTP 429 beendete damit einen kompletten geplanten Lauf, obwohl /// Rate-Limits und kurze 5xx bei OpenRouter Normalbetrieb sind. /// public sealed class RetryPolicyTests { [Theory] [InlineData(HttpStatusCode.TooManyRequests, true)] [InlineData(HttpStatusCode.RequestTimeout, true)] [InlineData(HttpStatusCode.InternalServerError, true)] [InlineData(HttpStatusCode.BadGateway, true)] [InlineData(HttpStatusCode.ServiceUnavailable, true)] [InlineData(HttpStatusCode.GatewayTimeout, true)] [InlineData(HttpStatusCode.BadRequest, false)] [InlineData(HttpStatusCode.Unauthorized, false)] [InlineData(HttpStatusCode.Forbidden, false)] [InlineData(HttpStatusCode.NotFound, false)] [InlineData(HttpStatusCode.Conflict, false)] public void Nur_voruebergehende_Stoerungen_werden_wiederholt(HttpStatusCode status, bool expected) { // Ein 400 oder 401 wiederholt sich nicht von selbst — dafür zu warten // verzögert nur die Fehlermeldung. RetryPolicy.Default.ShouldRetry(status).ShouldBe(expected); } [Fact] public void Die_Wartezeit_waechst_exponentiell() { var policy = new RetryPolicy { BaseDelay = TimeSpan.FromSeconds(1), JitterFactor = 0 }; policy.GetDelay(1).ShouldBe(TimeSpan.FromSeconds(1)); policy.GetDelay(2).ShouldBe(TimeSpan.FromSeconds(2)); policy.GetDelay(3).ShouldBe(TimeSpan.FromSeconds(4)); policy.GetDelay(4).ShouldBe(TimeSpan.FromSeconds(8)); } [Fact] public void Die_Wartezeit_ist_nach_oben_begrenzt() { var policy = new RetryPolicy { BaseDelay = TimeSpan.FromSeconds(1), MaxDelay = TimeSpan.FromSeconds(10), JitterFactor = 0 }; policy.GetDelay(20).ShouldBe(TimeSpan.FromSeconds(10)); } [Fact] public void Ein_RetryAfter_des_Servers_hat_Vorrang() { var policy = new RetryPolicy { BaseDelay = TimeSpan.FromSeconds(1), JitterFactor = 0 }; policy.GetDelay(1, TimeSpan.FromSeconds(7)).ShouldBe(TimeSpan.FromSeconds(7)); } [Fact] public void Auch_ein_RetryAfter_wird_gedeckelt() { // Ein Server könnte Stunden verlangen — so lange darf kein Lauf blockieren. var policy = new RetryPolicy { MaxDelay = TimeSpan.FromSeconds(30) }; policy.GetDelay(1, TimeSpan.FromHours(1)).ShouldBe(TimeSpan.FromSeconds(30)); } [Theory] [InlineData(0.0)] [InlineData(0.5)] [InlineData(1.0)] public void Die_Streuung_bleibt_im_erwarteten_Rahmen(double sample) { var policy = new RetryPolicy { BaseDelay = TimeSpan.FromSeconds(4), JitterFactor = 0.25 }; var delay = policy.GetDelay(1, null, sample); delay.TotalSeconds.ShouldBeInRange(3.0, 5.0); } } public sealed class OpenRouterClientRetryTests { private static ChatRequest SimpleRequest() => new() { Model = "test/model", Messages = [ChatMessage.User("Hallo")] }; private const string SuccessBody = """{"id":"x","choices":[{"index":0,"message":{"role":"assistant","content":"Hi"}}]}"""; /// Wartet nicht wirklich — sonst dauerte die Suite Sekunden. private static Task NoWait(TimeSpan _, CancellationToken __) => Task.CompletedTask; private static OpenRouterClient CreateClient(FakeHttpMessageHandler handler, RetryPolicy? policy = null) { var http = new HttpClient(handler) { BaseAddress = new Uri("https://openrouter.ai/api/v1/") }; return new OpenRouterClient("sk-test", NullLogger.Instance, http, policy ?? RetryPolicy.Default, NoWait); } [Fact] public async Task Ein_RateLimit_beendet_den_Lauf_nicht_mehr() { var handler = new FakeHttpMessageHandler() .Responds(HttpStatusCode.TooManyRequests, """{"error":"rate limit"}""") .RespondsWithSuccess(SuccessBody); using var client = CreateClient(handler); var response = await client.CompleteAsync(SimpleRequest(), default); response.Choices[0].Message.Content.ShouldBe("Hi"); handler.RequestCount.ShouldBe(2); } [Fact] public async Task Mehrere_Stoerungen_hintereinander_werden_ueberbrueckt() { var handler = new FakeHttpMessageHandler() .Responds(HttpStatusCode.ServiceUnavailable) .Responds(HttpStatusCode.BadGateway) .RespondsWithSuccess(SuccessBody); using var client = CreateClient(handler); await client.CompleteAsync(SimpleRequest(), default); handler.RequestCount.ShouldBe(3); } [Fact] public async Task Nach_dem_letzten_Versuch_wird_der_Fehler_gemeldet() { var policy = new RetryPolicy { MaxAttempts = 3 }; var handler = new FakeHttpMessageHandler() .Responds(HttpStatusCode.TooManyRequests) .Responds(HttpStatusCode.TooManyRequests) .Responds(HttpStatusCode.TooManyRequests); using var client = CreateClient(handler, policy); var ex = await Should.ThrowAsync( () => client.CompleteAsync(SimpleRequest(), default)); ex.StatusCode.ShouldBe(429); handler.RequestCount.ShouldBe(3); } [Fact] public async Task Ein_dauerhafter_Fehler_wird_nicht_wiederholt() { // Ein 401 wird durch Warten nicht besser — sofort melden. var handler = new FakeHttpMessageHandler() .Responds(HttpStatusCode.Unauthorized, """{"error":"invalid key"}"""); using var client = CreateClient(handler); await Should.ThrowAsync( () => client.CompleteAsync(SimpleRequest(), default)); handler.RequestCount.ShouldBe(1); } [Fact] public async Task Netzwerkfehler_werden_wiederholt() { var handler = new FakeHttpMessageHandler() .Throws(new HttpRequestException("Verbindung abgebrochen")) .RespondsWithSuccess(SuccessBody); using var client = CreateClient(handler); await client.CompleteAsync(SimpleRequest(), default); handler.RequestCount.ShouldBe(2); } [Fact] public async Task Ein_Abbruch_durch_den_Benutzer_wird_nicht_wiederholt() { var handler = new FakeHttpMessageHandler().Responds(HttpStatusCode.TooManyRequests); using var client = CreateClient(handler); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); await Should.ThrowAsync( () => client.CompleteAsync(SimpleRequest(), cts.Token)); handler.RequestCount.ShouldBe(0); } [Fact] public async Task Ein_erfolgreicher_erster_Versuch_loest_keine_Wiederholung_aus() { var handler = new FakeHttpMessageHandler().RespondsWithSuccess(SuccessBody); using var client = CreateClient(handler); await client.CompleteAsync(SimpleRequest(), default); handler.RequestCount.ShouldBe(1); } }