| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170 |
- // 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
- }
|