Skip to content

Commit cf40483

Browse files
fix: paginate environment-document for local evaluation (#204)
1 parent d407307 commit cf40483

3 files changed

Lines changed: 297 additions & 18 deletions

File tree

client.go

Lines changed: 74 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"log/slog"
77
"net/http"
8+
"net/url"
89
"reflect"
910
"runtime"
1011
"strings"
@@ -432,29 +433,54 @@ func (c *Client) pollThenStartRealtime(ctx context.Context) {
432433
}
433434

434435
func (c *Client) UpdateEnvironment(ctx context.Context) error {
436+
start := time.Now()
437+
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
441441

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)
442+
for {
443+
var page environments.EnvironmentModel
444+
req := c.client.NewRequest().
445+
SetContext(ctx).
446+
SetResult(&page).
447+
ForceContentType("application/json")
448+
if nextPage != "" {
449+
req = req.SetQueryParam("page_id", nextPage)
447450
}
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)
451+
452+
resp, err := req.Get(c.config.baseURL + "environment-document/")
453+
if err != nil {
454+
msg := fmt.Sprintf("flagsmith: error performing request to Flagsmith API: %s", err)
455+
f := &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()}
456+
if c.errorHandler != nil {
457+
c.errorHandler(f)
458+
}
459+
return f
460+
}
461+
if resp.StatusCode() != 200 {
462+
msg := fmt.Sprintf("flagsmith: unexpected response from Flagsmith API: %s", resp.Status())
463+
f := &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()}
464+
if c.errorHandler != nil {
465+
c.errorHandler(f)
466+
}
467+
return f
468+
}
469+
470+
pageCount++
471+
nextPage = c.ExtractNextPage(resp.Header().Get("link"))
472+
473+
if pageCount == 1 {
474+
env = page
475+
} else {
476+
env.IdentityOverrides = append(env.IdentityOverrides, page.IdentityOverrides...)
477+
}
478+
479+
if nextPage == "" {
480+
break
455481
}
456-
return f
457482
}
483+
458484
isNew := false
459485
previousEnv := c.environment.Load()
460486
if previousEnv == nil || env.UpdatedAt.After(previousEnv.(*environments.EnvironmentModel).UpdatedAt) {
@@ -469,5 +495,35 @@ func (c *Client) UpdateEnvironment(ctx context.Context) error {
469495
c.log.Info("environment updated", "environment", env.APIKey, "updated_at", env.UpdatedAt)
470496
}
471497

498+
c.log.Debug("IdentityOverrides", "len", len(env.IdentityOverrides))
499+
500+
if elapsed := time.Since(start); c.config.envRefreshInterval > 0 && elapsed > c.config.envRefreshInterval {
501+
c.log.Warn(
502+
"fetching environment took longer than the configured refresh interval; raise WithEnvironmentRefreshInterval or trim the environment",
503+
"elapsed", elapsed,
504+
"refresh_interval", c.config.envRefreshInterval,
505+
)
506+
}
507+
472508
return nil
473509
}
510+
511+
// ExtractNextPage parses the Link header from the environment-document API and
512+
// returns the decoded page_id value when a next page exists, or empty string otherwise.
513+
// Expected format: </api/v1/environment-document/?page_id=xxx>; rel="next".
514+
func (c *Client) ExtractNextPage(linkHeader string) string {
515+
parts := strings.SplitN(linkHeader, ">", 2)
516+
if len(parts) == 0 {
517+
return ""
518+
}
519+
520+
u, err := url.Parse(strings.TrimPrefix(parts[0], "<"))
521+
if err != nil {
522+
return ""
523+
}
524+
525+
pageID := u.Query().Get("page_id")
526+
c.log.Debug("environment-document next page", "link", linkHeader, "page_id", pageID)
527+
528+
return pageID
529+
}

client_test.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1236,3 +1236,158 @@ 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+
1282+
// When
1283+
err := client.UpdateEnvironment(ctx)
1284+
1285+
// Then
1286+
assert.NoError(t, err)
1287+
1288+
// Identity from page 1 should be found
1289+
flags, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifier, nil)
1290+
assert.NoError(t, err)
1291+
enabled, err := flags.IsFeatureEnabled(fixtures.Feature1Name)
1292+
assert.NoError(t, err)
1293+
assert.False(t, enabled, "identity from page 1 should have overridden feature disabled")
1294+
1295+
// Identity from page 2 should also be found
1296+
flags2, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifierPage2, nil)
1297+
assert.NoError(t, err)
1298+
enabled2, err := flags2.IsFeatureEnabled(fixtures.Feature1Name)
1299+
assert.NoError(t, err)
1300+
assert.False(t, enabled2, "identity from page 2 should have overridden feature disabled")
1301+
}
1302+
1303+
func TestUpdateEnvironmentSinglePageNoLinkHeader(t *testing.T) {
1304+
// Given
1305+
ctx := context.Background()
1306+
server := httptest.NewServer(http.HandlerFunc(fixtures.EnvironmentDocumentHandler))
1307+
defer server.Close()
1308+
1309+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx),
1310+
flagsmith.WithBaseURL(server.URL+"/api/v1/"))
1311+
1312+
// When
1313+
err := client.UpdateEnvironment(ctx)
1314+
1315+
// Then — no pagination, identity from page 1 should still work
1316+
assert.NoError(t, err)
1317+
1318+
flags, err := client.GetIdentityFlags(ctx, fixtures.OverriddenIdentifier, nil)
1319+
assert.NoError(t, err)
1320+
enabled, err := flags.IsFeatureEnabled(fixtures.Feature1Name)
1321+
assert.NoError(t, err)
1322+
assert.False(t, enabled, "identity override should have feature disabled")
1323+
}
1324+
1325+
func TestUpdateEnvironmentLogsWarningWhenSlowerThanRefreshInterval(t *testing.T) {
1326+
// Given: handler delays the response so the fetch takes longer than the
1327+
// refresh interval; we capture logs to assert the warning is emitted.
1328+
ctx := context.Background()
1329+
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
1330+
time.Sleep(20 * time.Millisecond)
1331+
fixtures.EnvironmentDocumentHandler(rw, req)
1332+
}))
1333+
defer server.Close()
1334+
1335+
var logOutput strings.Builder
1336+
var logMu sync.Mutex
1337+
slogLogger := slog.New(slog.NewTextHandler(writerFunc(func(p []byte) (n int, err error) {
1338+
logMu.Lock()
1339+
defer logMu.Unlock()
1340+
return logOutput.Write(p)
1341+
}), &slog.HandlerOptions{Level: slog.LevelWarn}))
1342+
1343+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey,
1344+
flagsmith.WithSlogLogger(slogLogger),
1345+
flagsmith.WithBaseURL(server.URL+"/api/v1/"),
1346+
flagsmith.WithEnvironmentRefreshInterval(1*time.Millisecond))
1347+
1348+
// When
1349+
err := client.UpdateEnvironment(ctx)
1350+
1351+
// Then
1352+
assert.NoError(t, err)
1353+
1354+
logMu.Lock()
1355+
logStr := logOutput.String()
1356+
logMu.Unlock()
1357+
1358+
assert.Contains(t, logStr, "fetching environment took longer than the configured refresh interval")
1359+
assert.Contains(t, logStr, "refresh_interval=1ms")
1360+
assert.Contains(t, logStr, "elapsed=")
1361+
}
1362+
1363+
func TestUpdateEnvironmentDoesNotLogWarningWhenWithinRefreshInterval(t *testing.T) {
1364+
// Given
1365+
ctx := context.Background()
1366+
server := httptest.NewServer(http.HandlerFunc(fixtures.EnvironmentDocumentHandler))
1367+
defer server.Close()
1368+
1369+
var logOutput strings.Builder
1370+
var logMu sync.Mutex
1371+
slogLogger := slog.New(slog.NewTextHandler(writerFunc(func(p []byte) (n int, err error) {
1372+
logMu.Lock()
1373+
defer logMu.Unlock()
1374+
return logOutput.Write(p)
1375+
}), &slog.HandlerOptions{Level: slog.LevelWarn}))
1376+
1377+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey,
1378+
flagsmith.WithSlogLogger(slogLogger),
1379+
flagsmith.WithBaseURL(server.URL+"/api/v1/"),
1380+
flagsmith.WithEnvironmentRefreshInterval(10*time.Second))
1381+
1382+
// When
1383+
err := client.UpdateEnvironment(ctx)
1384+
1385+
// Then
1386+
assert.NoError(t, err)
1387+
1388+
logMu.Lock()
1389+
logStr := logOutput.String()
1390+
logMu.Unlock()
1391+
1392+
assert.NotContains(t, logStr, "fetching environment took longer")
1393+
}

fixtures/fixture.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ const Feature1ID = 1
1414
const Feature1OverriddenValue = "some-overridden-value"
1515
const ClientAPIKey = "B62qaMZNwfiqT76p38ggrQ"
1616

17+
const OverriddenIdentifier = "overridden-id"
18+
const OverriddenIdentifierPage2 = "overridden-id-page2"
19+
const PageID = "identity_override:1:00000000-0000-0000-0000-000000000001"
20+
const PageIDEncoded = "identity_override%3A1%3A00000000-0000-0000-0000-000000000001"
21+
1722
const EnvironmentJson = `
1823
{
1924
"api_key": "B62qaMZNwfiqT76p38ggrQ",
@@ -206,6 +211,40 @@ const IdentityResponseJson = `
206211
207212
`
208213

214+
// EnvironmentJsonPage2 contains only identity_overrides — the base environment fields
215+
// are irrelevant for subsequent pages since only IdentityOverrides are merged.
216+
const EnvironmentJsonPage2 = `
217+
{
218+
"api_key": "B62qaMZNwfiqT76p38ggrQ",
219+
"updated_at": "2023-12-06T10:21:54.079725Z",
220+
"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": []},
221+
"segment_overrides": [],
222+
"id": 1,
223+
"feature_states": [],
224+
"identity_overrides": [
225+
{
226+
"identifier": "overridden-id-page2",
227+
"identity_uuid": "1a2b3c4d-5e6f-7890-abcd-ef1234567890",
228+
"created_date": "2019-08-27T14:53:45.698555Z",
229+
"updated_at": "2023-07-14 16:12:00.000000",
230+
"environment_api_key": "B62qaMZNwfiqT76p38ggrQ",
231+
"identity_features": [
232+
{
233+
"id": 1,
234+
"feature": {"id": 1, "name": "feature_1", "type": "STANDARD"},
235+
"featurestate_uuid": "00000000-0000-0000-0000-000000000002",
236+
"feature_state_value": "some-overridden-value",
237+
"enabled": false,
238+
"environment": 1,
239+
"identity": null,
240+
"feature_segment": null
241+
}
242+
]
243+
}
244+
]
245+
}
246+
`
247+
209248
func EnvironmentDocumentHandler(rw http.ResponseWriter, req *http.Request) {
210249
if req.URL.Path != "/api/v1/environment-document/" {
211250
panic("Wrong path")
@@ -223,6 +262,35 @@ func EnvironmentDocumentHandler(rw http.ResponseWriter, req *http.Request) {
223262
}
224263
}
225264

265+
// PaginatedEnvironmentDocumentHandler serves two pages of environment document.
266+
// Page 1 includes a Link header pointing to page 2. Page 2 has no Link header.
267+
func PaginatedEnvironmentDocumentHandler(rw http.ResponseWriter, req *http.Request) {
268+
if req.URL.Path != "/api/v1/environment-document/" {
269+
panic("Wrong path")
270+
}
271+
if req.Header.Get("X-Environment-Key") != EnvironmentAPIKey {
272+
panic("Wrong API key")
273+
}
274+
275+
rw.Header().Set("Content-Type", "application/json")
276+
277+
if req.URL.Query().Get("page_id") == "" {
278+
rw.Header().Set("link", "</api/v1/environment-document/?page_id="+PageIDEncoded+">; rel=\"next\"")
279+
rw.WriteHeader(http.StatusOK)
280+
_, err := io.WriteString(rw, EnvironmentJson)
281+
if err != nil {
282+
panic(err)
283+
}
284+
return
285+
}
286+
287+
rw.WriteHeader(http.StatusOK)
288+
_, err := io.WriteString(rw, EnvironmentJsonPage2)
289+
if err != nil {
290+
panic(err)
291+
}
292+
}
293+
226294
func FlagsAPIHandlerWithInternalServerError(rw http.ResponseWriter, req *http.Request) {
227295
if req.URL.Path != "/api/v1/flags/" {
228296
panic("Wrong path")

0 commit comments

Comments
 (0)