retry.go 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. // Package retry is the M8 in-process retry helper used by
  2. // deliverd-fcm and deliverd-telegram. It implements
  3. // bounded exponential backoff with a per-attempt cap
  4. // and a total time budget so a single delivery can't
  5. // tie up a NATS consumer for minutes.
  6. //
  7. // Why in-process retry, not JetStream redelivery?
  8. // - We want explicit, config-driven backoff (SPEC §9:
  9. // 1s, 2s, 4s, … up to 10 attempts). JetStream's
  10. // redelivery timer is fixed at the consumer level
  11. // and doesn't express per-attempt exp backoff.
  12. // - We want a single deliveries table row per
  13. // attempt for the audit trail. JetStream redelivery
  14. // would re-process the same envelope; we'd have
  15. // to dedupe in the worker anyway.
  16. // - We want a hard cap (RetryBudget) so a stuck
  17. // downstream (e.g. fakefcmd with --fail-rate=1.0)
  18. // can never block a consumer for the full
  19. // 1+2+4+…+512 = 1023s the SPEC literally calls
  20. // for. In practice we ship defaults that
  21. // terminate in ~10s.
  22. package retry
  23. import (
  24. "context"
  25. "errors"
  26. "time"
  27. )
  28. // Config is the retry policy. All durations are wall
  29. // clock; the helper never sleeps past the ctx deadline.
  30. type Config struct {
  31. // MaxAttempts is the total number of attempts
  32. // (including the first). Default 10. After
  33. // MaxAttempts failures, Run returns the last
  34. // error from fn.
  35. MaxAttempts int
  36. // BaseDelay is the wait before the SECOND attempt;
  37. // it doubles each subsequent attempt. Default 100ms.
  38. BaseDelay time.Duration
  39. // MaxDelay caps the per-attempt wait. Default 2s.
  40. // With BaseDelay=100ms, MaxDelay=2s, MaxAttempts=10,
  41. // the per-attempt waits are 100, 200, 400, 800, 1600,
  42. // 2000, 2000, 2000, 2000 ms (9 waits), ~12s total.
  43. MaxDelay time.Duration
  44. // Budget is the wall-clock cap across all attempts.
  45. // Run returns ctx.DeadlineExceeded if the budget
  46. // is hit before MaxAttempts completes. Default 30s.
  47. Budget time.Duration
  48. }
  49. // Default returns the M8 spec defaults.
  50. func Default() Config {
  51. return Config{
  52. MaxAttempts: 10,
  53. BaseDelay: 100 * time.Millisecond,
  54. MaxDelay: 2 * time.Second,
  55. Budget: 30 * time.Second,
  56. }
  57. }
  58. // PermanentError signals "don't retry this". The helper
  59. // returns it to the caller as-is after wrapping the
  60. // attempt count. Use it for HTTP 4xx (except 408/429),
  61. // parse errors, and other "retry won't help" cases.
  62. type PermanentError struct {
  63. Err error
  64. }
  65. func (e *PermanentError) Error() string { return e.Err.Error() }
  66. func (e *PermanentError) Unwrap() error { return e.Err }
  67. // IsPermanent reports whether err is a PermanentError.
  68. func IsPermanent(err error) bool {
  69. var p *PermanentError
  70. return errors.As(err, &p)
  71. }
  72. // Result is the outcome of Run.
  73. type Result struct {
  74. // Attempts is the number of fn invocations that ran
  75. // (1 = succeeded on first try, MaxAttempts = gave up).
  76. Attempts int
  77. // LastError is the error from the final attempt, or
  78. // nil on success.
  79. LastError error
  80. }
  81. // Run calls fn up to cfg.MaxAttempts times, sleeping
  82. // exp-backoff between failed attempts. It returns a
  83. // Result with the attempt count and last error.
  84. //
  85. // The sleep respects ctx cancellation. If the budget
  86. // is hit before all attempts complete, fn is not called
  87. // again and Result.LastError is the original fn error
  88. // (not ctx.DeadlineExceeded, so the caller can decide
  89. // whether to DLQ the message).
  90. //
  91. // PermanentError short-circuits the loop: if fn
  92. // returns &PermanentError{…}, Run returns immediately
  93. // with Attempts set to the current count and LastError
  94. // = the wrapped error.
  95. func Run(ctx context.Context, cfg Config, fn func(ctx context.Context, attempt int) error) Result {
  96. if cfg.MaxAttempts <= 0 {
  97. cfg.MaxAttempts = 10
  98. }
  99. if cfg.BaseDelay <= 0 {
  100. cfg.BaseDelay = 100 * time.Millisecond
  101. }
  102. if cfg.MaxDelay <= 0 {
  103. cfg.MaxDelay = 2 * time.Second
  104. }
  105. if cfg.Budget <= 0 {
  106. cfg.Budget = 30 * time.Second
  107. }
  108. deadline := time.Now().Add(cfg.Budget)
  109. res := Result{}
  110. for attempt := 1; attempt <= cfg.MaxAttempts; attempt++ {
  111. res.Attempts = attempt
  112. err := fn(ctx, attempt)
  113. if err == nil {
  114. res.LastError = nil // clear any prior failure on success
  115. return res
  116. }
  117. res.LastError = err
  118. if IsPermanent(err) {
  119. return res
  120. }
  121. if attempt == cfg.MaxAttempts {
  122. break
  123. }
  124. // Compute this attempt's wait, then sleep
  125. // with ctx + budget awareness.
  126. wait := backoff(cfg.BaseDelay, cfg.MaxDelay, attempt)
  127. // If the budget would be exceeded by this wait,
  128. // bail out early with the last error.
  129. if time.Now().Add(wait).After(deadline) {
  130. break
  131. }
  132. t := time.NewTimer(wait)
  133. select {
  134. case <-ctx.Done():
  135. t.Stop()
  136. return res
  137. case <-t.C:
  138. }
  139. }
  140. return res
  141. }
  142. // backoff returns the wait for the Nth retry. attempt=1
  143. // is the FIRST attempt (no wait); attempt=2 is the wait
  144. // before the second attempt. So we use attempt-1 to
  145. // compute the exponent.
  146. func backoff(base, max time.Duration, attempt int) time.Duration {
  147. if attempt < 2 {
  148. return 0
  149. }
  150. d := base
  151. for i := 2; i < attempt; i++ {
  152. d *= 2
  153. if d > max {
  154. return max
  155. }
  156. }
  157. if d > max {
  158. return max
  159. }
  160. return d
  161. }