2024-03-11 11:09:50 +00:00
package circuitbreaker
import (
2024-07-01 15:46:57 +00:00
"context"
2024-03-11 11:09:50 +00:00
"errors"
2024-07-01 15:46:57 +00:00
"fmt"
2024-03-11 11:09:50 +00:00
"testing"
"time"
2024-07-01 15:46:57 +00:00
"github.com/afex/hystrix-go/hystrix"
"github.com/stretchr/testify/assert"
2024-03-11 11:09:50 +00:00
"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
2024-07-01 15:46:57 +00:00
circuitName := "SuccessSingle"
cmd := NewCommand ( context . TODO ( ) , [ ] * Functor {
NewFunctor ( func ( ) ( [ ] interface { } , error ) {
return [ ] any { expectedResult } , nil
} , circuitName ) } ,
)
2024-03-11 11:09:50 +00:00
result := cb . Execute ( cmd )
require . NoError ( t , result . Error ( ) )
require . Equal ( t , expectedResult , result . Result ( ) [ 0 ] . ( string ) )
2024-10-15 15:59:17 +00:00
require . False ( t , result . Cancelled ( ) )
2024-03-11 11:09:50 +00:00
}
func TestCircuitBreaker_ExecuteMultipleFallbacksFail ( t * testing . T ) {
cb := NewCircuitBreaker ( Config {
Timeout : 10 ,
MaxConcurrentRequests : 100 ,
RequestVolumeThreshold : 10 ,
SleepWindow : 10 ,
ErrorPercentThreshold : 10 ,
} )
2024-07-29 17:07:43 +00:00
circuitName := fmt . Sprintf ( "ExecuteMultipleFallbacksFail_%d" , time . Now ( ) . Nanosecond ( ) ) // unique name to avoid conflicts with go tests `-count` option
2024-07-01 15:46:57 +00:00
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
2024-07-29 17:07:43 +00:00
} , circuitName + "1" ) ,
2024-07-01 15:46:57 +00:00
NewFunctor ( func ( ) ( [ ] interface { } , error ) {
return nil , errSecProvFailed
2024-07-29 17:07:43 +00:00
} , circuitName + "2" ) ,
2024-07-01 15:46:57 +00:00
NewFunctor ( func ( ) ( [ ] interface { } , error ) {
return nil , errThirdProvFailed
2024-07-29 17:07:43 +00:00
} , circuitName + "3" ) ,
2024-07-01 15:46:57 +00:00
} )
2024-03-11 11:09:50 +00:00
result := cb . Execute ( cmd )
require . Error ( t , result . Error ( ) )
2024-07-01 15:46:57 +00:00
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 ) )
2024-03-11 11:09:50 +00:00
}
func TestCircuitBreaker_ExecuteMultipleFallbacksFailButLastSuccessStress ( t * testing . T ) {
cb := NewCircuitBreaker ( Config {
Timeout : 10 ,
MaxConcurrentRequests : 100 ,
RequestVolumeThreshold : 10 ,
SleepWindow : 10 ,
ErrorPercentThreshold : 10 ,
} )
expectedResult := success
2024-07-01 15:46:57 +00:00
circuitName := fmt . Sprintf ( "LastSuccessStress_%d" , time . Now ( ) . Nanosecond ( ) ) // unique name to avoid conflicts with go tests `-count` option
2024-03-11 11:09:50 +00:00
// These are executed sequentially, but I had an issue with the test failing
// because of the open circuit
for i := 0 ; i < 1000 ; i ++ {
2024-07-01 15:46:57 +00:00
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" ) ,
} ,
)
2024-03-11 11:09:50 +00:00
result := cb . Execute ( cmd )
require . NoError ( t , result . Error ( ) )
require . Equal ( t , expectedResult , result . Result ( ) [ 0 ] . ( string ) )
}
}
2024-07-01 15:46:57 +00:00
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 ) )
}
2024-07-17 14:05:51 +00:00
assert . Less ( t , prov1Called , 3 ) // most of the time only 1 call is made, but occasionally 2 can happen
2024-07-01 15:46:57 +00:00
assert . Equal ( t , 10 , prov2Called )
2024-07-29 17:07:43 +00:00
assert . True ( t , CircuitExists ( circuitName + "1" ) )
assert . True ( t , IsCircuitOpen ( circuitName + "1" ) )
2024-07-01 15:46:57 +00:00
// 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 ( ) )
2024-07-17 14:05:51 +00:00
assert . Less ( t , prov1Called , 4 ) // most of the time only 2 calls are made, but occasionally 3 can happen
2024-07-01 15:46:57 +00:00
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
2024-07-03 13:10:10 +00:00
var ctx context . Context
2024-07-01 15:46:57 +00:00
expectedErr := errors . New ( "provider 1 failed" )
2024-07-03 13:10:10 +00:00
cmd := NewCommand ( ctx , nil )
2024-07-01 15:46:57 +00:00
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 ) )
2024-10-15 15:59:17 +00:00
require . True ( t , result . Cancelled ( ) )
2024-07-01 15:46:57 +00:00
assert . Equal ( t , 1 , prov1Called )
assert . Equal ( t , 0 , prov2Called )
2024-10-15 15:59:17 +00:00
2024-07-01 15:46:57 +00:00
}
2024-07-03 13:10:10 +00:00
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 ( ) )
}
2024-07-29 17:07:43 +00:00
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
2024-08-19 12:44:46 +00:00
// We add it twice as otherwise it's only used for the fallback
cmd . Add ( NewFunctor ( func ( ) ( [ ] interface { } , error ) {
return nil , nil
} , existCircuit ) )
2024-07-29 17:07:43 +00:00
cmd . Add ( NewFunctor ( func ( ) ( [ ] interface { } , error ) {
return nil , nil
} , existCircuit ) )
_ = cb . Execute ( cmd )
require . True ( t , CircuitExists ( existCircuit ) )
require . False ( t , IsCircuitOpen ( existCircuit ) )
}
2024-08-19 12:44:46 +00:00
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 )
}
2024-10-15 15:59:17 +00:00
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 )
}