diff --git a/fiat/fiat.go b/fiat/fiat.go new file mode 100644 index 0000000..a29e7d8 --- /dev/null +++ b/fiat/fiat.go @@ -0,0 +1,54 @@ +package fiat + +import ( + "context" + "errors" + "time" +) + +const ( + // maxRetries is the maximum number of retries we allow per call to an + // api. + maxRetries = 3 + + // retrySleep is the period we backoff for between tries, set to 0.5 + // second. + retrySleep = time.Millisecond * 500 +) + +var ( + errShuttingDown = errors.New("shutting down") + errRetriesFailed = errors.New("could not get data within max retries") +) + +// usdPrice represents the Bitcoin price in USD at a certain time. +type usdPrice struct{} + +// retryQuery calls an api until it succeeds, or we hit our maximum retries. +// It sleeps between calls and can be terminated early by cancelling the +// context passed in. It takes query and convert functions as parameters for +// testing purposes. +func retryQuery(ctx context.Context, queryAPI func() ([]byte, error), + convert func([]byte) ([]*usdPrice, error)) ([]*usdPrice, error) { + + for i := 0; i < maxRetries; i++ { + // If our request fails, log the error, sleep for the retry + // period and then continue so we can try again. + response, err := queryAPI() + if err != nil { + log.Errorf("http get attempt: %v failed: %v", i, err) + + select { + case <-time.After(retrySleep): + case <-ctx.Done(): + return nil, errShuttingDown + } + + continue + } + + return convert(response) + } + + return nil, errRetriesFailed +} diff --git a/fiat/fiat_test.go b/fiat/fiat_test.go new file mode 100644 index 0000000..81bcadf --- /dev/null +++ b/fiat/fiat_test.go @@ -0,0 +1,121 @@ +package fiat + +import ( + "context" + "errors" + "testing" +) + +var errMocked = errors.New("mocked error") + +// fakeQuery mocks failing and successful repeated queries, failing after a +// given call count. +type fakeQuery struct { + // callCount tracks the number of times the httpQuery has been executed. + callCount int + + // errorUntil is the call count at which the mock will stop failing. + // If you do want the mock call to succeed, set this value to -1. + // Eg, if this value is set to 2, the call will fail on first call and + // succeed thereafter. + errorUntil int +} + +func (f *fakeQuery) call() error { + f.callCount++ + + if f.callCount <= f.errorUntil { + return errMocked + } + + return nil +} + +// TestRetryQuery tests our retry logic, including the case where we receive +// instruction to shutdown. +func TestRetryQuery(t *testing.T) { + tests := []struct { + name string + expectedCallCount int + expectedErr error + cancelContext bool + mock *fakeQuery + }{ + { + name: "always failing", + expectedCallCount: maxRetries, + expectedErr: errRetriesFailed, + mock: &fakeQuery{ + errorUntil: 3, + }, + }, + { + name: "last call succeeds", + expectedCallCount: 3, + expectedErr: nil, + mock: &fakeQuery{ + errorUntil: 2, + }, + }, + { + name: "first succeeds", + expectedCallCount: 1, + expectedErr: nil, + mock: &fakeQuery{ + errorUntil: 0, + }, + }, + { + name: "call cancelled", + expectedCallCount: 1, + expectedErr: errShuttingDown, + cancelContext: true, + mock: &fakeQuery{ + errorUntil: 1, + }, + }, + } + + for _, test := range tests { + test := test + + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + // Create a test context which we can cancel. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // If we want to cancel test context to test early exit, + // go do now. + if test.cancelContext { + cancel() + } + + query := func() ([]byte, error) { + if err := test.mock.call(); err != nil { + return nil, err + } + + return nil, nil + } + + // Create a mocked parse call which acts as a nop. + parse := func([]byte) ([]*usdPrice, error) { + return nil, nil + } + + _, err := retryQuery(ctx, query, parse) + if err != test.expectedErr { + t.Fatalf("expected: %v, got: %v", + test.expectedErr, err) + } + + if test.mock.callCount != test.expectedCallCount { + t.Fatalf("expected call count: %v, got :%v", + test.expectedCallCount, + test.mock.callCount) + } + }) + } +} diff --git a/fiat/log.go b/fiat/log.go new file mode 100644 index 0000000..7e06cd7 --- /dev/null +++ b/fiat/log.go @@ -0,0 +1,26 @@ +package fiat + +import ( + "github.com/btcsuite/btclog" + "github.com/lightningnetwork/lnd/build" +) + +// Subsystem defines the logging code for this subsystem. +const Subsystem = "FIAT" + +// log is a logger that is initialized with no output filters. This +// means the package will not perform any logging by default until the +// caller requests it. +var log btclog.Logger + +// The default amount of logging is none. +func init() { + UseLogger(build.NewSubLogger(Subsystem, nil)) +} + +// UseLogger uses a specified Logger to output package logging info. +// This should be used in preference to SetLogWriter if the caller is also +// using btclog. +func UseLogger(logger btclog.Logger) { + log = logger +} diff --git a/log.go b/log.go index 0684892..beef4f4 100644 --- a/log.go +++ b/log.go @@ -3,6 +3,7 @@ package faraday import ( "github.com/btcsuite/btclog" "github.com/lightninglabs/faraday/dataset" + "github.com/lightninglabs/faraday/fiat" "github.com/lightninglabs/faraday/frdrpc" "github.com/lightninglabs/faraday/recommend" "github.com/lightninglabs/faraday/revenue" @@ -28,6 +29,7 @@ func init() { addSubLogger(dataset.Subsystem, dataset.UseLogger) addSubLogger(frdrpc.Subsystem, frdrpc.UseLogger) addSubLogger(revenue.Subsystem, revenue.UseLogger) + addSubLogger(fiat.Subsystem, fiat.UseLogger) } // UseLogger uses a specified Logger to output package logging info.