Skip to content
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
42 changes: 19 additions & 23 deletions internal/dev_server/adapters/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ func GetApi(ctx context.Context) Api {
//go:generate go run go.uber.org/mock/mockgen -destination mocks/api.go -package mocks . Api
type Api interface {
GetSdkKey(ctx context.Context, projectKey, environmentKey string) (string, error)
GetAllFlags(ctx context.Context, projectKey string) ([]ldapi.FeatureFlag, error)
GetFlag(ctx context.Context, projectKey, flagKey string) (ldapi.FeatureFlag, error)
// GetFlagsPage returns one page of flags plus the project's total flag count.
GetFlagsPage(ctx context.Context, projectKey string, limit, offset int64) (flags []ldapi.FeatureFlag, total int, err error)
GetProjectEnvironments(ctx context.Context, projectKey string, query string, limit *int) ([]ldapi.Environment, error)
}

Expand All @@ -45,13 +47,24 @@ func (a apiClientApi) GetSdkKey(ctx context.Context, projectKey, environmentKey
return environment.ApiKey, nil
}

func (a apiClientApi) GetAllFlags(ctx context.Context, projectKey string) ([]ldapi.FeatureFlag, error) {
log.Printf("Fetching all flags for project '%s'", projectKey)
flags, err := a.getFlags(ctx, projectKey, nil)
func (a apiClientApi) GetFlag(ctx context.Context, projectKey, flagKey string) (ldapi.FeatureFlag, error) {
flag, err := internal.Retry429s(a.apiClient.FeatureFlagsApi.GetFeatureFlag(ctx, projectKey, flagKey).Execute)
if err != nil {
err = errors.Wrap(err, "unable to get all flags from LD API")
return ldapi.FeatureFlag{}, errors.Wrapf(err, "unable to get flag '%s' from LD API", flagKey)
}
return flags, err
return *flag, nil
}

func (a apiClientApi) GetFlagsPage(ctx context.Context, projectKey string, limit, offset int64) ([]ldapi.FeatureFlag, int, error) {
query := a.apiClient.FeatureFlagsApi.GetFeatureFlags(ctx, projectKey).
Filter("purpose:all+!(holdout)").
Limit(limit).
Offset(offset)
flags, err := internal.Retry429s(query.Execute)
if err != nil {
return nil, 0, errors.Wrapf(err, "unable to get flags page (offset %d) from LD API", offset)
}
return flags.Items, int(flags.GetTotalCount()), nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Plausible (mechanics verified) — totalCount is optional and defaults to 0, which terminally marks flags 101+ unresolvable. TotalCount is *int32 with omitempty (the repo's own ld-openapi.json requires only items/_links), and GetTotalCount() returns 0 when nil — there's no GetTotalCountOk check or len(Items) fallback. If it's ever 0 on a >100-flag project, the offset loop in FillVariationNames never runs, incomplete stays false, markUnresolvableVariations permanently rewrites flags 101+ to unresolvable-*, and the pending gate blocks every future fill.

Same terminal outcome for flags that shift across page boundaries mid-fill (concurrent fixed offsets against a total snapshotted once): skipped flags are marked unresolvable with no retry. The deleted GetPaginatedItems followed _links.next and had neither failure mode. A cheap guard (GetTotalCountOk, or continue-while-page-is-full) would close this.

}

func (a apiClientApi) GetProjectEnvironments(ctx context.Context, projectKey string, query string, limit *int) ([]ldapi.Environment, error) {
Expand All @@ -63,23 +76,6 @@ func (a apiClientApi) GetProjectEnvironments(ctx context.Context, projectKey str
return environments, err
}

func (a apiClientApi) getFlags(ctx context.Context, projectKey string, href *string) ([]ldapi.FeatureFlag, error) {
return internal.GetPaginatedItems(ctx, projectKey, href, func(ctx context.Context, projectKey string, limit, offset *int64) (flags *ldapi.FeatureFlags, err error) {
// loop until we do not get rate limited
query := a.apiClient.FeatureFlagsApi.GetFeatureFlags(ctx, projectKey).Limit(100)
query = query.Filter("purpose:all+!(holdout)")

if limit != nil {
query = query.Limit(*limit)
}

if offset != nil {
query = query.Offset(*offset)
}
return internal.Retry429s(query.Execute)
})
}

func (a apiClientApi) getEnvironments(ctx context.Context, projectKey string, href *string, query string, limit *int) ([]ldapi.Environment, error) {
request := a.apiClient.EnvironmentsApi.GetEnvironmentsByProject(ctx, projectKey)

Expand Down
60 changes: 0 additions & 60 deletions internal/dev_server/adapters/internal/api_util.go
Original file line number Diff line number Diff line change
@@ -1,74 +1,14 @@
package internal

import (
"context"
"log"
"net/http"
"net/url"
"strconv"
"time"

"github.com/launchdarkly/api-client-go/v14"
"github.com/pkg/errors"
)

func GetPaginatedItems[T any, R interface {
GetItems() []T
GetLinks() map[string]ldapi.Link
}](ctx context.Context, projectKey string, href *string, fetchFunc func(context.Context, string, *int64, *int64) (R, error)) ([]T, error) {
var result R
var err error

if href == nil {
result, err = fetchFunc(ctx, projectKey, nil, nil)
if err != nil {
return nil, err
}
} else {
limit, offset, err := parseHref(*href)
if err != nil {
return nil, errors.Wrapf(err, "unable to parse href for next link: %s", *href)
}
result, err = fetchFunc(ctx, projectKey, &limit, &offset)
if err != nil {
return nil, err
}
}

items := result.GetItems()

if links := result.GetLinks(); links != nil {
if next, ok := links["next"]; ok && next.Href != nil {
newItems, err := GetPaginatedItems(ctx, projectKey, next.Href, fetchFunc)
if err != nil {
return nil, err
}
items = append(items, newItems...)
}
}

return items, nil
}

func parseHref(href string) (limit, offset int64, err error) {
parsedUrl, err := url.Parse(href)
if err != nil {
return
}
l, err := strconv.Atoi(parsedUrl.Query().Get("limit"))
if err != nil {
return
}
o, err := strconv.Atoi(parsedUrl.Query().Get("offset"))
if err != nil {
return
}

limit = int64(l)
offset = int64(o)
return
}

//go:generate go run go.uber.org/mock/mockgen -destination mocks.go -package internal . MockableTime
type MockableTime interface {
Sleep(duration time.Duration)
Expand Down
141 changes: 0 additions & 141 deletions internal/dev_server/adapters/internal/api_util_test.go
Original file line number Diff line number Diff line change
@@ -1,151 +1,14 @@
package internal

import (
"context"
"net/http"
"testing"
"time"

ldapi "github.com/launchdarkly/api-client-go/v14"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
)

type testItem struct {
ID string
}

type testResult struct {
items []testItem
links map[string]ldapi.Link
}

func (r testResult) GetItems() []testItem {
return r.items
}

func (r testResult) GetLinks() map[string]ldapi.Link {
return r.links
}

func TestGetPaginatedItems(t *testing.T) {
ctx := context.Background()
projectKey := "test-project"

testCases := []struct {
name string
fetchResponses []testResult
expectedItems []testItem
expectedError bool
}{
{
name: "Single page",
fetchResponses: []testResult{
{
items: []testItem{{ID: "1"}, {ID: "2"}},
links: map[string]ldapi.Link{},
},
},
expectedItems: []testItem{{ID: "1"}, {ID: "2"}},
},
{
name: "Multiple pages",
fetchResponses: []testResult{
{
items: []testItem{{ID: "1"}, {ID: "2"}},
links: map[string]ldapi.Link{
"next": {Href: strPtr("http://example.com?limit=2&offset=2")},
},
},
{
items: []testItem{{ID: "3"}, {ID: "4"}},
links: map[string]ldapi.Link{},
},
},
expectedItems: []testItem{{ID: "1"}, {ID: "2"}, {ID: "3"}, {ID: "4"}},
},
{
name: "Error on second page",
fetchResponses: []testResult{
{
items: []testItem{{ID: "1"}, {ID: "2"}},
links: map[string]ldapi.Link{
"next": {Href: strPtr("http://example.com?limit=2&offset=2")},
},
},
},
expectedError: true,
},
{
name: "Empty response",
fetchResponses: []testResult{
{
items: []testItem{},
links: map[string]ldapi.Link{},
},
},
expectedItems: []testItem{},
},
{
name: "Multiple pages with varying item counts",
fetchResponses: []testResult{
{
items: []testItem{{ID: "1"}, {ID: "2"}, {ID: "3"}},
links: map[string]ldapi.Link{
"next": {Href: strPtr("http://example.com?limit=3&offset=3")},
},
},
{
items: []testItem{{ID: "4"}, {ID: "5"}},
links: map[string]ldapi.Link{
"next": {Href: strPtr("http://example.com?limit=3&offset=5")},
},
},
{
items: []testItem{{ID: "6"}},
links: map[string]ldapi.Link{},
},
},
expectedItems: []testItem{{ID: "1"}, {ID: "2"}, {ID: "3"}, {ID: "4"}, {ID: "5"}, {ID: "6"}},
},
{
name: "Invalid next link",
fetchResponses: []testResult{
{
items: []testItem{{ID: "1"}, {ID: "2"}},
links: map[string]ldapi.Link{
"next": {Href: strPtr("invalid-url")},
},
},
},
expectedError: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
callCount := 0
fetchFunc := func(ctx context.Context, projectKey string, limit, offset *int64) (testResult, error) {
if callCount >= len(tc.fetchResponses) {
return testResult{}, assert.AnError
}
result := tc.fetchResponses[callCount]
callCount++
return result, nil
}

items, err := GetPaginatedItems(ctx, projectKey, nil, fetchFunc)

if tc.expectedError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedItems, items)
}
})
}
}

func TestRetry429s(t *testing.T) {
t.Run("it should call exactly once if not a 429", func(t *testing.T) {
called := 0
Expand Down Expand Up @@ -183,7 +46,3 @@ func TestRetry429s(t *testing.T) {
assert.Equal(t, 2, called)
})
}

func strPtr(s string) *string {
return &s
}
30 changes: 23 additions & 7 deletions internal/dev_server/adapters/mocks/api.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions internal/dev_server/adapters/mocks/sdk.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading