github.com/status-im/status-go@v1.1.0/circuitbreaker/circuit_breaker_test.go (about) 1 package circuitbreaker 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "testing" 8 "time" 9 10 "github.com/afex/hystrix-go/hystrix" 11 "github.com/stretchr/testify/assert" 12 "github.com/stretchr/testify/require" 13 ) 14 15 const success = "Success" 16 17 func TestCircuitBreaker_ExecuteSuccessSingle(t *testing.T) { 18 cb := NewCircuitBreaker(Config{ 19 Timeout: 1000, 20 MaxConcurrentRequests: 100, 21 RequestVolumeThreshold: 10, 22 SleepWindow: 10, 23 ErrorPercentThreshold: 10, 24 }) 25 26 expectedResult := success 27 circuitName := "SuccessSingle" 28 cmd := NewCommand(context.TODO(), []*Functor{ 29 NewFunctor(func() ([]interface{}, error) { 30 return []any{expectedResult}, nil 31 }, circuitName)}, 32 ) 33 34 result := cb.Execute(cmd) 35 require.NoError(t, result.Error()) 36 require.Equal(t, expectedResult, result.Result()[0].(string)) 37 } 38 39 func TestCircuitBreaker_ExecuteMultipleFallbacksFail(t *testing.T) { 40 cb := NewCircuitBreaker(Config{ 41 Timeout: 10, 42 MaxConcurrentRequests: 100, 43 RequestVolumeThreshold: 10, 44 SleepWindow: 10, 45 ErrorPercentThreshold: 10, 46 }) 47 48 circuitName := fmt.Sprintf("ExecuteMultipleFallbacksFail_%d", time.Now().Nanosecond()) // unique name to avoid conflicts with go tests `-count` option 49 errSecProvFailed := errors.New("provider 2 failed") 50 errThirdProvFailed := errors.New("provider 3 failed") 51 cmd := NewCommand(context.TODO(), []*Functor{ 52 NewFunctor(func() ([]interface{}, error) { 53 time.Sleep(100 * time.Millisecond) // will cause hystrix: timeout 54 return []any{success}, nil 55 }, circuitName+"1"), 56 NewFunctor(func() ([]interface{}, error) { 57 return nil, errSecProvFailed 58 }, circuitName+"2"), 59 NewFunctor(func() ([]interface{}, error) { 60 return nil, errThirdProvFailed 61 }, circuitName+"3"), 62 }) 63 64 result := cb.Execute(cmd) 65 require.Error(t, result.Error()) 66 assert.True(t, errors.Is(result.Error(), hystrix.ErrTimeout)) 67 assert.True(t, errors.Is(result.Error(), errSecProvFailed)) 68 assert.True(t, errors.Is(result.Error(), errThirdProvFailed)) 69 } 70 71 func TestCircuitBreaker_ExecuteMultipleFallbacksFailButLastSuccessStress(t *testing.T) { 72 cb := NewCircuitBreaker(Config{ 73 Timeout: 10, 74 MaxConcurrentRequests: 100, 75 RequestVolumeThreshold: 10, 76 SleepWindow: 10, 77 ErrorPercentThreshold: 10, 78 }) 79 80 expectedResult := success 81 circuitName := fmt.Sprintf("LastSuccessStress_%d", time.Now().Nanosecond()) // unique name to avoid conflicts with go tests `-count` option 82 83 // These are executed sequentially, but I had an issue with the test failing 84 // because of the open circuit 85 for i := 0; i < 1000; i++ { 86 cmd := NewCommand(context.TODO(), []*Functor{ 87 NewFunctor(func() ([]interface{}, error) { 88 return nil, errors.New("provider 1 failed") 89 }, circuitName+"1"), 90 NewFunctor(func() ([]interface{}, error) { 91 return nil, errors.New("provider 2 failed") 92 }, circuitName+"2"), 93 NewFunctor(func() ([]interface{}, error) { 94 return []any{expectedResult}, nil 95 }, circuitName+"3"), 96 }, 97 ) 98 99 result := cb.Execute(cmd) 100 require.NoError(t, result.Error()) 101 require.Equal(t, expectedResult, result.Result()[0].(string)) 102 } 103 } 104 105 func TestCircuitBreaker_ExecuteSwitchToWorkingProviderOnVolumeThresholdReached(t *testing.T) { 106 cb := NewCircuitBreaker(Config{ 107 RequestVolumeThreshold: 10, 108 }) 109 110 expectedResult := success 111 circuitName := fmt.Sprintf("SwitchToWorkingProviderOnVolumeThresholdReached_%d", time.Now().Nanosecond()) // unique name to avoid conflicts with go tests `-count` option 112 113 prov1Called := 0 114 prov2Called := 0 115 prov3Called := 0 116 // These are executed sequentially 117 for i := 0; i < 20; i++ { 118 cmd := NewCommand(context.TODO(), []*Functor{ 119 NewFunctor(func() ([]interface{}, error) { 120 prov1Called++ 121 return nil, errors.New("provider 1 failed") 122 }, circuitName+"1"), 123 NewFunctor(func() ([]interface{}, error) { 124 prov2Called++ 125 return nil, errors.New("provider 2 failed") 126 }, circuitName+"2"), 127 NewFunctor(func() ([]interface{}, error) { 128 prov3Called++ 129 return []any{expectedResult}, nil 130 }, circuitName+"3"), 131 }) 132 133 result := cb.Execute(cmd) 134 require.NoError(t, result.Error()) 135 require.Equal(t, expectedResult, result.Result()[0].(string)) 136 } 137 138 assert.Equal(t, 10, prov1Called) 139 assert.Equal(t, 10, prov2Called) 140 assert.Equal(t, 20, prov3Called) 141 } 142 143 func TestCircuitBreaker_ExecuteHealthCheckOnWindowTimeout(t *testing.T) { 144 sleepWindow := 10 145 cb := NewCircuitBreaker(Config{ 146 RequestVolumeThreshold: 1, // 1 failed request is enough to trip the circuit 147 SleepWindow: sleepWindow, 148 ErrorPercentThreshold: 1, // Trip on first error 149 }) 150 151 expectedResult := success 152 circuitName := fmt.Sprintf("SwitchToWorkingProviderOnWindowTimeout_%d", time.Now().Nanosecond()) // unique name to avoid conflicts with go tests `-count` option 153 154 prov1Called := 0 155 prov2Called := 0 156 // These are executed sequentially 157 for i := 0; i < 10; i++ { 158 cmd := NewCommand(context.TODO(), []*Functor{ 159 NewFunctor(func() ([]interface{}, error) { 160 prov1Called++ 161 return nil, errors.New("provider 1 failed") 162 }, circuitName+"1"), 163 NewFunctor(func() ([]interface{}, error) { 164 prov2Called++ 165 return []any{expectedResult}, nil 166 }, circuitName+"2"), 167 }) 168 169 result := cb.Execute(cmd) 170 require.NoError(t, result.Error()) 171 require.Equal(t, expectedResult, result.Result()[0].(string)) 172 } 173 174 assert.Less(t, prov1Called, 3) // most of the time only 1 call is made, but occasionally 2 can happen 175 assert.Equal(t, 10, prov2Called) 176 assert.True(t, CircuitExists(circuitName+"1")) 177 assert.True(t, IsCircuitOpen(circuitName+"1")) 178 179 // Wait for the sleep window to expire 180 time.Sleep(time.Duration(sleepWindow+1) * time.Millisecond) 181 cmd := NewCommand(context.TODO(), []*Functor{ 182 NewFunctor(func() ([]interface{}, error) { 183 prov1Called++ 184 return []any{expectedResult}, nil // Now it is working 185 }, circuitName+"1"), 186 NewFunctor(func() ([]interface{}, error) { 187 prov2Called++ 188 return []any{expectedResult}, nil 189 }, circuitName+"2"), 190 }) 191 result := cb.Execute(cmd) 192 require.NoError(t, result.Error()) 193 194 assert.Less(t, prov1Called, 4) // most of the time only 2 calls are made, but occasionally 3 can happen 195 assert.Equal(t, 10, prov2Called) 196 } 197 198 func TestCircuitBreaker_CommandCancel(t *testing.T) { 199 cb := NewCircuitBreaker(Config{}) 200 201 circuitName := fmt.Sprintf("CommandCancel_%d", time.Now().Nanosecond()) // unique name to avoid conflicts with go tests `-count` option 202 203 prov1Called := 0 204 prov2Called := 0 205 206 var ctx context.Context 207 expectedErr := errors.New("provider 1 failed") 208 209 cmd := NewCommand(ctx, nil) 210 cmd.Add(NewFunctor(func() ([]interface{}, error) { 211 prov1Called++ 212 cmd.Cancel() 213 return nil, expectedErr 214 }, circuitName+"1")) 215 cmd.Add(NewFunctor(func() ([]interface{}, error) { 216 prov2Called++ 217 return nil, errors.New("provider 2 failed") 218 }, circuitName+"2")) 219 220 result := cb.Execute(cmd) 221 require.True(t, errors.Is(result.Error(), expectedErr)) 222 223 assert.Equal(t, 1, prov1Called) 224 assert.Equal(t, 0, prov2Called) 225 } 226 227 func TestCircuitBreaker_EmptyOrNilCommand(t *testing.T) { 228 cb := NewCircuitBreaker(Config{}) 229 cmd := NewCommand(context.TODO(), nil) 230 result := cb.Execute(cmd) 231 require.Error(t, result.Error()) 232 result = cb.Execute(nil) 233 require.Error(t, result.Error()) 234 } 235 236 func TestCircuitBreaker_CircuitExistsAndClosed(t *testing.T) { 237 timestamp := time.Now().Nanosecond() 238 nonExCircuit := fmt.Sprintf("nonexistent_%d", timestamp) // unique name to avoid conflicts with go tests `-count` option 239 require.False(t, CircuitExists(nonExCircuit)) 240 241 cb := NewCircuitBreaker(Config{}) 242 cmd := NewCommand(context.TODO(), nil) 243 existCircuit := fmt.Sprintf("existing_%d", timestamp) // unique name to avoid conflicts with go tests `-count` option 244 // We add it twice as otherwise it's only used for the fallback 245 cmd.Add(NewFunctor(func() ([]interface{}, error) { 246 return nil, nil 247 }, existCircuit)) 248 249 cmd.Add(NewFunctor(func() ([]interface{}, error) { 250 return nil, nil 251 }, existCircuit)) 252 _ = cb.Execute(cmd) 253 require.True(t, CircuitExists(existCircuit)) 254 require.False(t, IsCircuitOpen(existCircuit)) 255 } 256 257 func TestCircuitBreaker_Fallback(t *testing.T) { 258 cb := NewCircuitBreaker(Config{ 259 RequestVolumeThreshold: 1, // 1 failed request is enough to trip the circuit 260 SleepWindow: 50000, 261 ErrorPercentThreshold: 1, // Trip on first error 262 }) 263 264 circuitName := fmt.Sprintf("Fallback_%d", time.Now().Nanosecond()) // unique name to avoid conflicts with go tests `-count` option 265 266 prov1Called := 0 267 268 var ctx context.Context 269 expectedErr := errors.New("provider 1 failed") 270 271 // we start with 2, and we open the first 272 for { 273 cmd := NewCommand(ctx, nil) 274 cmd.Add(NewFunctor(func() ([]interface{}, error) { 275 return nil, expectedErr 276 }, circuitName+"1")) 277 cmd.Add(NewFunctor(func() ([]interface{}, error) { 278 return nil, errors.New("provider 2 failed") 279 }, circuitName+"2")) 280 281 result := cb.Execute(cmd) 282 require.NotNil(t, result.Error()) 283 if IsCircuitOpen(circuitName + "1") { 284 break 285 } 286 } 287 288 // Make sure circuit is open 289 require.True(t, CircuitExists(circuitName+"1")) 290 require.True(t, IsCircuitOpen(circuitName+"1")) 291 292 // we send a single request, it should hit the provider, at that's a fallback 293 cmd := NewCommand(ctx, nil) 294 cmd.Add(NewFunctor(func() ([]interface{}, error) { 295 prov1Called++ 296 return nil, expectedErr 297 }, circuitName+"1")) 298 299 result := cb.Execute(cmd) 300 require.True(t, errors.Is(result.Error(), expectedErr)) 301 302 assert.Equal(t, 1, prov1Called) 303 }