diff --git a/README.md b/README.md index 46401ef..be5babf 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,6 @@ func main() { } ``` - #### Assign anywhere ```go @@ -131,6 +130,18 @@ func main() { } ``` +## Configuration + +The `Config` struct in `config.go` contains the following options: + +| Option Name | Description | +|-------------------------------------------------|-----------------------------------------------------------------------------| +| `BaseUrl` | The base URL for the Eppo API. Defaults to `https://fscdn.eppo.cloud/api`. | +| `SdkKey` | The SDK key for authenticating requests. | +| `AssignmentLogger` | An implementation of the `IAssignmentLogger` interface for logging assignments. | +| `PollerInterval` | The interval at which the SDK polls for updates. Defaults to 10 seconds. | +| `SkipDeserializeAndUpdateFlagConfigIfUnchanged` | A boolean flag to skip deserialization and update if the configuration has not changed. Defaults to false. | + ## Philosophy Eppo's SDKs are built for simplicity, speed and reliability. Flag configurations are compressed and distributed over a global CDN (Fastly), typically reaching your servers in under 15ms. Server SDKs continue polling Eppo’s API at 10-second intervals. Configurations are then cached locally, ensuring that each assignment is made instantly. Evaluation logic within each SDK consists of a few lines of simple numeric and string comparisons. The typed functions listed above are all developers need to understand, abstracting away the complexity of the Eppo's underlying (and expanding) feature set. diff --git a/eppoclient/client.go b/eppoclient/client.go index 81ff12e..73901df 100644 --- a/eppoclient/client.go +++ b/eppoclient/client.go @@ -181,10 +181,6 @@ func (ec *EppoClient) getAssignment(config configuration, flagKey string, subjec panic("no flag key provided") } - if ec.configRequestor != nil && !ec.configRequestor.IsAuthorized() { - panic("Unauthorized: please check your SDK key") - } - flag, err := config.getFlagConfiguration(flagKey) if err != nil { return nil, err diff --git a/eppoclient/config.go b/eppoclient/config.go index 9cf4267..08fb907 100644 --- a/eppoclient/config.go +++ b/eppoclient/config.go @@ -5,10 +5,11 @@ import "time" const default_base_url = "https://fscdn.eppo.cloud/api" type Config struct { - BaseUrl string - SdkKey string - AssignmentLogger IAssignmentLogger - PollerInterval time.Duration + BaseUrl string + SdkKey string + AssignmentLogger IAssignmentLogger + PollerInterval time.Duration + SkipDeserializeAndUpdateFlagConfigIfUnchanged bool // default false } func (cfg *Config) validate() { diff --git a/eppoclient/configurationrequestor.go b/eppoclient/configurationrequestor.go index 7b1bb7c..19022f8 100644 --- a/eppoclient/configurationrequestor.go +++ b/eppoclient/configurationrequestor.go @@ -9,21 +9,22 @@ const CONFIG_ENDPOINT = "/flag-config/v1/config" const BANDIT_ENDPOINT = "/flag-config/v1/bandits" type configurationRequestor struct { - httpClient httpClient - configStore *configurationStore + httpClient HttpClientInterface + configStore *configurationStore + storedUFCResponseETag string + deserializeCount int + skipDeserializeAndUpdateFlagConfigIfUnchanged bool } -func newConfigurationRequestor(httpClient httpClient, configStore *configurationStore) *configurationRequestor { +func newConfigurationRequestor(httpClient HttpClientInterface, configStore *configurationStore, skipDeserializeAndUpdateFlagConfigIfUnchanged bool) *configurationRequestor { return &configurationRequestor{ - httpClient: httpClient, - configStore: configStore, + httpClient: httpClient, + configStore: configStore, + deserializeCount: 0, + skipDeserializeAndUpdateFlagConfigIfUnchanged: skipDeserializeAndUpdateFlagConfigIfUnchanged, } } -func (cr *configurationRequestor) IsAuthorized() bool { - return !cr.httpClient.isUnauthorized -} - func (cr *configurationRequestor) FetchAndStoreConfigurations() { configuration, err := cr.fetchConfiguration() if err != nil { @@ -59,8 +60,19 @@ func (cr *configurationRequestor) fetchConfig() (configResponse, error) { return configResponse{}, err } + if cr.skipDeserializeAndUpdateFlagConfigIfUnchanged { + if result.ETag == cr.storedUFCResponseETag { + fmt.Println("[EppoSDK] Response has not changed, skipping deserialization and cache update.") + // Returning an empty configResponse and an error to indicate that the response has not changed + // which prevents the flag config from being updated with an empty configResponse + return configResponse{}, fmt.Errorf("config response has not changed") + } + cr.storedUFCResponseETag = result.ETag + } + var response configResponse - err = json.Unmarshal(result, &response) + cr.deserializeCount++ + err = json.Unmarshal([]byte(result.Body), &response) if err != nil { fmt.Println("Failed to unmarshal config response JSON", result) fmt.Println(err) @@ -83,7 +95,7 @@ func (cr *configurationRequestor) fetchBandits() (banditResponse, error) { } var response banditResponse - err = json.Unmarshal(result, &response) + err = json.Unmarshal([]byte(result.Body), &response) if err != nil { fmt.Println("Failed to unmarshal bandit response JSON", result) fmt.Println(err) diff --git a/eppoclient/configurationrequestor_test.go b/eppoclient/configurationrequestor_test.go index 0f85fb3..9001469 100644 --- a/eppoclient/configurationrequestor_test.go +++ b/eppoclient/configurationrequestor_test.go @@ -8,8 +8,18 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ) +type mockHttpClient struct { + mock.Mock +} + +func (m *mockHttpClient) get(url string) (HttpResponse, error) { + args := m.Called(url) + return args.Get(0).(HttpResponse), args.Error(1) +} + func Test_configurationRequestor_requestBandits(t *testing.T) { flags := readJsonFile[configResponse]("test-data/ufc/bandit-flags-v1.json") bandits := readJsonFile[banditResponse]("test-data/ufc/bandit-models-v1.json") @@ -18,7 +28,7 @@ func Test_configurationRequestor_requestBandits(t *testing.T) { sdkParams := SDKParams{sdkKey: "blah", sdkName: "go", sdkVersion: __version__} httpClient := newHttpClient(server.URL, &http.Client{Timeout: REQUEST_TIMEOUT_SECONDS}, sdkParams) configurationStore := newConfigurationStore(configuration{}) - configurationRequestor := newConfigurationRequestor(*httpClient, configurationStore) + configurationRequestor := newConfigurationRequestor(httpClient, configurationStore, false) configurationRequestor.FetchAndStoreConfigurations() @@ -37,7 +47,7 @@ func Test_configurationRequestor_shouldNotRequestBanditsIfNotPresentInFlags(t *t sdkParams := SDKParams{sdkKey: "blah", sdkName: "go", sdkVersion: __version__} httpClient := newHttpClient(server.URL, &http.Client{Timeout: REQUEST_TIMEOUT_SECONDS}, sdkParams) configurationStore := newConfigurationStore(configuration{}) - configurationRequestor := newConfigurationRequestor(*httpClient, configurationStore) + configurationRequestor := newConfigurationRequestor(httpClient, configurationStore, false) configurationRequestor.FetchAndStoreConfigurations() @@ -64,3 +74,99 @@ func newTestServer(configResponse configResponse, banditsResponse banditResponse } })) } + +func Test_FetchAndStoreConfigurations_SkipDeserializeIfUnchanged(t *testing.T) { + mockHttpClient := new(mockHttpClient) + mockConfigStore := newConfigurationStore(configuration{}) + configRequestor := newConfigurationRequestor(mockHttpClient, mockConfigStore, true) // true to skip deserialize + + // Mock the HTTP client to return a fixed response + mockResponse1 := ` + { + "createdAt": "2024-04-17T19:40:53.716Z", + "environment": { + "name": "Test" + }, + "flags": { + "empty_flag": { + "key": "empty_flag", + "enabled": true, + "variationType": "STRING", + "variations": {}, + "allocations": [], + "totalShards": 10000 + } + } + } + ` + mockCall := mockHttpClient.On("get", CONFIG_ENDPOINT).Return(HttpResponse{Body: mockResponse1, ETag: "tag_1"}, nil) + + // First fetch and store configurations + configRequestor.FetchAndStoreConfigurations() + + // Fetch and store configurations again + configRequestor.FetchAndStoreConfigurations() + + // Assert that configuration was fetched two times but deserialize was only called once + mockHttpClient.AssertNumberOfCalls(t, "get", 2) + assert.Equal(t, 1, configRequestor.deserializeCount) + + // Assert that configuration was stored as desired + flag, err := mockConfigStore.getConfiguration().getFlagConfiguration("empty_flag") + assert.Nil(t, err) + assert.Equal(t, "empty_flag", flag.Key) + mockCall.Unset() + + // change the remote config + mockResponse2 := ` + { + "createdAt": "2024-04-17T19:40:53.716Z", + "environment": { + "name": "Test" + }, + "flags": { + "empty_flag_2": { + "key": "empty_flag_2", + "enabled": true, + "variationType": "STRING", + "variations": {}, + "allocations": [], + "totalShards": 10000 + } + } + } + ` + mockCall = mockHttpClient.On("get", CONFIG_ENDPOINT).Return(HttpResponse{Body: mockResponse2, ETag: "tag_2"}, nil) + + // fetch and store again + configRequestor.FetchAndStoreConfigurations() + + // assert that another fetch was called and deserialize was called + mockHttpClient.AssertNumberOfCalls(t, "get", 3) + assert.Equal(t, 2, configRequestor.deserializeCount) + + // assert that the new config is stored + flag, err = mockConfigStore.getConfiguration().getFlagConfiguration("empty_flag") + assert.NotNil(t, err) + flag, err = mockConfigStore.getConfiguration().getFlagConfiguration("empty_flag_2") + assert.Nil(t, err) + assert.Equal(t, "empty_flag_2", flag.Key) + mockCall.Unset() + + // change remote config back to original + mockCall = mockHttpClient.On("get", CONFIG_ENDPOINT).Return(HttpResponse{Body: mockResponse1, ETag: "tag_1"}, nil) + + // fetch and store again + configRequestor.FetchAndStoreConfigurations() + + // assert that another fetch was called and deserialize was called + mockHttpClient.AssertNumberOfCalls(t, "get", 4) + assert.Equal(t, 3, configRequestor.deserializeCount) + + flag, err = mockConfigStore.getConfiguration().getFlagConfiguration("empty_flag") + assert.Nil(t, err) + assert.Equal(t, "empty_flag", flag.Key) + flag, err = mockConfigStore.getConfiguration().getFlagConfiguration("empty_flag_2") + assert.NotNil(t, err) + mockCall.Unset() +} diff --git a/eppoclient/httpclient.go b/eppoclient/httpclient.go index 39f2586..000b9c6 100644 --- a/eppoclient/httpclient.go +++ b/eppoclient/httpclient.go @@ -10,11 +10,14 @@ import ( const REQUEST_TIMEOUT_SECONDS = time.Duration(10 * time.Second) +type HttpClientInterface interface { + get(resource string) (HttpResponse, error) +} + type httpClient struct { - baseUrl string - sdkParams SDKParams - isUnauthorized bool - client *http.Client + baseUrl string + sdkParams SDKParams + client *http.Client } type SDKParams struct { @@ -23,22 +26,26 @@ type SDKParams struct { sdkVersion string } -func newHttpClient(baseUrl string, client *http.Client, sdkParams SDKParams) *httpClient { +type HttpResponse struct { + Body string + ETag string +} + +func newHttpClient(baseUrl string, client *http.Client, sdkParams SDKParams) HttpClientInterface { var hc = &httpClient{ - baseUrl: baseUrl, - sdkParams: sdkParams, - isUnauthorized: false, - client: client, + baseUrl: baseUrl, + sdkParams: sdkParams, + client: client, } return hc } -func (hc *httpClient) get(resource string) ([]byte, error) { +func (hc *httpClient) get(resource string) (HttpResponse, error) { url := hc.baseUrl + resource req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { - return nil, err + return HttpResponse{}, err // Return empty strings and the error } req.Header.Set("Content-Type", "application/json; charset=UTF-8") @@ -59,22 +66,25 @@ func (hc *httpClient) get(resource string) ([]byte, error) { // error. // // We should almost never expect to see this condition be executed. - return nil, err + return HttpResponse{}, err // Return empty strings and the error } defer resp.Body.Close() if resp.StatusCode == 401 { - hc.isUnauthorized = true - return nil, fmt.Errorf("unauthorized access") + return HttpResponse{}, fmt.Errorf("unauthorized access") // Return an error indicating unauthorized access } if resp.StatusCode >= 500 { - return nil, fmt.Errorf("server error: %d", resp.StatusCode) + return HttpResponse{}, fmt.Errorf("server error: %d", resp.StatusCode) // Handle server errors (status code > 500) } b, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("server error: unreadable body") + return HttpResponse{}, fmt.Errorf("server error: unreadable body") // Return empty strings and the error } - return b, nil + + return HttpResponse{ + Body: string(b), + ETag: resp.Header.Get("ETag"), // Capture the ETag header + }, nil } diff --git a/eppoclient/httpclient_test.go b/eppoclient/httpclient_test.go index 81862ae..61d59d3 100644 --- a/eppoclient/httpclient_test.go +++ b/eppoclient/httpclient_test.go @@ -1,7 +1,6 @@ package eppoclient import ( - "bytes" "net/http" "net/http/httptest" "testing" @@ -11,6 +10,7 @@ func TestHttpClientGet(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/test": + w.Header().Set("ETag", "testETag") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`OK`)) case "/unauthorized": @@ -40,12 +40,14 @@ func TestHttpClientGet(t *testing.T) { name string resource string expectedError string - expectedResult []byte + expectedResult string + expectedETag string }{ { name: "api returns http 200", resource: "/test", - expectedResult: []byte("OK"), + expectedResult: "OK", + expectedETag: "testETag", }, { name: "api returns 401 unauthorized error", @@ -71,15 +73,21 @@ func TestHttpClientGet(t *testing.T) { if err.Error() != tc.expectedError { t.Errorf("Expected error %v, got %v", tc.expectedError, err) } - if result != nil { // Check if result is not an empty []byte when an error is expected - t.Errorf("Expected result to be an empty string when there is an error, got %v", result) + if result.Body != "" { // Check if result is not an empty string when an error is expected + t.Errorf("Expected result to be an empty string when there is an error, got %v", result.Body) + } + if result.ETag != "" { // Check if result is not an empty string when an error is expected + t.Errorf("Expected ETag to be an empty string when there is an error, got %v", result.ETag) } } else { if tc.expectedError != "" { t.Errorf("Expected error %v, got nil", tc.expectedError) } - if !bytes.Equal(result, tc.expectedResult) { - t.Errorf("Expected result %v, got %v", tc.expectedResult, result) + if result.Body != tc.expectedResult { + t.Errorf("Expected result %v, got %v", tc.expectedResult, result.Body) + } + if result.ETag != tc.expectedETag { + t.Errorf("Expected ETag %v, got %v", tc.expectedETag, result.ETag) } } }) diff --git a/eppoclient/initclient.go b/eppoclient/initclient.go index eb53385..a6a7b86 100644 --- a/eppoclient/initclient.go +++ b/eppoclient/initclient.go @@ -14,7 +14,7 @@ func InitClient(config Config) *EppoClient { httpClient := newHttpClient(config.BaseUrl, &http.Client{Timeout: REQUEST_TIMEOUT_SECONDS}, sdkParams) configStore := newConfigurationStore(configuration{}) - requestor := newConfigurationRequestor(*httpClient, configStore) + requestor := newConfigurationRequestor(httpClient, configStore, config.SkipDeserializeAndUpdateFlagConfigIfUnchanged) assignmentLogger := config.AssignmentLogger poller := newPoller(config.PollerInterval, requestor.FetchAndStoreConfigurations)