diff --git a/README.md b/README.md index df88759..963daa6 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ func main() { | `ConnectTimeout` | `time.Duration` | Max time to establish a remote connection before failing fast | `300ms` | | `Timeout` | `time.Duration` | Max time for remote request/response and idle connection reuse | `5s` | -**Under development:** transport errors are normalized into typed SDK errors, and silent mode uses the configured remote timeouts to fail fast and switch back to local evaluation. +**Note:** lower remote connect timeouts help silent mode fall back faster when the upstream is unavailable. #### Security Features diff --git a/client.go b/client.go index 32072e7..0b829b3 100644 --- a/client.go +++ b/client.go @@ -25,6 +25,9 @@ type Client struct { httpClientMu sync.Mutex httpClient_ *http.Client + + notifyErrorMu sync.RWMutex + notifyErrorCallback func(error) } func NewClient(ctx Context) *Client { @@ -171,38 +174,25 @@ func (c *Client) CheckSnapshot() (bool, error) { return true, nil } -func (c *Client) snapshotState() *Snapshot { - c.mu.RLock() - defer c.mu.RUnlock() - - return c.snapshot +func SubscribeNotifyError(callback func(error)) { + defaultClient().SubscribeNotifyError(callback) } -func (c *Client) setSnapshot(snapshot *Snapshot) { - c.mu.Lock() - defer c.mu.Unlock() - - c.snapshot = snapshot -} +func (c *Client) SubscribeNotifyError(callback func(error)) { + c.notifyErrorMu.Lock() + defer c.notifyErrorMu.Unlock() -func (c *Client) stopBackgroundTasks() { - c.TerminateSnapshotAutoUpdate() - c.UnwatchSnapshot() + c.notifyErrorCallback = callback } -func (c *Client) shouldCheckSnapshot(fetchRemote bool) bool { - ctx := c.Context() - return c.SnapshotVersion() == 0 && (fetchRemote || !ctx.Options.Local) -} +func (c *Client) notifyError(err error) { + c.notifyErrorMu.RLock() + callback := c.notifyErrorCallback + c.notifyErrorMu.RUnlock() -func (c *Client) loadSnapshotFromCurrentFile() (*Snapshot, error) { - snapshot, err := loadSnapshotFromFile(c.Context()) - if err != nil { - return nil, err + if callback != nil { + callback(err) } - - c.setSnapshot(snapshot) - return snapshot, nil } func defaultClient() *Client { diff --git a/client_silent_mode.go b/client_silent_mode.go new file mode 100644 index 0000000..f6bfe7f --- /dev/null +++ b/client_silent_mode.go @@ -0,0 +1,65 @@ +package client + +import "time" + +const silentModeAuthToken = "SILENT" + +func (c *Client) shouldUseLocalSilentMode() bool { + if !c.hasSilentMode() { + return false + } + + token, expiration := c.authState() + if token != silentModeAuthToken { + return false + } + + if !tokenExpired(expiration) { + return true + } + + c.updateSilentToken() + if c.checkAPIHealth() { + c.clearSilentToken() + return false + } + + return true +} + +func (c *Client) fallbackToSilentMode(switcher *Switcher, err error) (ResultDetail, error) { + if !c.hasSilentMode() { + return ResultDetail{}, err + } + + c.notifyError(err) + c.updateSilentToken() + return checkLocalCriteria(c.snapshotState(), switcher) +} + +func (c *Client) hasSilentMode() bool { + return c.Context().Options.SilentMode > 0 +} + +func (c *Client) authState() (string, int64) { + c.authMu.Lock() + defer c.authMu.Unlock() + + return c.authToken, c.authTokenExp +} + +func (c *Client) updateSilentToken() { + c.authMu.Lock() + defer c.authMu.Unlock() + + c.authToken = silentModeAuthToken + c.authTokenExp = time.Now().Add(c.Context().Options.SilentMode).UnixMilli() +} + +func (c *Client) clearSilentToken() { + c.authMu.Lock() + defer c.authMu.Unlock() + + c.authToken = "" + c.authTokenExp = 0 +} diff --git a/client_silent_mode_test.go b/client_silent_mode_test.go new file mode 100644 index 0000000..9ae0222 --- /dev/null +++ b/client_silent_mode_test.go @@ -0,0 +1,375 @@ +package client + +import ( + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSilentMode(t *testing.T) { + t.Run("should fall back to the local snapshot and notify the remote failure", func(t *testing.T) { + server := newSilentModeTestServer(t, silentModeServerOptions{ + authStatuses: []int{http.StatusOK}, + criteriaStatuses: []int{http.StatusTooManyRequests}, + criteriaBodies: []map[string]any{{"error": "Too many requests"}}, + healthStatuses: []int{http.StatusInternalServerError}, + snapshotDomain: loadSnapshotFixture(t, "default"), + }) + defer server.Close() + + client := NewClient(Context{ + Domain: "My Domain", + URL: server.URL, + APIKey: "[YOUR_API_KEY]", + Component: "MyApp", + Options: ContextOptions{ + SilentMode: time.Second, + }, + }) + + version, loadErr := client.LoadSnapshot(&LoadSnapshotOptions{FetchRemote: true}) + require.NoError(t, loadErr) + require.Equal(t, 1, version) + + var asyncError string + client.SubscribeNotifyError(func(err error) { + asyncError = err.Error() + }) + + switcher := client.GetSwitcher("FF2FOR2022") + + first, firstErr := switcher.IsOn() + require.NoError(t, firstErr) + assert.True(t, first) + assert.Equal(t, "[check_criteria] failed with status: 429", asyncError) + assert.Equal(t, 1, server.criteriaRequests()) + assert.Equal(t, 0, server.healthRequests()) + + asyncError = "" + second, secondErr := switcher.IsOn() + require.NoError(t, secondErr) + assert.True(t, second) + assert.Empty(t, asyncError) + assert.Equal(t, 1, server.criteriaRequests()) + assert.Equal(t, 0, server.healthRequests()) + + time.Sleep(1100 * time.Millisecond) + + third, thirdErr := switcher.IsOn() + require.NoError(t, thirdErr) + assert.True(t, third) + assert.Empty(t, asyncError) + assert.Equal(t, 1, server.criteriaRequests()) + assert.Equal(t, 1, server.healthRequests()) + }) + + t.Run("should restore remote execution after the upstream health check succeeds", func(t *testing.T) { + server := newSilentModeTestServer(t, silentModeServerOptions{ + authStatuses: []int{http.StatusOK, http.StatusOK}, + criteriaStatuses: []int{http.StatusTooManyRequests, http.StatusOK}, + criteriaBodies: []map[string]any{ + {"error": "Too many requests"}, + {"result": false, "reason": "remote"}, + }, + healthStatuses: []int{http.StatusOK}, + snapshotDomain: loadSnapshotFixture(t, "default"), + }) + defer server.Close() + + client := NewClient(Context{ + Domain: "My Domain", + URL: server.URL, + APIKey: "[YOUR_API_KEY]", + Component: "MyApp", + Options: ContextOptions{ + SilentMode: time.Second, + }, + }) + + _, loadErr := client.LoadSnapshot(&LoadSnapshotOptions{FetchRemote: true}) + require.NoError(t, loadErr) + + var asyncError string + client.SubscribeNotifyError(func(err error) { + asyncError = err.Error() + }) + + switcher := client.GetSwitcher("FF2FOR2022") + + first, firstErr := switcher.IsOn() + require.NoError(t, firstErr) + assert.True(t, first) + assert.Equal(t, "[check_criteria] failed with status: 429", asyncError) + + time.Sleep(1100 * time.Millisecond) + asyncError = "" + + second, secondErr := switcher.IsOn() + require.NoError(t, secondErr) + assert.False(t, second) + assert.Empty(t, asyncError) + assert.Equal(t, 2, server.authRequests()) + assert.Equal(t, 2, server.criteriaRequests()) + assert.Equal(t, 1, server.healthRequests()) + }) + + t.Run("should keep using the local snapshot when the health check request fails", func(t *testing.T) { + server := newSilentModeTestServer(t, silentModeServerOptions{ + authStatuses: []int{http.StatusOK}, + criteriaStatuses: []int{http.StatusTooManyRequests}, + criteriaBodies: []map[string]any{{"error": "Too many requests"}}, + snapshotDomain: loadSnapshotFixture(t, "default"), + }) + + client := NewClient(Context{ + Domain: "My Domain", + URL: server.URL, + APIKey: "[YOUR_API_KEY]", + Component: "MyApp", + Options: ContextOptions{ + SilentMode: time.Second, + }, + }) + + _, loadErr := client.LoadSnapshot(&LoadSnapshotOptions{FetchRemote: true}) + require.NoError(t, loadErr) + + var asyncError string + client.SubscribeNotifyError(func(err error) { + asyncError = err.Error() + }) + + switcher := client.GetSwitcher("FF2FOR2022") + + first, firstErr := switcher.IsOn() + require.NoError(t, firstErr) + assert.True(t, first) + assert.Equal(t, "[check_criteria] failed with status: 429", asyncError) + + time.Sleep(1100 * time.Millisecond) + asyncError = "" + server.Close() + + second, secondErr := switcher.IsOn() + require.NoError(t, secondErr) + assert.True(t, second) + assert.Empty(t, asyncError) + assert.Equal(t, 1, server.criteriaRequests()) + assert.Equal(t, 1, server.authRequests()) + assert.Equal(t, 0, server.healthRequests()) + }) + + t.Run("should fall back to the local snapshot when authentication fails during silent mode", func(t *testing.T) { + server := newSilentModeTestServer(t, silentModeServerOptions{ + authStatuses: []int{http.StatusOK, http.StatusUnauthorized}, + criteriaStatuses: []int{http.StatusOK}, + criteriaBodies: []map[string]any{{"result": true}}, + snapshotDomain: loadSnapshotFixture(t, "default"), + }) + defer server.Close() + + client := NewClient(Context{ + Domain: "My Domain", + URL: server.URL, + APIKey: "[YOUR_API_KEY]", + Component: "MyApp", + Options: ContextOptions{ + SilentMode: time.Second, + }, + }) + + _, loadErr := client.LoadSnapshot(&LoadSnapshotOptions{FetchRemote: true}) + require.NoError(t, loadErr) + + client.authMu.Lock() + client.authToken = "" + client.authTokenExp = 0 + client.authMu.Unlock() + + var asyncError string + client.SubscribeNotifyError(func(err error) { + asyncError = err.Error() + }) + + enabled, err := client.GetSwitcher("FF2FOR2022").IsOn() + + require.NoError(t, err) + assert.True(t, enabled) + assert.Equal(t, "invalid API key", asyncError) + assert.Equal(t, 2, server.authRequests()) + assert.Equal(t, 0, server.healthRequests()) + }) + + t.Run("should surface the local snapshot error when silent mode has no snapshot to use", func(t *testing.T) { + server := newSilentModeTestServer(t, silentModeServerOptions{ + authStatuses: []int{http.StatusOK}, + criteriaStatuses: []int{http.StatusTooManyRequests}, + criteriaBodies: []map[string]any{{"error": "Too many requests"}}, + snapshotDomain: loadSnapshotFixture(t, "default"), + }) + defer server.Close() + + client := NewClient(Context{ + Domain: "My Domain", + URL: server.URL, + APIKey: "[YOUR_API_KEY]", + Component: "MyApp", + Options: ContextOptions{ + SilentMode: time.Second, + }, + }) + + var asyncError string + client.SubscribeNotifyError(func(err error) { + asyncError = err.Error() + }) + + enabled, err := client.GetSwitcher("FF2FOR2022").IsOn() + + assert.False(t, enabled) + assert.EqualError(t, err, "Snapshot not loaded. Try to use 'Client.load_snapshot()'") + assert.Equal(t, "[check_criteria] failed with status: 429", asyncError) + }) +} + +type silentModeServerOptions struct { + authStatuses []int + criteriaStatuses []int + criteriaBodies []map[string]any + healthStatuses []int + snapshotDomain map[string]any +} + +type silentModeTestServer struct { + *httptest.Server + mu sync.Mutex + authCount int + criteriaCount int + healthCount int +} + +func newSilentModeTestServer(t *testing.T, options silentModeServerOptions) *silentModeTestServer { + t.Helper() + + server := &silentModeTestServer{} + + var authIndex int + var criteriaIndex int + var healthIndex int + mux := http.NewServeMux() + mux.HandleFunc("/criteria/auth", func(writer http.ResponseWriter, request *http.Request) { + server.mu.Lock() + server.authCount++ + status := selectStatus(options.authStatuses, authIndex, http.StatusOK) + authIndex++ + server.mu.Unlock() + + assert.Equal(t, http.MethodPost, request.Method) + + if status != http.StatusOK { + writeJSONResponse(t, writer, status, map[string]any{}) + return + } + + writeJSONResponse(t, writer, status, map[string]any{ + "token": "[token]", + "exp": time.Now().Add(time.Hour).Unix(), + }) + }) + mux.HandleFunc("/criteria/snapshot_check/", func(writer http.ResponseWriter, request *http.Request) { + assert.Equal(t, http.MethodGet, request.Method) + writeJSONResponse(t, writer, http.StatusOK, map[string]any{"status": false}) + }) + mux.HandleFunc("/graphql", func(writer http.ResponseWriter, request *http.Request) { + assert.Equal(t, http.MethodPost, request.Method) + writeJSONResponse(t, writer, http.StatusOK, map[string]any{ + "data": map[string]any{ + "domain": options.snapshotDomain, + }, + }) + }) + mux.HandleFunc("/criteria", func(writer http.ResponseWriter, request *http.Request) { + server.mu.Lock() + server.criteriaCount++ + status := selectStatus(options.criteriaStatuses, criteriaIndex, http.StatusOK) + body := selectBody(options.criteriaBodies, criteriaIndex, map[string]any{"result": true}) + criteriaIndex++ + server.mu.Unlock() + + assert.Equal(t, http.MethodPost, request.Method) + assert.Equal(t, "FF2FOR2022", request.URL.Query().Get("key")) + assert.Equal(t, "false", request.URL.Query().Get("showReason")) + writeJSONResponse(t, writer, status, body) + }) + mux.HandleFunc("/check", func(writer http.ResponseWriter, request *http.Request) { + server.mu.Lock() + server.healthCount++ + status := selectStatus(options.healthStatuses, healthIndex, http.StatusOK) + healthIndex++ + server.mu.Unlock() + + assert.Equal(t, http.MethodGet, request.Method) + writer.WriteHeader(status) + }) + + server.Server = httptest.NewServer(mux) + return server +} + +func (s *silentModeTestServer) authRequests() int { + s.mu.Lock() + defer s.mu.Unlock() + + return s.authCount +} + +func (s *silentModeTestServer) criteriaRequests() int { + s.mu.Lock() + defer s.mu.Unlock() + + return s.criteriaCount +} + +func (s *silentModeTestServer) healthRequests() int { + s.mu.Lock() + defer s.mu.Unlock() + + return s.healthCount +} + +func selectStatus(statuses []int, index int, fallback int) int { + if len(statuses) == 0 { + return fallback + } + + return statuses[min(index, len(statuses)-1)] +} + +func selectBody(bodies []map[string]any, index int, fallback map[string]any) map[string]any { + if len(bodies) == 0 { + return fallback + } + + return bodies[min(index, len(bodies)-1)] +} + +func TestSubscribeNotifyError(t *testing.T) { + t.Run("should register the callback on the default client", func(t *testing.T) { + BuildContext(Context{Domain: "My Domain"}) + + var got error + SubscribeNotifyError(func(err error) { + got = err + }) + + defaultClient().notifyError(fmt.Errorf("boom")) + + require.EqualError(t, got, "boom") + }) +} diff --git a/client_snapshot.go b/client_snapshot.go new file mode 100644 index 0000000..0a7e7c3 --- /dev/null +++ b/client_snapshot.go @@ -0,0 +1,35 @@ +package client + +func (c *Client) snapshotState() *Snapshot { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.snapshot +} + +func (c *Client) setSnapshot(snapshot *Snapshot) { + c.mu.Lock() + defer c.mu.Unlock() + + c.snapshot = snapshot +} + +func (c *Client) stopBackgroundTasks() { + c.TerminateSnapshotAutoUpdate() + c.UnwatchSnapshot() +} + +func (c *Client) shouldCheckSnapshot(fetchRemote bool) bool { + ctx := c.Context() + return c.SnapshotVersion() == 0 && (fetchRemote || !ctx.Options.Local) +} + +func (c *Client) loadSnapshotFromCurrentFile() (*Snapshot, error) { + snapshot, err := loadSnapshotFromFile(c.Context()) + if err != nil { + return nil, err + } + + c.setSnapshot(snapshot) + return snapshot, nil +} diff --git a/remote.go b/remote.go index 449878c..6ae82b6 100644 --- a/remote.go +++ b/remote.go @@ -55,7 +55,7 @@ func (c *Client) ensureToken() (string, error) { c.authMu.Lock() defer c.authMu.Unlock() - if strings.TrimSpace(c.authToken) != "" && !tokenExpired(c.authTokenExp) { + if strings.TrimSpace(c.authToken) != "" && c.authToken != silentModeAuthToken && !tokenExpired(c.authTokenExp) { return c.authToken, nil } @@ -217,6 +217,26 @@ func (c *Client) resolveSnapshot(token string) (*Snapshot, error) { return &Snapshot{Domain: payload.Data.Domain}, nil } +func (c *Client) checkAPIHealth() bool { + ctx := c.Context() + endpoint := strings.TrimRight(ctx.URL, "/") + "/check" + + response, err := c.doJSONRequest( + http.MethodGet, + endpoint, + nil, + map[string]string{}, + ) + if err != nil { + return false + } + defer func() { + _ = response.Body.Close() + }() + + return response.StatusCode == http.StatusOK +} + func (c *Client) doJSONRequest(method, endpoint string, payload any, headers map[string]string) (*http.Response, error) { var bodyReader io.Reader if payload != nil { diff --git a/switcher.go b/switcher.go index 3a2dcea..d75175b 100644 --- a/switcher.go +++ b/switcher.go @@ -98,16 +98,25 @@ func (s *Switcher) submit(showDetails bool) (ResultDetail, error) { return ResultDetail{}, err } + if s.client.shouldUseLocalSilentMode() { + return checkLocalCriteria(s.client.snapshotState(), s) + } + token, err := s.client.ensureToken() if err != nil { - return ResultDetail{}, err + return s.client.fallbackToSilentMode(s, err) } if err := missingTokenError(token); err != nil { - return ResultDetail{}, err + return s.client.fallbackToSilentMode(s, err) + } + + result, err := s.client.checkCriteria(token, s, showDetails) + if err != nil { + return s.client.fallbackToSilentMode(s, err) } - return s.client.checkCriteria(token, s, showDetails) + return result, nil } func upsertEntry(entries []criteriaEntry, next criteriaEntry) []criteriaEntry {