Skip to content

Commit fea5882

Browse files
committed
fix: paginate environment-document for local evaluation
1 parent 017af04 commit fea5882

5 files changed

Lines changed: 354 additions & 23 deletions

File tree

client.go

Lines changed: 101 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package flagsmith
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"log/slog"
78
"net/http"
9+
"net/url"
810
"reflect"
911
"runtime"
1012
"strings"
@@ -14,6 +16,7 @@ import (
1416
"github.com/Flagsmith/flagsmith-go-client/v5/flagengine"
1517
"github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval"
1618
"github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments"
19+
"github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities"
1720
"github.com/Flagsmith/flagsmith-go-client/v5/flagengine/segments"
1821
"github.com/go-resty/resty/v2"
1922
)
@@ -433,28 +436,61 @@ func (c *Client) pollThenStartRealtime(ctx context.Context) {
433436

434437
func (c *Client) UpdateEnvironment(ctx context.Context) error {
435438
var env environments.EnvironmentModel
436-
resp, err := c.client.NewRequest().
437-
SetContext(ctx).
438-
SetResult(&env).
439-
ForceContentType("application/json").
440-
Get(c.config.baseURL + "environment-document/")
439+
nextPage := ""
440+
pageCount := 0
441+
var totalOverridesBytes int
441442

442-
if err != nil {
443-
msg := fmt.Sprintf("flagsmith: error performing request to Flagsmith API: %s", err)
444-
f := &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()}
445-
if c.errorHandler != nil {
446-
c.errorHandler(f)
443+
for {
444+
var page environments.EnvironmentModel
445+
req := c.client.NewRequest().
446+
SetContext(ctx).
447+
SetResult(&page).
448+
ForceContentType("application/json")
449+
if nextPage != "" {
450+
req = req.SetQueryParam("page_id", nextPage)
447451
}
448-
return f
449-
}
450-
if resp.StatusCode() != 200 {
451-
msg := fmt.Sprintf("flagsmith: unexpected response from Flagsmith API: %s", resp.Status())
452-
f := &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()}
453-
if c.errorHandler != nil {
454-
c.errorHandler(f)
452+
453+
resp, err := req.Get(c.config.baseURL + "environment-document/")
454+
if err != nil {
455+
msg := fmt.Sprintf("flagsmith: error performing request to Flagsmith API: %s", err)
456+
f := &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()}
457+
if c.errorHandler != nil {
458+
c.errorHandler(f)
459+
}
460+
return f
461+
}
462+
if resp.StatusCode() != 200 {
463+
msg := fmt.Sprintf("flagsmith: unexpected response from Flagsmith API: %s", resp.Status())
464+
f := &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()}
465+
if c.errorHandler != nil {
466+
c.errorHandler(f)
467+
}
468+
return f
469+
}
470+
471+
pageCount++
472+
nextPage = c.ExtractNextPage(resp.Header().Get("link"))
473+
474+
if pageCount == 1 {
475+
env = page
476+
} else {
477+
ok, newTotal := c.checkEnvironmentMemoryAlloc(page.IdentityOverrides, pageCount, totalOverridesBytes)
478+
if !ok {
479+
break
480+
}
481+
totalOverridesBytes = newTotal
482+
env.IdentityOverrides = append(env.IdentityOverrides, page.IdentityOverrides...)
483+
}
484+
485+
if nextPage == "" {
486+
break
487+
}
488+
if c.config.localEvaluationPageLimit > 0 && pageCount >= c.config.localEvaluationPageLimit {
489+
c.log.Debug("page limit reached, stopping pagination", "limit", c.config.localEvaluationPageLimit)
490+
break
455491
}
456-
return f
457492
}
493+
458494
isNew := false
459495
previousEnv := c.environment.Load()
460496
if previousEnv == nil || env.UpdatedAt.After(previousEnv.(*environments.EnvironmentModel).UpdatedAt) {
@@ -469,5 +505,52 @@ func (c *Client) UpdateEnvironment(ctx context.Context) error {
469505
c.log.Info("environment updated", "environment", env.APIKey, "updated_at", env.UpdatedAt)
470506
}
471507

508+
c.log.Debug("IdentityOverrides", "len", len(env.IdentityOverrides))
509+
472510
return nil
473511
}
512+
513+
// ExtractNextPage parses the Link header from the environment-document API and
514+
// returns the decoded page_id value when a next page exists, or empty string otherwise.
515+
// Expected format: </api/v1/environment-document/?page_id=xxx>; rel="next"
516+
func (c *Client) ExtractNextPage(linkHeader string) string {
517+
parts := strings.SplitN(linkHeader, ">", 2)
518+
if len(parts) == 0 {
519+
return ""
520+
}
521+
522+
u, err := url.Parse(strings.TrimPrefix(parts[0], "<"))
523+
if err != nil {
524+
return ""
525+
}
526+
527+
pageID := u.Query().Get("page_id")
528+
c.log.Debug("environment-document next page", "link", linkHeader, "page_id", pageID)
529+
530+
return pageID
531+
}
532+
533+
// checkEnvironmentMemoryAlloc checks whether appending a new page's identity overrides
534+
// would exceed the configured byte limit. Returns ok=false and the unchanged
535+
// accumulated total when the limit would be breached; otherwise returns ok=true
536+
// and the updated total. When no limit is configured (0) it always returns ok=true.
537+
func (c *Client) checkEnvironmentMemoryAlloc(overrides []*identities.IdentityModel, pageCount, accumulated int) (ok bool, newTotal int) {
538+
if c.config.localEvaluationMemoryAllocBytes == 0 {
539+
return true, accumulated
540+
}
541+
542+
pageBytes, err := json.Marshal(overrides)
543+
if err != nil {
544+
return true, accumulated
545+
}
546+
newTotal = accumulated + len(pageBytes)
547+
if newTotal > c.config.localEvaluationMemoryAllocBytes {
548+
c.log.Warn("memory limit reached, skipping page",
549+
"page", pageCount,
550+
"limit_bytes", c.config.localEvaluationMemoryAllocBytes,
551+
"accumulated_bytes", accumulated,
552+
)
553+
return false, accumulated
554+
}
555+
return true, newTotal
556+
}

client_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1236,3 +1236,148 @@ func TestCustomClientOptionsShoudPanic(t *testing.T) {
12361236
})
12371237
}
12381238
}
1239+
1240+
func TestExtractNextPage(t *testing.T) {
1241+
client := flagsmith.NewClient("test-key")
1242+
1243+
testCases := []struct {
1244+
name string
1245+
header string
1246+
expected string
1247+
}{
1248+
{
1249+
name: "valid link header with encoded page_id",
1250+
header: "</api/v1/environment-document/?page_id=" + fixtures.PageIDEncoded + ">; rel=\"next\"",
1251+
expected: fixtures.PageID,
1252+
},
1253+
{
1254+
name: "empty header returns empty string",
1255+
header: "",
1256+
expected: "",
1257+
},
1258+
{
1259+
name: "header without page_id returns empty string",
1260+
header: "</api/v1/environment-document/>; rel=\"next\"",
1261+
expected: "",
1262+
},
1263+
}
1264+
1265+
for _, tc := range testCases {
1266+
t.Run(tc.name, func(t *testing.T) {
1267+
result := client.ExtractNextPage(tc.header)
1268+
assert.Equal(t, tc.expected, result)
1269+
})
1270+
}
1271+
}
1272+
1273+
func TestUpdateEnvironmentPaginatesIdentityOverrides(t *testing.T) {
1274+
// Given
1275+
ctx := context.Background()
1276+
server := httptest.NewServer(http.HandlerFunc(fixtures.PaginatedEnvironmentDocumentHandler))
1277+
defer server.Close()
1278+
1279+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx),
1280+
flagsmith.WithBaseURL(server.URL+"/api/v1/"),
1281+
flagsmith.WithLocalEvaluationPageLimit(0))
1282+
1283+
// When
1284+
err := client.UpdateEnvironment(ctx)
1285+
1286+
// Then
1287+
assert.NoError(t, err)
1288+
1289+
// Identity from page 1 should be found
1290+
flags, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifier, nil)
1291+
assert.NoError(t, err)
1292+
enabled, err := flags.IsFeatureEnabled(fixtures.Feature1Name)
1293+
assert.NoError(t, err)
1294+
assert.False(t, enabled, "identity from page 1 should have overridden feature disabled")
1295+
1296+
// Identity from page 2 should also be found
1297+
flags2, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifierPage2, nil)
1298+
assert.NoError(t, err)
1299+
enabled2, err := flags2.IsFeatureEnabled(fixtures.Feature1Name)
1300+
assert.NoError(t, err)
1301+
assert.False(t, enabled2, "identity from page 2 should have overridden feature disabled")
1302+
}
1303+
1304+
func TestUpdateEnvironmentSinglePageNoLinkHeader(t *testing.T) {
1305+
// Given
1306+
ctx := context.Background()
1307+
server := httptest.NewServer(http.HandlerFunc(fixtures.EnvironmentDocumentHandler))
1308+
defer server.Close()
1309+
1310+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx),
1311+
flagsmith.WithBaseURL(server.URL+"/api/v1/"))
1312+
1313+
// When
1314+
err := client.UpdateEnvironment(ctx)
1315+
1316+
// Then — no pagination, identity from page 1 should still work
1317+
assert.NoError(t, err)
1318+
1319+
flags, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifier, nil)
1320+
assert.NoError(t, err)
1321+
enabled, err := flags.IsFeatureEnabled(fixtures.Feature1Name)
1322+
assert.NoError(t, err)
1323+
assert.False(t, enabled, "identity override should have feature disabled")
1324+
}
1325+
1326+
func TestUpdateEnvironmentRespectsPageLimit(t *testing.T) {
1327+
// Given: server has 2 pages but client is limited to 1 (the default)
1328+
ctx := context.Background()
1329+
server := httptest.NewServer(http.HandlerFunc(fixtures.PaginatedEnvironmentDocumentHandler))
1330+
defer server.Close()
1331+
1332+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx),
1333+
flagsmith.WithBaseURL(server.URL+"/api/v1/"),
1334+
flagsmith.WithLocalEvaluationPageLimit(1))
1335+
1336+
// When
1337+
err := client.UpdateEnvironment(ctx)
1338+
assert.NoError(t, err)
1339+
1340+
// Then: page 1 identity is present
1341+
flags, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifier, nil)
1342+
assert.NoError(t, err)
1343+
enabled, err := flags.IsFeatureEnabled(fixtures.Feature1Name)
1344+
assert.NoError(t, err)
1345+
assert.False(t, enabled)
1346+
1347+
// Page 2 identity must NOT be present — falls back to env default (enabled=true)
1348+
flags2, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifierPage2, nil)
1349+
assert.NoError(t, err)
1350+
enabled2, err := flags2.IsFeatureEnabled(fixtures.Feature1Name)
1351+
assert.NoError(t, err)
1352+
assert.True(t, enabled2, "page 2 identity should not have been loaded")
1353+
}
1354+
1355+
func TestUpdateEnvironmentRespectsMemoryAllocLimit(t *testing.T) {
1356+
// Given: memory limit of 1 byte — page 2 will always exceed it
1357+
ctx := context.Background()
1358+
server := httptest.NewServer(http.HandlerFunc(fixtures.PaginatedEnvironmentDocumentHandler))
1359+
defer server.Close()
1360+
1361+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx),
1362+
flagsmith.WithBaseURL(server.URL+"/api/v1/"),
1363+
flagsmith.WithLocalEvaluationPageLimit(0),
1364+
flagsmith.WithLocalEvaluationMemoryAllocLimit(1)) // 1 byte — guarantees page 2 is blocked
1365+
1366+
// When
1367+
err := client.UpdateEnvironment(ctx)
1368+
assert.NoError(t, err)
1369+
1370+
// Then: page 1 identity is present (memory limit only blocks appending subsequent pages)
1371+
flags, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifier, nil)
1372+
assert.NoError(t, err)
1373+
enabled, err := flags.IsFeatureEnabled(fixtures.Feature1Name)
1374+
assert.NoError(t, err)
1375+
assert.False(t, enabled)
1376+
1377+
// Page 2 identity must NOT be present — memory limit blocked the append
1378+
flags2, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifierPage2, nil)
1379+
assert.NoError(t, err)
1380+
enabled2, err := flags2.IsFeatureEnabled(fixtures.Feature1Name)
1381+
assert.NoError(t, err)
1382+
assert.True(t, enabled2, "page 2 should have been blocked by memory limit")
1383+
}

config.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,26 @@ type config struct {
2828
useRealtime bool
2929
polling bool
3030
userProvidedClient bool
31+
32+
// localEvaluationPageLimit caps how many pages of the environment-document
33+
// are fetched per update cycle. 0 means unlimited. Default is 1 (first page only)
34+
// to preserve existing behaviour.
35+
localEvaluationPageLimit int
36+
37+
// localEvaluationMemoryAllocBytes is the maximum total size (in bytes, measured
38+
// as the marshaled JSON of identity_overrides) that may be accumulated across
39+
// pages. 0 means no limit. Use multiples of 1024*1024 for MB-based limits.
40+
localEvaluationMemoryAllocBytes int
3141
}
3242

3343
// defaultConfig returns default configuration.
3444
func defaultConfig() config {
3545
return config{
36-
baseURL: DefaultBaseURL,
37-
timeout: DefaultTimeout,
38-
envRefreshInterval: time.Second * 60,
39-
realtimeBaseUrl: DefaultRealtimeBaseUrl,
40-
userProvidedClient: false,
46+
baseURL: DefaultBaseURL,
47+
timeout: DefaultTimeout,
48+
envRefreshInterval: time.Second * 60,
49+
realtimeBaseUrl: DefaultRealtimeBaseUrl,
50+
userProvidedClient: false,
51+
localEvaluationPageLimit: 1,
4152
}
4253
}

0 commit comments

Comments
 (0)