package scheduler import ( "errors" "testing" "time" ) // TestRetryOnlyForTransientFailures ist der wichtigste Wiederholungstest. // // Ein Anmeldefehler behebt sich nicht durch Warten; ein Integritätsfehler wird // durch Wiederholen nicht besser, sondern nur später bemerkt. func TestRetryOnlyForTransientFailures(testInstance *testing.T) { retryPolicy := DefaultRetryPolicy() retryableClasses := []FailureClass{FailureTransient, FailureNetwork, FailureRepository, FailureSource} for _, failureClass := range retryableClasses { decision := retryPolicy.Decide(1, failureClass) if !decision.ShouldRetry { testInstance.Errorf("ein Fehler der Art %q wurde nicht wiederholt: %s", failureClass, decision.Reason) } } permanentClasses := []FailureClass{ FailureAuthentication, FailureIntegrity, FailureConfiguration, FailureSecurity, FailurePermanent, } for _, failureClass := range permanentClasses { decision := retryPolicy.Decide(1, failureClass) if decision.ShouldRetry { testInstance.Errorf("ein Fehler der Art %q wurde wiederholt, obwohl er sich nicht behebt", failureClass) } // Auch ein „nein" braucht eine Begründung: Der Betreiber muss zwischen // „aufgegeben" und „nicht wiederholbar" unterscheiden können. if decision.Reason == "" { testInstance.Errorf("die Ablehnung bei %q wurde nicht begründet", failureClass) } } } // TestRetryStopsAfterMaximumAttempts prüft die Obergrenze. func TestRetryStopsAfterMaximumAttempts(testInstance *testing.T) { retryPolicy := RetryPolicy{MaximumAttempts: 3, InitialDelay: time.Second, MaximumDelay: time.Minute} for attemptNumber := 1; attemptNumber < 3; attemptNumber++ { if decision := retryPolicy.Decide(attemptNumber, FailureTransient); !decision.ShouldRetry { testInstance.Errorf("nach Versuch %d wurde nicht wiederholt: %s", attemptNumber, decision.Reason) } } finalDecision := retryPolicy.Decide(3, FailureTransient) if finalDecision.ShouldRetry { testInstance.Fatal("nach dem letzten zugelassenen Versuch wurde erneut wiederholt") } } // TestBackoffGrowsExponentiallyAndIsCapped prüft die Wartezeiten. // // Ohne Deckel wüchse die Wartezeit weiter: Beim achten Versuch wären es aus // 30 Sekunden über eine Stunde, beim zehnten mehr als vier — ein nächtliches // Sicherungsfenster wäre längst vorbei. func TestBackoffGrowsExponentiallyAndIsCapped(testInstance *testing.T) { // Ohne Streuung, damit die Werte genau prüfbar sind. retryPolicy := RetryPolicy{ MaximumAttempts: 10, InitialDelay: 30 * time.Second, MaximumDelay: 15 * time.Minute, } expectedDelays := map[int]time.Duration{ 1: 30 * time.Second, 2: 60 * time.Second, 3: 120 * time.Second, 4: 240 * time.Second, 5: 480 * time.Second, // Ab hier greift der Deckel: 960 s wären mehr als 15 Minuten. 6: 15 * time.Minute, 9: 15 * time.Minute, } for attemptNumber, expectedDelay := range expectedDelays { decision := retryPolicy.Decide(attemptNumber, FailureTransient) if decision.Delay != expectedDelay { testInstance.Errorf("nach Versuch %d wurde %s gewartet, erwartet waren %s", attemptNumber, decision.Delay, expectedDelay) } } } // TestJitterOnlyShortensDelay prüft die Streuung. // // Nach oben zu streuen verlängerte die Wartezeit über die vereinbarte Grenze // hinaus — der Deckel wäre dann keiner. func TestJitterOnlyShortensDelay(testInstance *testing.T) { retryPolicy := RetryPolicy{ MaximumAttempts: 5, InitialDelay: time.Minute, MaximumDelay: time.Minute, JitterFraction: 0.5, } // Mehrere Durchläufe, weil die Streuung zufällig ist. for runIndex := 0; runIndex < 200; runIndex++ { decision := retryPolicy.Decide(1, FailureTransient) if decision.Delay > time.Minute { testInstance.Fatalf("die Streuung verlängerte die Wartezeit auf %s über die Grenze von 1m", decision.Delay) } if decision.Delay < 30*time.Second { testInstance.Fatalf("die Streuung verkürzte die Wartezeit auf %s, mehr als die zugelassenen 50 %%", decision.Delay) } } } // TestBackoffSurvivesLargeAttemptNumbers prüft den Überlauf. // // Mit einer Schiebeoperation liefe die Wartezeit bei hohen Versuchsnummern // über und würde negativ — aus einer langen Wartezeit würde gar keine. func TestBackoffSurvivesLargeAttemptNumbers(testInstance *testing.T) { retryPolicy := RetryPolicy{ MaximumAttempts: 10, InitialDelay: time.Hour, MaximumDelay: 2 * time.Hour, } decision := retryPolicy.Decide(9, FailureTransient) if decision.Delay <= 0 { testInstance.Fatalf("bei Versuch 9 ergab sich eine Wartezeit von %s", decision.Delay) } if decision.Delay > 2*time.Hour { testInstance.Fatalf("bei Versuch 9 wurde der Deckel überschritten: %s", decision.Delay) } } // TestRetryPolicyValidation prüft die Eingabeprüfung. func TestRetryPolicyValidation(testInstance *testing.T) { defaultPolicy := DefaultRetryPolicy() if validationError := defaultPolicy.Validate(); validationError != nil { testInstance.Fatalf("die Voreinstellung wurde abgelehnt: %v", validationError) } brokenPolicies := map[string]RetryPolicy{ "kein Versuch": {MaximumAttempts: 0}, "zu viele Versuche": {MaximumAttempts: 50}, "negative Wartezeit": {MaximumAttempts: 3, InitialDelay: -time.Second}, "Deckel unter Anfang": { MaximumAttempts: 3, InitialDelay: time.Minute, MaximumDelay: time.Second, }, "Streuung außerhalb": {MaximumAttempts: 3, JitterFraction: 1.5}, } for caseName, brokenPolicy := range brokenPolicies { if validationError := brokenPolicy.Validate(); validationError == nil { testInstance.Errorf("%s: die Strategie wurde angenommen", caseName) } } } // TestClassifyErrorDefaultsToPermanent prüft den sicheren Rückfall. // // Ein falsch als vorübergehend eingeordneter Fehler liesse einen aussichtslosen // Auftrag immer wieder anlaufen und verdeckte dabei die eigentliche Ursache. func TestClassifyErrorDefaultsToPermanent(testInstance *testing.T) { knownNetworkError := errors.New("verbindung abgebrochen") classifications := map[error]FailureClass{ knownNetworkError: FailureNetwork, } if classifiedClass := ClassifyError(knownNetworkError, classifications); classifiedClass != FailureNetwork { testInstance.Errorf("ein bekannter Fehler wurde als %q eingeordnet", classifiedClass) } unknownError := errors.New("etwas ganz anderes") if classifiedClass := ClassifyError(unknownError, classifications); classifiedClass != FailurePermanent { testInstance.Fatalf("ein unbekannter Fehler wurde als %q eingeordnet statt als dauerhaft", classifiedClass) } if ClassifyError(nil, classifications) != "" { testInstance.Error("ein fehlender Fehler wurde eingeordnet") } }