From fea58828c483a44830fff121ce4cfbc02e8194b2 Mon Sep 17 00:00:00 2001 From: higordasneves Date: Mon, 27 Apr 2026 22:21:27 -0300 Subject: [PATCH 1/3] fix: paginate environment-document for local evaluation --- client.go | 119 ++++++++++++++++++++++++++++++------ client_test.go | 145 ++++++++++++++++++++++++++++++++++++++++++++ config.go | 21 +++++-- fixtures/fixture.go | 68 +++++++++++++++++++++ options.go | 24 ++++++++ 5 files changed, 354 insertions(+), 23 deletions(-) diff --git a/client.go b/client.go index adef7aa9..0e41a5b9 100644 --- a/client.go +++ b/client.go @@ -2,9 +2,11 @@ package flagsmith import ( "context" + "encoding/json" "fmt" "log/slog" "net/http" + "net/url" "reflect" "runtime" "strings" @@ -14,6 +16,7 @@ import ( "github.com/Flagsmith/flagsmith-go-client/v5/flagengine" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/segments" "github.com/go-resty/resty/v2" ) @@ -433,28 +436,61 @@ func (c *Client) pollThenStartRealtime(ctx context.Context) { func (c *Client) UpdateEnvironment(ctx context.Context) error { var env environments.EnvironmentModel - resp, err := c.client.NewRequest(). - SetContext(ctx). - SetResult(&env). - ForceContentType("application/json"). - Get(c.config.baseURL + "environment-document/") + nextPage := "" + pageCount := 0 + var totalOverridesBytes int - if err != nil { - msg := fmt.Sprintf("flagsmith: error performing request to Flagsmith API: %s", err) - f := &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()} - if c.errorHandler != nil { - c.errorHandler(f) + for { + var page environments.EnvironmentModel + req := c.client.NewRequest(). + SetContext(ctx). + SetResult(&page). + ForceContentType("application/json") + if nextPage != "" { + req = req.SetQueryParam("page_id", nextPage) } - return f - } - if resp.StatusCode() != 200 { - msg := fmt.Sprintf("flagsmith: unexpected response from Flagsmith API: %s", resp.Status()) - f := &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()} - if c.errorHandler != nil { - c.errorHandler(f) + + resp, err := req.Get(c.config.baseURL + "environment-document/") + if err != nil { + msg := fmt.Sprintf("flagsmith: error performing request to Flagsmith API: %s", err) + f := &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()} + if c.errorHandler != nil { + c.errorHandler(f) + } + return f + } + if resp.StatusCode() != 200 { + msg := fmt.Sprintf("flagsmith: unexpected response from Flagsmith API: %s", resp.Status()) + f := &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()} + if c.errorHandler != nil { + c.errorHandler(f) + } + return f + } + + pageCount++ + nextPage = c.ExtractNextPage(resp.Header().Get("link")) + + if pageCount == 1 { + env = page + } else { + ok, newTotal := c.checkEnvironmentMemoryAlloc(page.IdentityOverrides, pageCount, totalOverridesBytes) + if !ok { + break + } + totalOverridesBytes = newTotal + env.IdentityOverrides = append(env.IdentityOverrides, page.IdentityOverrides...) + } + + if nextPage == "" { + break + } + if c.config.localEvaluationPageLimit > 0 && pageCount >= c.config.localEvaluationPageLimit { + c.log.Debug("page limit reached, stopping pagination", "limit", c.config.localEvaluationPageLimit) + break } - return f } + isNew := false previousEnv := c.environment.Load() if previousEnv == nil || env.UpdatedAt.After(previousEnv.(*environments.EnvironmentModel).UpdatedAt) { @@ -469,5 +505,52 @@ func (c *Client) UpdateEnvironment(ctx context.Context) error { c.log.Info("environment updated", "environment", env.APIKey, "updated_at", env.UpdatedAt) } + c.log.Debug("IdentityOverrides", "len", len(env.IdentityOverrides)) + return nil } + +// ExtractNextPage parses the Link header from the environment-document API and +// returns the decoded page_id value when a next page exists, or empty string otherwise. +// Expected format: ; rel="next" +func (c *Client) ExtractNextPage(linkHeader string) string { + parts := strings.SplitN(linkHeader, ">", 2) + if len(parts) == 0 { + return "" + } + + u, err := url.Parse(strings.TrimPrefix(parts[0], "<")) + if err != nil { + return "" + } + + pageID := u.Query().Get("page_id") + c.log.Debug("environment-document next page", "link", linkHeader, "page_id", pageID) + + return pageID +} + +// checkEnvironmentMemoryAlloc checks whether appending a new page's identity overrides +// would exceed the configured byte limit. Returns ok=false and the unchanged +// accumulated total when the limit would be breached; otherwise returns ok=true +// and the updated total. When no limit is configured (0) it always returns ok=true. +func (c *Client) checkEnvironmentMemoryAlloc(overrides []*identities.IdentityModel, pageCount, accumulated int) (ok bool, newTotal int) { + if c.config.localEvaluationMemoryAllocBytes == 0 { + return true, accumulated + } + + pageBytes, err := json.Marshal(overrides) + if err != nil { + return true, accumulated + } + newTotal = accumulated + len(pageBytes) + if newTotal > c.config.localEvaluationMemoryAllocBytes { + c.log.Warn("memory limit reached, skipping page", + "page", pageCount, + "limit_bytes", c.config.localEvaluationMemoryAllocBytes, + "accumulated_bytes", accumulated, + ) + return false, accumulated + } + return true, newTotal +} diff --git a/client_test.go b/client_test.go index aec2a1db..cd53fc57 100644 --- a/client_test.go +++ b/client_test.go @@ -1236,3 +1236,148 @@ func TestCustomClientOptionsShoudPanic(t *testing.T) { }) } } + +func TestExtractNextPage(t *testing.T) { + client := flagsmith.NewClient("test-key") + + testCases := []struct { + name string + header string + expected string + }{ + { + name: "valid link header with encoded page_id", + header: "; rel=\"next\"", + expected: fixtures.PageID, + }, + { + name: "empty header returns empty string", + header: "", + expected: "", + }, + { + name: "header without page_id returns empty string", + header: "; rel=\"next\"", + expected: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := client.ExtractNextPage(tc.header) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestUpdateEnvironmentPaginatesIdentityOverrides(t *testing.T) { + // Given + ctx := context.Background() + server := httptest.NewServer(http.HandlerFunc(fixtures.PaginatedEnvironmentDocumentHandler)) + defer server.Close() + + client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), + flagsmith.WithBaseURL(server.URL+"/api/v1/"), + flagsmith.WithLocalEvaluationPageLimit(0)) + + // When + err := client.UpdateEnvironment(ctx) + + // Then + assert.NoError(t, err) + + // Identity from page 1 should be found + flags, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifier, nil) + assert.NoError(t, err) + enabled, err := flags.IsFeatureEnabled(fixtures.Feature1Name) + assert.NoError(t, err) + assert.False(t, enabled, "identity from page 1 should have overridden feature disabled") + + // Identity from page 2 should also be found + flags2, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifierPage2, nil) + assert.NoError(t, err) + enabled2, err := flags2.IsFeatureEnabled(fixtures.Feature1Name) + assert.NoError(t, err) + assert.False(t, enabled2, "identity from page 2 should have overridden feature disabled") +} + +func TestUpdateEnvironmentSinglePageNoLinkHeader(t *testing.T) { + // Given + ctx := context.Background() + server := httptest.NewServer(http.HandlerFunc(fixtures.EnvironmentDocumentHandler)) + defer server.Close() + + client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), + flagsmith.WithBaseURL(server.URL+"/api/v1/")) + + // When + err := client.UpdateEnvironment(ctx) + + // Then — no pagination, identity from page 1 should still work + assert.NoError(t, err) + + flags, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifier, nil) + assert.NoError(t, err) + enabled, err := flags.IsFeatureEnabled(fixtures.Feature1Name) + assert.NoError(t, err) + assert.False(t, enabled, "identity override should have feature disabled") +} + +func TestUpdateEnvironmentRespectsPageLimit(t *testing.T) { + // Given: server has 2 pages but client is limited to 1 (the default) + ctx := context.Background() + server := httptest.NewServer(http.HandlerFunc(fixtures.PaginatedEnvironmentDocumentHandler)) + defer server.Close() + + client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), + flagsmith.WithBaseURL(server.URL+"/api/v1/"), + flagsmith.WithLocalEvaluationPageLimit(1)) + + // When + err := client.UpdateEnvironment(ctx) + assert.NoError(t, err) + + // Then: page 1 identity is present + flags, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifier, nil) + assert.NoError(t, err) + enabled, err := flags.IsFeatureEnabled(fixtures.Feature1Name) + assert.NoError(t, err) + assert.False(t, enabled) + + // Page 2 identity must NOT be present — falls back to env default (enabled=true) + flags2, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifierPage2, nil) + assert.NoError(t, err) + enabled2, err := flags2.IsFeatureEnabled(fixtures.Feature1Name) + assert.NoError(t, err) + assert.True(t, enabled2, "page 2 identity should not have been loaded") +} + +func TestUpdateEnvironmentRespectsMemoryAllocLimit(t *testing.T) { + // Given: memory limit of 1 byte — page 2 will always exceed it + ctx := context.Background() + server := httptest.NewServer(http.HandlerFunc(fixtures.PaginatedEnvironmentDocumentHandler)) + defer server.Close() + + client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), + flagsmith.WithBaseURL(server.URL+"/api/v1/"), + flagsmith.WithLocalEvaluationPageLimit(0), + flagsmith.WithLocalEvaluationMemoryAllocLimit(1)) // 1 byte — guarantees page 2 is blocked + + // When + err := client.UpdateEnvironment(ctx) + assert.NoError(t, err) + + // Then: page 1 identity is present (memory limit only blocks appending subsequent pages) + flags, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifier, nil) + assert.NoError(t, err) + enabled, err := flags.IsFeatureEnabled(fixtures.Feature1Name) + assert.NoError(t, err) + assert.False(t, enabled) + + // Page 2 identity must NOT be present — memory limit blocked the append + flags2, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifierPage2, nil) + assert.NoError(t, err) + enabled2, err := flags2.IsFeatureEnabled(fixtures.Feature1Name) + assert.NoError(t, err) + assert.True(t, enabled2, "page 2 should have been blocked by memory limit") +} diff --git a/config.go b/config.go index 859a520b..1d942358 100644 --- a/config.go +++ b/config.go @@ -28,15 +28,26 @@ type config struct { useRealtime bool polling bool userProvidedClient bool + + // localEvaluationPageLimit caps how many pages of the environment-document + // are fetched per update cycle. 0 means unlimited. Default is 1 (first page only) + // to preserve existing behaviour. + localEvaluationPageLimit int + + // localEvaluationMemoryAllocBytes is the maximum total size (in bytes, measured + // as the marshaled JSON of identity_overrides) that may be accumulated across + // pages. 0 means no limit. Use multiples of 1024*1024 for MB-based limits. + localEvaluationMemoryAllocBytes int } // defaultConfig returns default configuration. func defaultConfig() config { return config{ - baseURL: DefaultBaseURL, - timeout: DefaultTimeout, - envRefreshInterval: time.Second * 60, - realtimeBaseUrl: DefaultRealtimeBaseUrl, - userProvidedClient: false, + baseURL: DefaultBaseURL, + timeout: DefaultTimeout, + envRefreshInterval: time.Second * 60, + realtimeBaseUrl: DefaultRealtimeBaseUrl, + userProvidedClient: false, + localEvaluationPageLimit: 1, } } diff --git a/fixtures/fixture.go b/fixtures/fixture.go index 76b1170b..b899f6ad 100644 --- a/fixtures/fixture.go +++ b/fixtures/fixture.go @@ -14,6 +14,11 @@ const Feature1ID = 1 const Feature1OverriddenValue = "some-overridden-value" const ClientAPIKey = "B62qaMZNwfiqT76p38ggrQ" +const OverriddenIdentifier = "overridden-id" +const OverriddenIdentifierPage2 = "overridden-id-page2" +const PageID = "identity_override:1:00000000-0000-0000-0000-000000000001" +const PageIDEncoded = "identity_override%3A1%3A00000000-0000-0000-0000-000000000001" + const EnvironmentJson = ` { "api_key": "B62qaMZNwfiqT76p38ggrQ", @@ -206,6 +211,40 @@ const IdentityResponseJson = ` ` +// EnvironmentJsonPage2 contains only identity_overrides — the base environment fields +// are irrelevant for subsequent pages since only IdentityOverrides are merged. +const EnvironmentJsonPage2 = ` +{ + "api_key": "B62qaMZNwfiqT76p38ggrQ", + "updated_at": "2023-12-06T10:21:54.079725Z", + "project": {"name": "Test project", "organisation": {"feature_analytics": false, "name": "Test Org", "id": 1, "persist_trait_data": true, "stop_serving_flags": false}, "id": 1, "hide_disabled_flags": false, "segments": []}, + "segment_overrides": [], + "id": 1, + "feature_states": [], + "identity_overrides": [ + { + "identifier": "overridden-id-page2", + "identity_uuid": "1a2b3c4d-5e6f-7890-abcd-ef1234567890", + "created_date": "2019-08-27T14:53:45.698555Z", + "updated_at": "2023-07-14 16:12:00.000000", + "environment_api_key": "B62qaMZNwfiqT76p38ggrQ", + "identity_features": [ + { + "id": 1, + "feature": {"id": 1, "name": "feature_1", "type": "STANDARD"}, + "featurestate_uuid": "00000000-0000-0000-0000-000000000002", + "feature_state_value": "some-overridden-value", + "enabled": false, + "environment": 1, + "identity": null, + "feature_segment": null + } + ] + } + ] +} +` + func EnvironmentDocumentHandler(rw http.ResponseWriter, req *http.Request) { if req.URL.Path != "/api/v1/environment-document/" { panic("Wrong path") @@ -223,6 +262,35 @@ func EnvironmentDocumentHandler(rw http.ResponseWriter, req *http.Request) { } } +// PaginatedEnvironmentDocumentHandler serves two pages of environment document. +// Page 1 includes a Link header pointing to page 2. Page 2 has no Link header. +func PaginatedEnvironmentDocumentHandler(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/api/v1/environment-document/" { + panic("Wrong path") + } + if req.Header.Get("X-Environment-Key") != EnvironmentAPIKey { + panic("Wrong API key") + } + + rw.Header().Set("Content-Type", "application/json") + + if req.URL.Query().Get("page_id") == "" { + rw.Header().Set("link", "; rel=\"next\"") + rw.WriteHeader(http.StatusOK) + _, err := io.WriteString(rw, EnvironmentJson) + if err != nil { + panic(err) + } + return + } + + rw.WriteHeader(http.StatusOK) + _, err := io.WriteString(rw, EnvironmentJsonPage2) + if err != nil { + panic(err) + } +} + func FlagsAPIHandlerWithInternalServerError(rw http.ResponseWriter, req *http.Request) { if req.URL.Path != "/api/v1/flags/" { panic("Wrong path") diff --git a/options.go b/options.go index 3b6436c5..66deb48e 100644 --- a/options.go +++ b/options.go @@ -37,6 +37,8 @@ var _ = []Option{ WithSlogLogger(nil), WithRestyClient(nil), WithHTTPClient(nil), + WithLocalEvaluationPageLimit(0), + WithLocalEvaluationMemoryAllocLimit(0), } func WithBaseURL(url string) Option { @@ -181,6 +183,28 @@ func WithRealtimeBaseURL(url string) Option { } } +// WithLocalEvaluationPageLimit sets the maximum number of environment-document +// pages fetched per update cycle. 0 means unlimited. Default is 1 (first page +// only), which preserves existing behaviour for users that have not opted in to +// pagination. +func WithLocalEvaluationPageLimit(limit int) Option { + return func(c *Client) { + c.config.localEvaluationPageLimit = limit + } +} + +// WithLocalEvaluationMemoryAllocLimit sets an upper bound (in bytes) on the +// total size of identity_overrides accumulated across pages. Size is estimated +// from the marshaled JSON of each page's identity_overrides slice. When adding +// a page would exceed the limit the page is skipped and pagination stops. 0 +// means no limit. For MB-based limits use multiples of 1024*1024, e.g. +// (50 * 1024 * 1024) for 50 MB. +func WithLocalEvaluationMemoryAllocLimit(bytes int) Option { + return func(c *Client) { + c.config.localEvaluationMemoryAllocBytes = bytes + } +} + // WithPolling makes it so that the client will poll for updates even when WithRealtime is used. func WithPolling() Option { return func(c *Client) { From aafc96e9e906a9bae8ef0b09d2a4e0930ffc2f16 Mon Sep 17 00:00:00 2001 From: higordasneves Date: Tue, 28 Apr 2026 17:44:21 -0300 Subject: [PATCH 2/3] fix: always paginate environment-document and warn on slow fetches Follow Link: rel="next" until exhaustion instead of gating pagination behind opt-in limits, so local evaluation matches remote evaluation on large environments by default. When a refresh takes longer than the configured interval, log a warning naming both durations so operators know to raise WithEnvironmentRefreshInterval or trim the environment. --- client.go | 47 ++++++---------------------- client_test.go | 84 ++++++++++++++++++++++++++++---------------------- config.go | 21 +++---------- options.go | 24 --------------- 4 files changed, 62 insertions(+), 114 deletions(-) diff --git a/client.go b/client.go index 0e41a5b9..5120c85d 100644 --- a/client.go +++ b/client.go @@ -2,7 +2,6 @@ package flagsmith import ( "context" - "encoding/json" "fmt" "log/slog" "net/http" @@ -16,7 +15,6 @@ import ( "github.com/Flagsmith/flagsmith-go-client/v5/flagengine" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" - "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/segments" "github.com/go-resty/resty/v2" ) @@ -435,10 +433,11 @@ func (c *Client) pollThenStartRealtime(ctx context.Context) { } func (c *Client) UpdateEnvironment(ctx context.Context) error { + start := time.Now() + var env environments.EnvironmentModel nextPage := "" pageCount := 0 - var totalOverridesBytes int for { var page environments.EnvironmentModel @@ -474,21 +473,12 @@ func (c *Client) UpdateEnvironment(ctx context.Context) error { if pageCount == 1 { env = page } else { - ok, newTotal := c.checkEnvironmentMemoryAlloc(page.IdentityOverrides, pageCount, totalOverridesBytes) - if !ok { - break - } - totalOverridesBytes = newTotal env.IdentityOverrides = append(env.IdentityOverrides, page.IdentityOverrides...) } if nextPage == "" { break } - if c.config.localEvaluationPageLimit > 0 && pageCount >= c.config.localEvaluationPageLimit { - c.log.Debug("page limit reached, stopping pagination", "limit", c.config.localEvaluationPageLimit) - break - } } isNew := false @@ -507,6 +497,14 @@ func (c *Client) UpdateEnvironment(ctx context.Context) error { c.log.Debug("IdentityOverrides", "len", len(env.IdentityOverrides)) + if elapsed := time.Since(start); c.config.envRefreshInterval > 0 && elapsed > c.config.envRefreshInterval { + c.log.Warn( + "fetching environment took longer than the configured refresh interval; raise WithEnvironmentRefreshInterval or trim the environment", + "elapsed", elapsed, + "refresh_interval", c.config.envRefreshInterval, + ) + } + return nil } @@ -529,28 +527,3 @@ func (c *Client) ExtractNextPage(linkHeader string) string { return pageID } - -// checkEnvironmentMemoryAlloc checks whether appending a new page's identity overrides -// would exceed the configured byte limit. Returns ok=false and the unchanged -// accumulated total when the limit would be breached; otherwise returns ok=true -// and the updated total. When no limit is configured (0) it always returns ok=true. -func (c *Client) checkEnvironmentMemoryAlloc(overrides []*identities.IdentityModel, pageCount, accumulated int) (ok bool, newTotal int) { - if c.config.localEvaluationMemoryAllocBytes == 0 { - return true, accumulated - } - - pageBytes, err := json.Marshal(overrides) - if err != nil { - return true, accumulated - } - newTotal = accumulated + len(pageBytes) - if newTotal > c.config.localEvaluationMemoryAllocBytes { - c.log.Warn("memory limit reached, skipping page", - "page", pageCount, - "limit_bytes", c.config.localEvaluationMemoryAllocBytes, - "accumulated_bytes", accumulated, - ) - return false, accumulated - } - return true, newTotal -} diff --git a/client_test.go b/client_test.go index cd53fc57..fae8d670 100644 --- a/client_test.go +++ b/client_test.go @@ -1277,8 +1277,7 @@ func TestUpdateEnvironmentPaginatesIdentityOverrides(t *testing.T) { defer server.Close() client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), - flagsmith.WithBaseURL(server.URL+"/api/v1/"), - flagsmith.WithLocalEvaluationPageLimit(0)) + flagsmith.WithBaseURL(server.URL+"/api/v1/")) // When err := client.UpdateEnvironment(ctx) @@ -1323,61 +1322,72 @@ func TestUpdateEnvironmentSinglePageNoLinkHeader(t *testing.T) { assert.False(t, enabled, "identity override should have feature disabled") } -func TestUpdateEnvironmentRespectsPageLimit(t *testing.T) { - // Given: server has 2 pages but client is limited to 1 (the default) +func TestUpdateEnvironmentLogsWarningWhenSlowerThanRefreshInterval(t *testing.T) { + // Given: handler delays the response so the fetch takes longer than the + // refresh interval; we capture logs to assert the warning is emitted. ctx := context.Background() - server := httptest.NewServer(http.HandlerFunc(fixtures.PaginatedEnvironmentDocumentHandler)) + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + time.Sleep(20 * time.Millisecond) + fixtures.EnvironmentDocumentHandler(rw, req) + })) defer server.Close() - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), + var logOutput strings.Builder + var logMu sync.Mutex + slogLogger := slog.New(slog.NewTextHandler(writerFunc(func(p []byte) (n int, err error) { + logMu.Lock() + defer logMu.Unlock() + return logOutput.Write(p) + }), &slog.HandlerOptions{Level: slog.LevelWarn})) + + client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, + flagsmith.WithSlogLogger(slogLogger), flagsmith.WithBaseURL(server.URL+"/api/v1/"), - flagsmith.WithLocalEvaluationPageLimit(1)) + flagsmith.WithEnvironmentRefreshInterval(1*time.Millisecond)) // When err := client.UpdateEnvironment(ctx) - assert.NoError(t, err) - // Then: page 1 identity is present - flags, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifier, nil) - assert.NoError(t, err) - enabled, err := flags.IsFeatureEnabled(fixtures.Feature1Name) + // Then assert.NoError(t, err) - assert.False(t, enabled) - // Page 2 identity must NOT be present — falls back to env default (enabled=true) - flags2, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifierPage2, nil) - assert.NoError(t, err) - enabled2, err := flags2.IsFeatureEnabled(fixtures.Feature1Name) - assert.NoError(t, err) - assert.True(t, enabled2, "page 2 identity should not have been loaded") + logMu.Lock() + logStr := logOutput.String() + logMu.Unlock() + + assert.Contains(t, logStr, "fetching environment took longer than the configured refresh interval") + assert.Contains(t, logStr, "refresh_interval=1ms") + assert.Contains(t, logStr, "elapsed=") } -func TestUpdateEnvironmentRespectsMemoryAllocLimit(t *testing.T) { - // Given: memory limit of 1 byte — page 2 will always exceed it +func TestUpdateEnvironmentDoesNotLogWarningWhenWithinRefreshInterval(t *testing.T) { + // Given ctx := context.Background() - server := httptest.NewServer(http.HandlerFunc(fixtures.PaginatedEnvironmentDocumentHandler)) + server := httptest.NewServer(http.HandlerFunc(fixtures.EnvironmentDocumentHandler)) defer server.Close() - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), + var logOutput strings.Builder + var logMu sync.Mutex + slogLogger := slog.New(slog.NewTextHandler(writerFunc(func(p []byte) (n int, err error) { + logMu.Lock() + defer logMu.Unlock() + return logOutput.Write(p) + }), &slog.HandlerOptions{Level: slog.LevelWarn})) + + client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, + flagsmith.WithSlogLogger(slogLogger), flagsmith.WithBaseURL(server.URL+"/api/v1/"), - flagsmith.WithLocalEvaluationPageLimit(0), - flagsmith.WithLocalEvaluationMemoryAllocLimit(1)) // 1 byte — guarantees page 2 is blocked + flagsmith.WithEnvironmentRefreshInterval(10*time.Second)) // When err := client.UpdateEnvironment(ctx) - assert.NoError(t, err) - // Then: page 1 identity is present (memory limit only blocks appending subsequent pages) - flags, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifier, nil) - assert.NoError(t, err) - enabled, err := flags.IsFeatureEnabled(fixtures.Feature1Name) + // Then assert.NoError(t, err) - assert.False(t, enabled) - // Page 2 identity must NOT be present — memory limit blocked the append - flags2, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifierPage2, nil) - assert.NoError(t, err) - enabled2, err := flags2.IsFeatureEnabled(fixtures.Feature1Name) - assert.NoError(t, err) - assert.True(t, enabled2, "page 2 should have been blocked by memory limit") + logMu.Lock() + logStr := logOutput.String() + logMu.Unlock() + + assert.NotContains(t, logStr, "fetching environment took longer") } diff --git a/config.go b/config.go index 1d942358..859a520b 100644 --- a/config.go +++ b/config.go @@ -28,26 +28,15 @@ type config struct { useRealtime bool polling bool userProvidedClient bool - - // localEvaluationPageLimit caps how many pages of the environment-document - // are fetched per update cycle. 0 means unlimited. Default is 1 (first page only) - // to preserve existing behaviour. - localEvaluationPageLimit int - - // localEvaluationMemoryAllocBytes is the maximum total size (in bytes, measured - // as the marshaled JSON of identity_overrides) that may be accumulated across - // pages. 0 means no limit. Use multiples of 1024*1024 for MB-based limits. - localEvaluationMemoryAllocBytes int } // defaultConfig returns default configuration. func defaultConfig() config { return config{ - baseURL: DefaultBaseURL, - timeout: DefaultTimeout, - envRefreshInterval: time.Second * 60, - realtimeBaseUrl: DefaultRealtimeBaseUrl, - userProvidedClient: false, - localEvaluationPageLimit: 1, + baseURL: DefaultBaseURL, + timeout: DefaultTimeout, + envRefreshInterval: time.Second * 60, + realtimeBaseUrl: DefaultRealtimeBaseUrl, + userProvidedClient: false, } } diff --git a/options.go b/options.go index 66deb48e..3b6436c5 100644 --- a/options.go +++ b/options.go @@ -37,8 +37,6 @@ var _ = []Option{ WithSlogLogger(nil), WithRestyClient(nil), WithHTTPClient(nil), - WithLocalEvaluationPageLimit(0), - WithLocalEvaluationMemoryAllocLimit(0), } func WithBaseURL(url string) Option { @@ -183,28 +181,6 @@ func WithRealtimeBaseURL(url string) Option { } } -// WithLocalEvaluationPageLimit sets the maximum number of environment-document -// pages fetched per update cycle. 0 means unlimited. Default is 1 (first page -// only), which preserves existing behaviour for users that have not opted in to -// pagination. -func WithLocalEvaluationPageLimit(limit int) Option { - return func(c *Client) { - c.config.localEvaluationPageLimit = limit - } -} - -// WithLocalEvaluationMemoryAllocLimit sets an upper bound (in bytes) on the -// total size of identity_overrides accumulated across pages. Size is estimated -// from the marshaled JSON of each page's identity_overrides slice. When adding -// a page would exceed the limit the page is skipped and pagination stops. 0 -// means no limit. For MB-based limits use multiples of 1024*1024, e.g. -// (50 * 1024 * 1024) for 50 MB. -func WithLocalEvaluationMemoryAllocLimit(bytes int) Option { - return func(c *Client) { - c.config.localEvaluationMemoryAllocBytes = bytes - } -} - // WithPolling makes it so that the client will poll for updates even when WithRealtime is used. func WithPolling() Option { return func(c *Client) { From d538df79e3e89bff8f5cad2be123c0b6eaace184 Mon Sep 17 00:00:00 2001 From: higordasneves Date: Tue, 28 Apr 2026 17:58:28 -0300 Subject: [PATCH 3/3] chore: end ExtractNextPage doc comment with a period Satisfies the godot linter rule. --- client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client.go b/client.go index 5120c85d..98732d53 100644 --- a/client.go +++ b/client.go @@ -510,7 +510,7 @@ func (c *Client) UpdateEnvironment(ctx context.Context) error { // ExtractNextPage parses the Link header from the environment-document API and // returns the decoded page_id value when a next page exists, or empty string otherwise. -// Expected format: ; rel="next" +// Expected format: ; rel="next". func (c *Client) ExtractNextPage(linkHeader string) string { parts := strings.SplitN(linkHeader, ">", 2) if len(parts) == 0 {