Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: [do not deserialize json objects or update local cache if remote response has not changed from the previous one] (FF-2576) #56

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ func main() {
}
```


#### Assign anywhere

```go
Expand Down Expand Up @@ -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. |
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we want it configurable? Is there any reason for a user to ever disable it?


## 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.
4 changes: 0 additions & 4 deletions eppoclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions eppoclient/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want it enabled by default?

}

func (cfg *Config) validate() {
Expand Down
34 changes: 23 additions & 11 deletions eppoclient/configurationrequestor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only for testing validation

skipDeserializeAndUpdateFlagConfigIfUnchanged: skipDeserializeAndUpdateFlagConfigIfUnchanged,
}
}

func (cr *configurationRequestor) IsAuthorized() bool {
return !cr.httpClient.isUnauthorized
}

func (cr *configurationRequestor) FetchAndStoreConfigurations() {
configuration, err := cr.fetchConfiguration()
if err != nil {
Expand Down Expand Up @@ -59,8 +60,19 @@ func (cr *configurationRequestor) fetchConfig() (configResponse, error) {
return configResponse{}, err
}

if cr.skipDeserializeAndUpdateFlagConfigIfUnchanged {
if result.ETag == cr.storedUFCResponseETag {
Comment on lines +63 to +64
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use If-None-Match header on the request, so the server can respond with 304 Not Modified and skip sending the body, instead of doing this check on the client side

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)
Expand All @@ -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)
Expand Down
110 changes: 108 additions & 2 deletions eppoclient/configurationrequestor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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()

Expand All @@ -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()

Expand All @@ -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()
}
44 changes: 27 additions & 17 deletions eppoclient/httpclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,22 +26,26 @@ type SDKParams struct {
sdkVersion string
}

func newHttpClient(baseUrl string, client *http.Client, sdkParams SDKParams) *httpClient {
type HttpResponse struct {
Body string
Copy link
Collaborator

@rasendubi rasendubi Jul 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be []byte to avoid copying response bytes when converting to/from string.

Suggested change
Body string
Body []byte

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")

Expand All @@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am learning that there are two types but ETag (capital T) is the canonical form: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag

The Eppo Fastly CDN seems to return Etag prefix with a W which is a "weak" tag. I think this is because by default express, the web server underlying NestJS, adds this by default.

Before we release this, Eppo's web server might need to be updated to return "strong" etags.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weak ETag is fine for our purposes as we don't care about byte-by-byte identity—we only need the json value to match.

}, nil
}
22 changes: 15 additions & 7 deletions eppoclient/httpclient_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package eppoclient

import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
Expand All @@ -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":
Expand Down Expand Up @@ -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",
Expand All @@ -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)
}
}
})
Expand Down
Loading
Loading