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  }