Files
2026-09-15 21:27:46 +05:00

173 lines
6.2 KiB
Go

package middleware
import (
"time"
"github.com/cenkalti/backoff/v5"
"github.com/pkg/errors"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
)
// RetryParams holds the parameters for a retry attempt
type RetryParams struct {
// Err is the error that caused the retry attempt.
Err error
// RetryNum is the number of the retry attempt, starting from 1.
RetryNum int
// Delay is the delay for the next retry attempt.
Delay time.Duration
}
// RetriesExhaustedParams holds the parameters passed to OnRetriesExhausted.
type RetriesExhaustedParams struct {
// Err is the last error returned by the handler before retries were exhausted.
Err error
// RetryNum is the total number of attempts performed (1 initial + MaxRetries retries).
// For MaxRetries=N this will equal N+1.
RetryNum int
}
// Retry provides a middleware that retries the handler if errors are returned.
// The retry behaviour is configurable, with exponential backoff and maximum elapsed time.
type Retry struct {
// MaxRetries is maximum number of times a retry will be attempted.
MaxRetries int
// InitialInterval is the first interval between retries. Subsequent intervals will be scaled by Multiplier.
InitialInterval time.Duration
// MaxInterval sets the limit for the exponential backoff of retries. The interval will not be increased beyond MaxInterval.
MaxInterval time.Duration
// Multiplier is the factor by which the waiting interval will be multiplied between retries.
Multiplier float64
// MaxElapsedTime sets the time limit of how long retries will be attempted. Disabled if 0.
MaxElapsedTime time.Duration
// RandomizationFactor randomizes the spread of the backoff times within the interval of:
// [currentInterval * (1 - RandomizationFactor), currentInterval * (1 + RandomizationFactor)].
RandomizationFactor float64
// OnRetryHook is an optional function that will be executed on each retry attempt.
// The number of the current retry is passed as retryNum,
OnRetryHook func(retryNum int, delay time.Duration)
// OnRetriesExhausted is an optional function that will be executed when all retries are exhausted.
// This is called when MaxRetries is reached and the handler still returns an error.
// It is NOT called when ShouldRetry returns false (that path returns a permanent error and exits earlier).
OnRetriesExhausted func(params RetriesExhaustedParams)
// ShouldRetry is an optional function that will be executed before each retry attempt.
// If ShouldRetry returns false, the retry will not be attempted.
ShouldRetry func(params RetryParams) bool
// ResetContextOnRetry indicates whether the message context should be reset on each retry attempt.
// See more: https://github.com/ThreeDotsLabs/watermill/issues/467
//
// This is not enabled by default to keep backward compatibility
// (in theory, someone may want to preserve context values between retries).
ResetContextOnRetry bool
Logger watermill.LoggerAdapter
}
// Middleware returns the Retry middleware.
func (r Retry) Middleware(h message.HandlerFunc) message.HandlerFunc {
return func(msg *message.Message) ([]*message.Message, error) {
originalCtx := msg.Context()
retryNum := 0
// stoppedByPermanent is set when ShouldRetry returns false, so we know
// to skip OnRetriesExhausted — retries weren't actually exhausted, they
// were short-circuited. backoff/v5 strips the *PermanentError wrapper
// inside Retry, so we can't detect this from the returned error alone.
stoppedByPermanent := false
expBackoff := backoff.NewExponentialBackOff()
expBackoff.InitialInterval = r.InitialInterval
expBackoff.MaxInterval = r.MaxInterval
expBackoff.Multiplier = r.Multiplier
expBackoff.RandomizationFactor = r.RandomizationFactor
// MaxRetries + 1 because the first attempt is not a retry
retryBackoff := backoff.WithMaxTries(uint(r.MaxRetries + 1))
maxElapsedBackoff := backoff.WithMaxElapsedTime(r.MaxElapsedTime)
// notification is called on a failed retry attempt.
notification := func(err error, delay time.Duration) {
if r.Logger != nil {
r.Logger.Error("Error occurred, retrying", err, watermill.LogFields{
"retry_no": retryNum,
"max_retries": r.MaxRetries,
"wait_time": delay,
})
}
}
// operation: the function that will be retried.
operation := func() ([]*message.Message, error) {
select {
case <-originalCtx.Done():
return nil, originalCtx.Err()
default:
if r.ResetContextOnRetry {
// message is passed as a pointer, so its context can be canceled
// by the previous attempts -> it will break retries, because any
// underlying logic that relies on the context will fail.
// see more: https://github.com/ThreeDotsLabs/watermill/issues/467
//
// to avoid this, we need to reset the original context on each attempt
// we may lose context value that was set by the previous attempt
msg.SetContext(originalCtx)
}
producedMessages, err := h(msg)
if err == nil {
return producedMessages, nil
}
if r.ShouldRetry != nil && !r.ShouldRetry(RetryParams{
RetryNum: retryNum,
Err: err,
Delay: expBackoff.NextBackOff(),
}) {
// backoff.Permanent will stop the retry attempts
stoppedByPermanent = true
return producedMessages, backoff.Permanent(err)
}
if r.OnRetryHook != nil && retryNum > 0 {
// call RetryHook function on each retry attempt.
r.OnRetryHook(retryNum, expBackoff.NextBackOff())
}
retryNum++
return producedMessages, err
}
}
producedMessages, retryErr := backoff.Retry(
originalCtx,
operation,
backoff.WithBackOff(expBackoff),
retryBackoff,
maxElapsedBackoff,
backoff.WithNotify(notification),
)
var backoffPermanentError *backoff.PermanentError
if errors.As(retryErr, &backoffPermanentError) {
// just in case, we don't want to expose backoff.PermanentError to the outside world
return producedMessages, backoffPermanentError.Unwrap()
}
if retryErr != nil {
if r.OnRetriesExhausted != nil && !stoppedByPermanent {
r.OnRetriesExhausted(RetriesExhaustedParams{
Err: retryErr,
RetryNum: retryNum,
})
}
return producedMessages, retryErr
}
return producedMessages, nil
}
}