// Package retry is the M8 in-process retry helper used by // deliverd-fcm and deliverd-telegram. It implements // bounded exponential backoff with a per-attempt cap // and a total time budget so a single delivery can't // tie up a NATS consumer for minutes. // // Why in-process retry, not JetStream redelivery? // - We want explicit, config-driven backoff (SPEC §9: // 1s, 2s, 4s, … up to 10 attempts). JetStream's // redelivery timer is fixed at the consumer level // and doesn't express per-attempt exp backoff. // - We want a single deliveries table row per // attempt for the audit trail. JetStream redelivery // would re-process the same envelope; we'd have // to dedupe in the worker anyway. // - We want a hard cap (RetryBudget) so a stuck // downstream (e.g. fakefcmd with --fail-rate=1.0) // can never block a consumer for the full // 1+2+4+…+512 = 1023s the SPEC literally calls // for. In practice we ship defaults that // terminate in ~10s. package retry import ( "context" "errors" "time" ) // Config is the retry policy. All durations are wall // clock; the helper never sleeps past the ctx deadline. type Config struct { // MaxAttempts is the total number of attempts // (including the first). Default 10. After // MaxAttempts failures, Run returns the last // error from fn. MaxAttempts int // BaseDelay is the wait before the SECOND attempt; // it doubles each subsequent attempt. Default 100ms. BaseDelay time.Duration // MaxDelay caps the per-attempt wait. Default 2s. // With BaseDelay=100ms, MaxDelay=2s, MaxAttempts=10, // the per-attempt waits are 100, 200, 400, 800, 1600, // 2000, 2000, 2000, 2000 ms (9 waits), ~12s total. MaxDelay time.Duration // Budget is the wall-clock cap across all attempts. // Run returns ctx.DeadlineExceeded if the budget // is hit before MaxAttempts completes. Default 30s. Budget time.Duration } // Default returns the M8 spec defaults. func Default() Config { return Config{ MaxAttempts: 10, BaseDelay: 100 * time.Millisecond, MaxDelay: 2 * time.Second, Budget: 30 * time.Second, } } // PermanentError signals "don't retry this". The helper // returns it to the caller as-is after wrapping the // attempt count. Use it for HTTP 4xx (except 408/429), // parse errors, and other "retry won't help" cases. type PermanentError struct { Err error } func (e *PermanentError) Error() string { return e.Err.Error() } func (e *PermanentError) Unwrap() error { return e.Err } // IsPermanent reports whether err is a PermanentError. func IsPermanent(err error) bool { var p *PermanentError return errors.As(err, &p) } // Result is the outcome of Run. type Result struct { // Attempts is the number of fn invocations that ran // (1 = succeeded on first try, MaxAttempts = gave up). Attempts int // LastError is the error from the final attempt, or // nil on success. LastError error } // Run calls fn up to cfg.MaxAttempts times, sleeping // exp-backoff between failed attempts. It returns a // Result with the attempt count and last error. // // The sleep respects ctx cancellation. If the budget // is hit before all attempts complete, fn is not called // again and Result.LastError is the original fn error // (not ctx.DeadlineExceeded, so the caller can decide // whether to DLQ the message). // // PermanentError short-circuits the loop: if fn // returns &PermanentError{…}, Run returns immediately // with Attempts set to the current count and LastError // = the wrapped error. func Run(ctx context.Context, cfg Config, fn func(ctx context.Context, attempt int) error) Result { if cfg.MaxAttempts <= 0 { cfg.MaxAttempts = 10 } if cfg.BaseDelay <= 0 { cfg.BaseDelay = 100 * time.Millisecond } if cfg.MaxDelay <= 0 { cfg.MaxDelay = 2 * time.Second } if cfg.Budget <= 0 { cfg.Budget = 30 * time.Second } deadline := time.Now().Add(cfg.Budget) res := Result{} for attempt := 1; attempt <= cfg.MaxAttempts; attempt++ { res.Attempts = attempt err := fn(ctx, attempt) if err == nil { res.LastError = nil // clear any prior failure on success return res } res.LastError = err if IsPermanent(err) { return res } if attempt == cfg.MaxAttempts { break } // Compute this attempt's wait, then sleep // with ctx + budget awareness. wait := backoff(cfg.BaseDelay, cfg.MaxDelay, attempt) // If the budget would be exceeded by this wait, // bail out early with the last error. if time.Now().Add(wait).After(deadline) { break } t := time.NewTimer(wait) select { case <-ctx.Done(): t.Stop() return res case <-t.C: } } return res } // backoff returns the wait for the Nth retry. attempt=1 // is the FIRST attempt (no wait); attempt=2 is the wait // before the second attempt. So we use attempt-1 to // compute the exponent. func backoff(base, max time.Duration, attempt int) time.Duration { if attempt < 2 { return 0 } d := base for i := 2; i < attempt; i++ { d *= 2 if d > max { return max } } if d > max { return max } return d }