package scheduler import ( "errors" "fmt" "math" "math/rand/v2" "time" ) // FailureClass ist die Einordnung eines Fehlers (PROMPT.md §139). // // Die Einordnung entscheidet über die Wiederholung. Ohne sie gäbe es nur zwei // gleich schlechte Möglichkeiten: alles zu wiederholen — dann läuft ein // Auftrag mit falschem Passwort ewig weiter — oder nichts — dann scheitert // eine Sicherung an einer Sekunde Netzstörung. type FailureClass string const ( // FailureTransient ist ein vorübergehender Fehler. FailureTransient FailureClass = "transient" // FailurePermanent ist ein dauerhafter Fehler. FailurePermanent FailureClass = "permanent" // FailureIntegrity ist ein Integritätsfehler. FailureIntegrity FailureClass = "integrity" // FailureAuthentication ist ein Anmeldefehler. FailureAuthentication FailureClass = "auth" // FailureRepository ist ein Fehler des Repositorys. FailureRepository FailureClass = "repository" // FailureSource ist ein Fehler der Quelle. FailureSource FailureClass = "source" // FailureNetwork ist ein Netzfehler. FailureNetwork FailureClass = "network" // FailureConfiguration ist ein Konfigurationsfehler. FailureConfiguration FailureClass = "configuration" // FailureSecurity ist ein sicherheitsrelevanter Fehler. FailureSecurity FailureClass = "security" ) // retryableFailureClasses sind die Klassen, bei denen ein neuer Versuch // sinnvoll ist. // // Bewusst kurz gehalten. Ein Anmeldefehler behebt sich nicht durch Warten; ein // Integritätsfehler wird durch Wiederholen nicht besser, sondern nur später // bemerkt. Und ein sicherheitsrelevanter Fehler gehört gemeldet, nicht // verschluckt. var retryableFailureClasses = map[FailureClass]bool{ FailureTransient: true, FailureNetwork: true, FailureRepository: true, FailureSource: true, } // IsRetryable meldet, ob ein neuer Versuch sinnvoll ist. func (failureClass FailureClass) IsRetryable() bool { return retryableFailureClasses[failureClass] } // RetryPolicy beschreibt das Wiederholungsverhalten. type RetryPolicy struct { // MaximumAttempts ist die Gesamtzahl der Versuche einschließlich des ersten. // // 1 bedeutet: keine Wiederholung. MaximumAttempts int `json:"maximum_attempts"` // InitialDelay ist die Wartezeit vor dem zweiten Versuch. InitialDelay time.Duration `json:"initial_delay"` // MaximumDelay begrenzt die Wartezeit nach oben. // // Ohne Deckel wüchse die Wartezeit exponentiell weiter: Beim achten Versuch // wären es aus 30 Sekunden bereits über eine Stunde, beim zehnten mehr als // vier. Ein nächtliches Sicherungsfenster wäre längst vorbei. MaximumDelay time.Duration `json:"maximum_delay"` // JitterFraction streut die Wartezeit zufällig. // // Nötig gegen den Gleichlauf: Fallen zwanzig Aufträge derselben Störung zum // Opfer, würden sie ohne Streuung alle gleichzeitig erneut anlaufen und die // eben erst erholte Gegenstelle sofort wieder überlasten. JitterFraction float64 `json:"jitter_fraction"` } // DefaultRetryPolicy ist die Voreinstellung. // // Drei Versuche, beginnend bei 30 Sekunden, gedeckelt bei 15 Minuten. Wer nach // drei Anläufen über eine halbe Stunde nicht durchkommt, hat ein Problem, das // sich nicht von selbst löst — dann ist eine Meldung nützlicher als ein // vierter Versuch. func DefaultRetryPolicy() RetryPolicy { return RetryPolicy{ MaximumAttempts: 3, InitialDelay: 30 * time.Second, MaximumDelay: 15 * time.Minute, JitterFraction: 0.2, } } // Validate prüft eine Wiederholungsstrategie. func (retryPolicy *RetryPolicy) Validate() error { if retryPolicy.MaximumAttempts < 1 { return errors.New("es muss mindestens ein versuch zugelassen sein") } if retryPolicy.MaximumAttempts > 10 { // Mehr als zehn Versuche verschleiern ein dauerhaftes Problem, statt es // zu melden. return fmt.Errorf("mehr als 10 versuche sind nicht zugelassen, angegeben waren %d", retryPolicy.MaximumAttempts) } if retryPolicy.InitialDelay < 0 || retryPolicy.MaximumDelay < 0 { return errors.New("die wartezeiten dürfen nicht negativ sein") } if retryPolicy.MaximumDelay > 0 && retryPolicy.MaximumDelay < retryPolicy.InitialDelay { return errors.New("die obere grenze der wartezeit liegt unter der anfänglichen wartezeit") } if retryPolicy.JitterFraction < 0 || retryPolicy.JitterFraction > 1 { return errors.New("die streuung muss zwischen 0 und 1 liegen") } return nil } // RetryDecision ist das Urteil über einen weiteren Versuch. type RetryDecision struct { // ShouldRetry meldet, ob ein weiterer Versuch stattfindet. ShouldRetry bool // Delay ist die Wartezeit bis dahin. Delay time.Duration // Reason erklärt das Urteil verständlich. // // Auch ein „nein" braucht eine Begründung: Der Betreiber muss unterscheiden // können zwischen „aufgegeben, weil die Versuche erschöpft sind" und // „nicht wiederholt, weil das Passwort falsch ist". Reason string } // Decide entscheidet über einen weiteren Versuch. // // attemptNumber ist die Nummer des soeben gescheiterten Versuchs, beginnend // bei 1. func (retryPolicy *RetryPolicy) Decide(attemptNumber int, failureClass FailureClass) RetryDecision { if !failureClass.IsRetryable() { return RetryDecision{ Reason: fmt.Sprintf("Ein Fehler der Art %q behebt sich nicht durch einen weiteren Versuch.", failureClass), } } if attemptNumber >= retryPolicy.MaximumAttempts { return RetryDecision{ Reason: fmt.Sprintf("Alle %d zugelassenen Versuche sind aufgebraucht.", retryPolicy.MaximumAttempts), } } return RetryDecision{ ShouldRetry: true, Delay: retryPolicy.delayForAttempt(attemptNumber), Reason: fmt.Sprintf("Versuch %d von %d scheiterte an einem Fehler der Art %q; ein weiterer Versuch folgt.", attemptNumber, retryPolicy.MaximumAttempts, failureClass), } } // delayForAttempt berechnet die Wartezeit nach einem Versuch. func (retryPolicy *RetryPolicy) delayForAttempt(attemptNumber int) time.Duration { if retryPolicy.InitialDelay <= 0 { return 0 } // Exponentiell: 30 s, 60 s, 120 s, 240 s … // // Gerechnet wird in Gleitkomma, weil ein Schieben um mehr als 62 Stellen // überliefe und aus einer langen Wartezeit eine negative machte. exponentialFactor := math.Pow(2, float64(attemptNumber-1)) computedDelay := time.Duration(float64(retryPolicy.InitialDelay) * exponentialFactor) if retryPolicy.MaximumDelay > 0 && computedDelay > retryPolicy.MaximumDelay { computedDelay = retryPolicy.MaximumDelay } if retryPolicy.JitterFraction <= 0 { return computedDelay } // Die Streuung wirkt nur nach unten. Nach oben verlängerte sie die // Wartezeit über die vereinbarte Grenze hinaus — der Deckel wäre dann // keiner. jitterRange := float64(computedDelay) * retryPolicy.JitterFraction appliedJitter := rand.Float64() * jitterRange return time.Duration(float64(computedDelay) - appliedJitter) } // ClassifyError ordnet einen Fehler anhand bekannter Merkmale ein. // // Die Einordnung ist eine Hilfe für Aufrufer, die keine eigene liefern. Sie // fällt im Zweifel auf FailurePermanent zurück — also auf „nicht wiederholen". // Der umgekehrte Standard wäre gefährlicher: Ein falsch als vorübergehend // eingeordneter Fehler ließe einen aussichtslosen Auftrag immer wieder // anlaufen und verdeckte dabei die eigentliche Ursache. func ClassifyError(occurredError error, knownClassifications map[error]FailureClass) FailureClass { if occurredError == nil { return "" } for knownError, failureClass := range knownClassifications { if errors.Is(occurredError, knownError) { return failureClass } } return FailurePermanent }