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

425 lines
11 KiB
Go

package middleware_test
import (
"context"
"testing"
"time"
"github.com/cenkalti/backoff/v5"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/ThreeDotsLabs/watermill/message/router/middleware"
)
func TestRetry_retry(t *testing.T) {
retry := middleware.Retry{
MaxRetries: 1,
}
runCount := 0
producedMessages := message.Messages{message.NewMessage("2", nil)}
h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) {
runCount++
if runCount == 0 {
return nil, errors.New("foo")
}
return producedMessages, nil
})
handlerMessages, handlerErr := h(message.NewMessage("1", nil))
assert.Equal(t, 1, runCount)
assert.EqualValues(t, producedMessages, handlerMessages)
assert.NoError(t, handlerErr)
}
func TestRetry_max_retries(t *testing.T) {
retry := middleware.Retry{
MaxRetries: 1,
Logger: watermill.NewStdLogger(true, true),
}
runCount := 0
h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) {
runCount++
return nil, errors.New("foo")
})
_, err := h(message.NewMessage("1", nil))
assert.Equal(t, 2, runCount)
assert.EqualError(t, err, "foo")
}
func TestRetry_retry_hook(t *testing.T) {
var retriesFromHook []int
retry := middleware.Retry{
MaxRetries: 2,
OnRetryHook: func(retryNum int, delay time.Duration) {
retriesFromHook = append(retriesFromHook, retryNum)
},
}
h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) {
return nil, errors.New("foo")
})
_, _ = h(message.NewMessage("1", nil))
assert.EqualValues(t, []int{1, 2}, retriesFromHook)
}
func TestRetry_retries_exhausted_hook(t *testing.T) {
var exhaustedParams middleware.RetriesExhaustedParams
handlerErr := errors.New("foo")
retry := middleware.Retry{
MaxRetries: 2,
OnRetriesExhausted: func(params middleware.RetriesExhaustedParams) {
exhaustedParams = params
},
}
h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) {
return nil, handlerErr
})
_, _ = h(message.NewMessage("1", nil))
assert.ErrorIs(t, exhaustedParams.Err, handlerErr)
// With MaxRetries=2, there are 3 total attempts (1 initial + 2 retries)
// retryNum is incremented after each attempt, so it's 3 when exhausted
assert.Equal(t, 3, exhaustedParams.RetryNum)
}
func TestRetry_retries_exhausted_hook_not_called_on_success(t *testing.T) {
var hookCalled bool
retry := middleware.Retry{
MaxRetries: 2,
OnRetriesExhausted: func(params middleware.RetriesExhaustedParams) {
hookCalled = true
},
}
h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) {
return nil, nil
})
_, _ = h(message.NewMessage("1", nil))
assert.False(t, hookCalled, "OnRetriesExhausted should not be called on success")
}
func TestRetry_retries_exhausted_hook_not_called_when_should_retry_returns_false(t *testing.T) {
var hookCalled bool
retry := middleware.Retry{
MaxRetries: 5,
ShouldRetry: func(params middleware.RetryParams) bool {
return false
},
OnRetriesExhausted: func(params middleware.RetriesExhaustedParams) {
hookCalled = true
},
}
h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) {
return nil, errors.New("foo")
})
_, _ = h(message.NewMessage("1", nil))
assert.False(t, hookCalled, "OnRetriesExhausted should not be called when ShouldRetry returns false")
}
func TestRetry_logger(t *testing.T) {
logger := watermill.NewCaptureLogger()
retry := middleware.Retry{
MaxRetries: 2,
Logger: logger,
}
handlerErr := errors.New("foo")
h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) {
return nil, handlerErr
})
_, _ = h(message.NewMessage("1", nil))
assert.True(t, logger.HasError(handlerErr))
}
func TestRetry_ctx_cancel(t *testing.T) {
retry := middleware.Retry{
InitialInterval: time.Minute,
}
producedMessages := message.Messages{message.NewMessage("2", nil)}
h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) {
return producedMessages, errors.New("err")
})
msg := message.NewMessage("1", nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
msg.SetContext(ctx)
done := make(chan struct{})
type handlerResult struct {
Messages message.Messages
Err error
}
handlerResultCh := make(chan handlerResult, 1)
go func() {
messages, err := h(msg)
handlerResultCh <- handlerResult{messages, err}
close(done)
}()
select {
case <-done:
t.Fatal("handler should be still during retrying")
default:
// ok
}
cancel()
select {
case <-done:
// ok
case <-time.After(time.Second):
t.Fatal("ctx cancelled, retrying should be done")
}
handlerResultReceived := <-handlerResultCh
assert.Error(t, handlerResultReceived.Err)
// produced messages are nil since ctx is canceling the operation in the middle
assert.Nil(t, handlerResultReceived.Messages)
}
func TestRetry_max_elapsed(t *testing.T) {
maxRetries := 10
sleepInHandler := time.Millisecond * 20
retry := middleware.Retry{
MaxElapsedTime: time.Millisecond * 10,
MaxRetries: maxRetries,
}
runTimeWithoutMaxElapsedTime := sleepInHandler * time.Duration(maxRetries)
h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) {
time.Sleep(sleepInHandler)
return nil, errors.New("foo")
})
startTime := time.Now()
_, _ = h(message.NewMessage("1", nil))
timeElapsed := time.Since(startTime)
assert.True(
t,
timeElapsed < runTimeWithoutMaxElapsedTime,
"handler should run less than %s, time elapsed: %s",
runTimeWithoutMaxElapsedTime,
timeElapsed,
)
}
func TestRetry_max_interval(t *testing.T) {
t.Parallel()
maxRetries := 10
backoffTimes := make([]time.Duration, maxRetries)
maxInterval := time.Millisecond * 30
retry := middleware.Retry{
MaxRetries: maxRetries,
InitialInterval: time.Millisecond * 10,
MaxInterval: maxInterval,
Multiplier: 2.0,
RandomizationFactor: 0,
OnRetryHook: func(retryNum int, delay time.Duration) {
backoffTimes[retryNum-1] = delay
},
}
h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) {
return nil, errors.New("bar")
})
_, _ = h(message.NewMessage("2", nil))
for i, delay := range backoffTimes {
assert.True(t, delay <= maxInterval, "wait interval %d (%s) exceeds maxInterval (%s)", i, delay, maxInterval)
}
}
func TestRetry_first_run_no_delay(t *testing.T) {
t.Parallel()
initialInterval := time.Millisecond * 100
retry := middleware.Retry{
MaxElapsedTime: initialInterval * 2,
MaxRetries: 10,
InitialInterval: initialInterval,
}
h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) {
return nil, nil
})
start := time.Now()
_, _ = h(message.NewMessage("1", nil))
elapsed := time.Since(start)
assert.True(t, elapsed < initialInterval, "first retry should not wait, elapsed: %s", elapsed)
}
func TestRetry_should_retry(t *testing.T) {
errToSkip := errors.New("this should be skipped")
retry := middleware.Retry{
MaxRetries: 5,
ShouldRetry: func(params middleware.RetryParams) bool {
return !errors.Is(params.Err, errToSkip)
},
}
runCount := 0
h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) {
runCount++
return nil, errToSkip
})
handlerMessages, handlerErr := h(message.NewMessage("1", nil))
assert.Equal(t, 1, runCount)
assert.Nil(t, handlerMessages)
assert.ErrorIs(t, handlerErr, errToSkip)
// to not create any dependency on backoff package
var backoffPermanentError *backoff.PermanentError
assert.False(t, errors.As(handlerErr, &backoffPermanentError))
}
// Test that the ShouldRetry function is called on each retry attempt.
// Under the hood, the second attempt goes over a bit different code path,
// so we want to make sure that it works consistently.
func TestRetry_should_retry_second_attempt(t *testing.T) {
errToSkip := errors.New("this should be skipped")
retry := middleware.Retry{
MaxRetries: 5,
ShouldRetry: func(params middleware.RetryParams) bool {
return !errors.Is(params.Err, errToSkip)
},
}
runCount := 0
h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) {
runCount++
if runCount == 1 {
return nil, errors.New("some other error")
} else {
return nil, errToSkip
}
})
handlerMessages, handlerErr := h(message.NewMessage("1", nil))
assert.Equal(t, 2, runCount)
assert.Nil(t, handlerMessages)
assert.ErrorIs(t, handlerErr, errToSkip)
// to not create any dependency on backoff package
var backoffPermanentError *backoff.PermanentError
assert.False(t, errors.As(handlerErr, &backoffPermanentError))
}
// Test that if backoff.Permanent error stops the retries.
// For sake of potential future regressions (see Hyrum's Law).
func TestRetry_backoff_backoff_permanent(t *testing.T) {
errToSkip := errors.New("this should be skipped")
retry := middleware.Retry{
MaxRetries: 5,
}
runCount := 0
h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) {
runCount++
return nil, backoff.Permanent(errToSkip)
})
handlerMessages, handlerErr := h(message.NewMessage("1", nil))
assert.Equal(t, 1, runCount)
assert.Nil(t, handlerMessages)
assert.ErrorIs(t, handlerErr, errToSkip)
// to not create any dependency on backoff package
var backoffPermanentError *backoff.PermanentError
assert.False(t, errors.As(handlerErr, &backoffPermanentError))
}
// TestRetry_uncancel_context checks scenario when context is canceled,
// and we want to retry. More context: https://github.com/ThreeDotsLabs/watermill/issues/467
//
// Message is passed as a pointer, so underlying middlewares or handlers can cancel its context.
// In this scenario, retrying is pointless because context is already canceled, so any operation
// that relies on context will fail immediately.
func TestRetry_uncancel_context(t *testing.T) {
retry := middleware.Retry{
MaxRetries: 5,
ResetContextOnRetry: true,
}
num := 0
var ctxCancelMiddleware = func(h message.HandlerFunc) message.HandlerFunc {
return func(msg *message.Message) ([]*message.Message, error) {
num++
ctx, cancel := context.WithCancel(msg.Context())
defer func() {
cancel()
}()
if num == 1 {
t.Log("Run 1: canceling context")
cancel()
} else {
t.Logf("Run %d: context is not canceled", num)
}
msg.SetContext(ctx)
return h(msg)
}
}
h := func(msg *message.Message) (messages []*message.Message, e error) {
return nil, msg.Context().Err()
}
h = ctxCancelMiddleware(h)
h = retry.Middleware(h)
_, handlerErr := h(message.NewMessage("1", nil))
assert.NoError(t, handlerErr)
}