453 lines
13 KiB
Go
453 lines
13 KiB
Go
package circuitbreaker
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/afex/hystrix-go/hystrix"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const success = "Success"
|
|
|
|
func TestCircuitBreaker_ExecuteSuccessSingle(t *testing.T) {
|
|
cb := NewCircuitBreaker(Config{
|
|
Timeout: 1000,
|
|
MaxConcurrentRequests: 100,
|
|
RequestVolumeThreshold: 10,
|
|
SleepWindow: 10,
|
|
ErrorPercentThreshold: 10,
|
|
})
|
|
|
|
expectedResult := success
|
|
circuitName := "SuccessSingle"
|
|
cmd := NewCommand(context.TODO(), []*Functor{
|
|
NewFunctor(func() ([]interface{}, error) {
|
|
return []any{expectedResult}, nil
|
|
}, circuitName)},
|
|
)
|
|
|
|
result := cb.Execute(cmd)
|
|
require.NoError(t, result.Error())
|
|
require.Equal(t, expectedResult, result.Result()[0].(string))
|
|
require.False(t, result.Cancelled())
|
|
}
|
|
|
|
func TestCircuitBreaker_ExecuteMultipleFallbacksFail(t *testing.T) {
|
|
cb := NewCircuitBreaker(Config{
|
|
Timeout: 10,
|
|
MaxConcurrentRequests: 100,
|
|
RequestVolumeThreshold: 10,
|
|
SleepWindow: 10,
|
|
ErrorPercentThreshold: 10,
|
|
})
|
|
|
|
circuitName := fmt.Sprintf("ExecuteMultipleFallbacksFail_%d", time.Now().Nanosecond()) // unique name to avoid conflicts with go tests `-count` option
|
|
errSecProvFailed := errors.New("provider 2 failed")
|
|
errThirdProvFailed := errors.New("provider 3 failed")
|
|
cmd := NewCommand(context.TODO(), []*Functor{
|
|
NewFunctor(func() ([]interface{}, error) {
|
|
time.Sleep(100 * time.Millisecond) // will cause hystrix: timeout
|
|
return []any{success}, nil
|
|
}, circuitName+"1"),
|
|
NewFunctor(func() ([]interface{}, error) {
|
|
return nil, errSecProvFailed
|
|
}, circuitName+"2"),
|
|
NewFunctor(func() ([]interface{}, error) {
|
|
return nil, errThirdProvFailed
|
|
}, circuitName+"3"),
|
|
})
|
|
|
|
result := cb.Execute(cmd)
|
|
require.Error(t, result.Error())
|
|
assert.True(t, errors.Is(result.Error(), hystrix.ErrTimeout))
|
|
assert.True(t, errors.Is(result.Error(), errSecProvFailed))
|
|
assert.True(t, errors.Is(result.Error(), errThirdProvFailed))
|
|
}
|
|
|
|
func TestCircuitBreaker_ExecuteMultipleFallbacksFailButLastSuccessStress(t *testing.T) {
|
|
cb := NewCircuitBreaker(Config{
|
|
Timeout: 10,
|
|
MaxConcurrentRequests: 100,
|
|
RequestVolumeThreshold: 10,
|
|
SleepWindow: 10,
|
|
ErrorPercentThreshold: 10,
|
|
})
|
|
|
|
expectedResult := success
|
|
circuitName := fmt.Sprintf("LastSuccessStress_%d", time.Now().Nanosecond()) // unique name to avoid conflicts with go tests `-count` option
|
|
|
|
// These are executed sequentially, but I had an issue with the test failing
|
|
// because of the open circuit
|
|
for i := 0; i < 1000; i++ {
|
|
cmd := NewCommand(context.TODO(), []*Functor{
|
|
NewFunctor(func() ([]interface{}, error) {
|
|
return nil, errors.New("provider 1 failed")
|
|
}, circuitName+"1"),
|
|
NewFunctor(func() ([]interface{}, error) {
|
|
return nil, errors.New("provider 2 failed")
|
|
}, circuitName+"2"),
|
|
NewFunctor(func() ([]interface{}, error) {
|
|
return []any{expectedResult}, nil
|
|
}, circuitName+"3"),
|
|
},
|
|
)
|
|
|
|
result := cb.Execute(cmd)
|
|
require.NoError(t, result.Error())
|
|
require.Equal(t, expectedResult, result.Result()[0].(string))
|
|
}
|
|
}
|
|
|
|
func TestCircuitBreaker_ExecuteSwitchToWorkingProviderOnVolumeThresholdReached(t *testing.T) {
|
|
cb := NewCircuitBreaker(Config{
|
|
RequestVolumeThreshold: 10,
|
|
})
|
|
|
|
expectedResult := success
|
|
circuitName := fmt.Sprintf("SwitchToWorkingProviderOnVolumeThresholdReached_%d", time.Now().Nanosecond()) // unique name to avoid conflicts with go tests `-count` option
|
|
|
|
prov1Called := 0
|
|
prov2Called := 0
|
|
prov3Called := 0
|
|
// These are executed sequentially
|
|
for i := 0; i < 20; i++ {
|
|
cmd := NewCommand(context.TODO(), []*Functor{
|
|
NewFunctor(func() ([]interface{}, error) {
|
|
prov1Called++
|
|
return nil, errors.New("provider 1 failed")
|
|
}, circuitName+"1"),
|
|
NewFunctor(func() ([]interface{}, error) {
|
|
prov2Called++
|
|
return nil, errors.New("provider 2 failed")
|
|
}, circuitName+"2"),
|
|
NewFunctor(func() ([]interface{}, error) {
|
|
prov3Called++
|
|
return []any{expectedResult}, nil
|
|
}, circuitName+"3"),
|
|
})
|
|
|
|
result := cb.Execute(cmd)
|
|
require.NoError(t, result.Error())
|
|
require.Equal(t, expectedResult, result.Result()[0].(string))
|
|
}
|
|
|
|
assert.Equal(t, 10, prov1Called)
|
|
assert.Equal(t, 10, prov2Called)
|
|
assert.Equal(t, 20, prov3Called)
|
|
}
|
|
|
|
func TestCircuitBreaker_ExecuteHealthCheckOnWindowTimeout(t *testing.T) {
|
|
sleepWindow := 10
|
|
cb := NewCircuitBreaker(Config{
|
|
RequestVolumeThreshold: 1, // 1 failed request is enough to trip the circuit
|
|
SleepWindow: sleepWindow,
|
|
ErrorPercentThreshold: 1, // Trip on first error
|
|
})
|
|
|
|
expectedResult := success
|
|
circuitName := fmt.Sprintf("SwitchToWorkingProviderOnWindowTimeout_%d", time.Now().Nanosecond()) // unique name to avoid conflicts with go tests `-count` option
|
|
|
|
prov1Called := 0
|
|
prov2Called := 0
|
|
// These are executed sequentially
|
|
for i := 0; i < 10; i++ {
|
|
cmd := NewCommand(context.TODO(), []*Functor{
|
|
NewFunctor(func() ([]interface{}, error) {
|
|
prov1Called++
|
|
return nil, errors.New("provider 1 failed")
|
|
}, circuitName+"1"),
|
|
NewFunctor(func() ([]interface{}, error) {
|
|
prov2Called++
|
|
return []any{expectedResult}, nil
|
|
}, circuitName+"2"),
|
|
})
|
|
|
|
result := cb.Execute(cmd)
|
|
require.NoError(t, result.Error())
|
|
require.Equal(t, expectedResult, result.Result()[0].(string))
|
|
}
|
|
|
|
assert.Less(t, prov1Called, 3) // most of the time only 1 call is made, but occasionally 2 can happen
|
|
assert.Equal(t, 10, prov2Called)
|
|
assert.True(t, CircuitExists(circuitName+"1"))
|
|
assert.True(t, IsCircuitOpen(circuitName+"1"))
|
|
|
|
// Wait for the sleep window to expire
|
|
time.Sleep(time.Duration(sleepWindow+1) * time.Millisecond)
|
|
cmd := NewCommand(context.TODO(), []*Functor{
|
|
NewFunctor(func() ([]interface{}, error) {
|
|
prov1Called++
|
|
return []any{expectedResult}, nil // Now it is working
|
|
}, circuitName+"1"),
|
|
NewFunctor(func() ([]interface{}, error) {
|
|
prov2Called++
|
|
return []any{expectedResult}, nil
|
|
}, circuitName+"2"),
|
|
})
|
|
result := cb.Execute(cmd)
|
|
require.NoError(t, result.Error())
|
|
|
|
assert.Less(t, prov1Called, 4) // most of the time only 2 calls are made, but occasionally 3 can happen
|
|
assert.Equal(t, 10, prov2Called)
|
|
}
|
|
|
|
func TestCircuitBreaker_CommandCancel(t *testing.T) {
|
|
cb := NewCircuitBreaker(Config{})
|
|
|
|
circuitName := fmt.Sprintf("CommandCancel_%d", time.Now().Nanosecond()) // unique name to avoid conflicts with go tests `-count` option
|
|
|
|
prov1Called := 0
|
|
prov2Called := 0
|
|
|
|
var ctx context.Context
|
|
expectedErr := errors.New("provider 1 failed")
|
|
|
|
cmd := NewCommand(ctx, nil)
|
|
cmd.Add(NewFunctor(func() ([]interface{}, error) {
|
|
prov1Called++
|
|
cmd.Cancel()
|
|
return nil, expectedErr
|
|
}, circuitName+"1"))
|
|
cmd.Add(NewFunctor(func() ([]interface{}, error) {
|
|
prov2Called++
|
|
return nil, errors.New("provider 2 failed")
|
|
}, circuitName+"2"))
|
|
|
|
result := cb.Execute(cmd)
|
|
require.True(t, errors.Is(result.Error(), expectedErr))
|
|
require.True(t, result.Cancelled())
|
|
|
|
assert.Equal(t, 1, prov1Called)
|
|
assert.Equal(t, 0, prov2Called)
|
|
|
|
}
|
|
|
|
func TestCircuitBreaker_EmptyOrNilCommand(t *testing.T) {
|
|
cb := NewCircuitBreaker(Config{})
|
|
cmd := NewCommand(context.TODO(), nil)
|
|
result := cb.Execute(cmd)
|
|
require.Error(t, result.Error())
|
|
result = cb.Execute(nil)
|
|
require.Error(t, result.Error())
|
|
}
|
|
|
|
func TestCircuitBreaker_CircuitExistsAndClosed(t *testing.T) {
|
|
timestamp := time.Now().Nanosecond()
|
|
nonExCircuit := fmt.Sprintf("nonexistent_%d", timestamp) // unique name to avoid conflicts with go tests `-count` option
|
|
require.False(t, CircuitExists(nonExCircuit))
|
|
|
|
cb := NewCircuitBreaker(Config{})
|
|
cmd := NewCommand(context.TODO(), nil)
|
|
existCircuit := fmt.Sprintf("existing_%d", timestamp) // unique name to avoid conflicts with go tests `-count` option
|
|
// We add it twice as otherwise it's only used for the fallback
|
|
cmd.Add(NewFunctor(func() ([]interface{}, error) {
|
|
return nil, nil
|
|
}, existCircuit))
|
|
|
|
cmd.Add(NewFunctor(func() ([]interface{}, error) {
|
|
return nil, nil
|
|
}, existCircuit))
|
|
_ = cb.Execute(cmd)
|
|
require.True(t, CircuitExists(existCircuit))
|
|
require.False(t, IsCircuitOpen(existCircuit))
|
|
}
|
|
|
|
func TestCircuitBreaker_Fallback(t *testing.T) {
|
|
cb := NewCircuitBreaker(Config{
|
|
RequestVolumeThreshold: 1, // 1 failed request is enough to trip the circuit
|
|
SleepWindow: 50000,
|
|
ErrorPercentThreshold: 1, // Trip on first error
|
|
})
|
|
|
|
circuitName := fmt.Sprintf("Fallback_%d", time.Now().Nanosecond()) // unique name to avoid conflicts with go tests `-count` option
|
|
|
|
prov1Called := 0
|
|
|
|
var ctx context.Context
|
|
expectedErr := errors.New("provider 1 failed")
|
|
|
|
// we start with 2, and we open the first
|
|
for {
|
|
cmd := NewCommand(ctx, nil)
|
|
cmd.Add(NewFunctor(func() ([]interface{}, error) {
|
|
return nil, expectedErr
|
|
}, circuitName+"1"))
|
|
cmd.Add(NewFunctor(func() ([]interface{}, error) {
|
|
return nil, errors.New("provider 2 failed")
|
|
}, circuitName+"2"))
|
|
|
|
result := cb.Execute(cmd)
|
|
require.NotNil(t, result.Error())
|
|
if IsCircuitOpen(circuitName + "1") {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Make sure circuit is open
|
|
require.True(t, CircuitExists(circuitName+"1"))
|
|
require.True(t, IsCircuitOpen(circuitName+"1"))
|
|
|
|
// we send a single request, it should hit the provider, at that's a fallback
|
|
cmd := NewCommand(ctx, nil)
|
|
cmd.Add(NewFunctor(func() ([]interface{}, error) {
|
|
prov1Called++
|
|
return nil, expectedErr
|
|
}, circuitName+"1"))
|
|
|
|
result := cb.Execute(cmd)
|
|
require.True(t, errors.Is(result.Error(), expectedErr))
|
|
|
|
assert.Equal(t, 1, prov1Called)
|
|
}
|
|
|
|
func TestCircuitBreaker_SuccessCallStatus(t *testing.T) {
|
|
cb := NewCircuitBreaker(Config{})
|
|
|
|
functor := NewFunctor(func() ([]any, error) {
|
|
return []any{"success"}, nil
|
|
}, "successCircuit")
|
|
|
|
cmd := NewCommand(context.Background(), []*Functor{functor})
|
|
|
|
result := cb.Execute(cmd)
|
|
|
|
require.Nil(t, result.Error())
|
|
require.False(t, result.Cancelled())
|
|
assert.Len(t, result.Result(), 1)
|
|
require.Equal(t, "success", result.Result()[0])
|
|
assert.Len(t, result.FunctorCallStatuses(), 1)
|
|
|
|
status := result.FunctorCallStatuses()[0]
|
|
if status.Name != "successCircuit" {
|
|
t.Errorf("Expected functor name to be 'successCircuit', got %s", status.Name)
|
|
}
|
|
if status.Err != nil {
|
|
t.Errorf("Expected no error in functor status, got %v", status.Err)
|
|
}
|
|
}
|
|
|
|
func TestCircuitBreaker_ErrorCallStatus(t *testing.T) {
|
|
cb := NewCircuitBreaker(Config{})
|
|
|
|
expectedError := errors.New("functor error")
|
|
functor := NewFunctor(func() ([]any, error) {
|
|
return nil, expectedError
|
|
}, "errorCircuit")
|
|
|
|
cmd := NewCommand(context.Background(), []*Functor{functor})
|
|
|
|
result := cb.Execute(cmd)
|
|
|
|
require.NotNil(t, result.Error())
|
|
require.True(t, errors.Is(result.Error(), expectedError))
|
|
|
|
assert.Len(t, result.Result(), 0)
|
|
assert.Len(t, result.FunctorCallStatuses(), 1)
|
|
|
|
status := result.FunctorCallStatuses()[0]
|
|
if status.Name != "errorCircuit" {
|
|
t.Errorf("Expected functor name to be 'errorCircuit', got %s", status.Name)
|
|
}
|
|
if !errors.Is(status.Err, expectedError) {
|
|
t.Errorf("Expected functor error to be '%v', got '%v'", expectedError, status.Err)
|
|
}
|
|
}
|
|
|
|
func TestCircuitBreaker_CancelledResult(t *testing.T) {
|
|
cb := NewCircuitBreaker(Config{Timeout: 1000})
|
|
|
|
functor := NewFunctor(func() ([]any, error) {
|
|
time.Sleep(500 * time.Millisecond)
|
|
return []any{"should not be returned"}, nil
|
|
}, "cancelCircuit")
|
|
|
|
cmd := NewCommand(context.Background(), []*Functor{functor})
|
|
cmd.Cancel()
|
|
|
|
result := cb.Execute(cmd)
|
|
|
|
assert.True(t, result.Cancelled())
|
|
require.Nil(t, result.Error())
|
|
require.Empty(t, result.Result())
|
|
require.Empty(t, result.FunctorCallStatuses())
|
|
}
|
|
|
|
func TestCircuitBreaker_MultipleFunctorsResult(t *testing.T) {
|
|
cb := NewCircuitBreaker(Config{
|
|
Timeout: 1000,
|
|
MaxConcurrentRequests: 100,
|
|
RequestVolumeThreshold: 20,
|
|
SleepWindow: 5000,
|
|
ErrorPercentThreshold: 50,
|
|
})
|
|
|
|
functor1 := NewFunctor(func() ([]any, error) {
|
|
return nil, errors.New("functor1 error")
|
|
}, "circuit1")
|
|
|
|
functor2 := NewFunctor(func() ([]any, error) {
|
|
return []any{"success from functor2"}, nil
|
|
}, "circuit2")
|
|
|
|
cmd := NewCommand(context.Background(), []*Functor{functor1, functor2})
|
|
|
|
result := cb.Execute(cmd)
|
|
|
|
require.Nil(t, result.Error())
|
|
|
|
require.Len(t, result.Result(), 1)
|
|
require.Equal(t, result.Result()[0], "success from functor2")
|
|
statuses := result.FunctorCallStatuses()
|
|
require.Len(t, statuses, 2)
|
|
|
|
require.Equal(t, statuses[0].Name, "circuit1")
|
|
require.NotNil(t, statuses[0].Err)
|
|
|
|
require.Equal(t, statuses[1].Name, "circuit2")
|
|
require.Nil(t, statuses[1].Err)
|
|
}
|
|
|
|
func TestCircuitBreaker_LastFunctorDirectExecution(t *testing.T) {
|
|
cb := NewCircuitBreaker(Config{
|
|
Timeout: 10, // short timeout to open circuit
|
|
MaxConcurrentRequests: 1,
|
|
RequestVolumeThreshold: 1,
|
|
SleepWindow: 1000,
|
|
ErrorPercentThreshold: 1,
|
|
})
|
|
|
|
failingFunctor := NewFunctor(func() ([]any, error) {
|
|
time.Sleep(20 * time.Millisecond)
|
|
return nil, errors.New("should time out")
|
|
}, "circuitName")
|
|
|
|
successFunctor := NewFunctor(func() ([]any, error) {
|
|
return []any{"success without circuit"}, nil
|
|
}, "circuitName")
|
|
|
|
cmd := NewCommand(context.Background(), []*Functor{failingFunctor, successFunctor})
|
|
|
|
require.False(t, IsCircuitOpen("circuitName"))
|
|
result := cb.Execute(cmd)
|
|
|
|
require.True(t, CircuitExists("circuitName"))
|
|
require.Nil(t, result.Error())
|
|
|
|
require.Len(t, result.Result(), 1)
|
|
require.Equal(t, result.Result()[0], "success without circuit")
|
|
|
|
statuses := result.FunctorCallStatuses()
|
|
require.Len(t, statuses, 2)
|
|
|
|
require.Equal(t, statuses[0].Name, "circuitName")
|
|
require.NotNil(t, statuses[0].Err)
|
|
|
|
require.Equal(t, statuses[1].Name, "circuitName")
|
|
require.Nil(t, statuses[1].Err)
|
|
}
|