Skip to content

Commit e5da2e9

Browse files
author
Mike Zorn
authored
fix: Retry when there are 429s on flag metadata fetches (#446)
Retry when there are 429s
1 parent 2d648b6 commit e5da2e9

File tree

4 files changed

+221
-66
lines changed

4 files changed

+221
-66
lines changed

internal/dev_server/adapters/api.go

Lines changed: 4 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@ import (
44
"context"
55
"fmt"
66
"log"
7-
"net/url"
8-
"strconv"
97

8+
"github.com/launchdarkly/ldcli/internal/dev_server/adapters/internal"
109
"github.com/pkg/errors"
1110

1211
ldapi "github.com/launchdarkly/api-client-go/v14"
@@ -65,7 +64,8 @@ func (a apiClientApi) GetProjectEnvironments(ctx context.Context, projectKey str
6564
}
6665

6766
func (a apiClientApi) getFlags(ctx context.Context, projectKey string, href *string) ([]ldapi.FeatureFlag, error) {
68-
return getPaginatedItems(ctx, projectKey, href, func(ctx context.Context, projectKey string, limit, offset *int64) (*ldapi.FeatureFlags, error) {
67+
return internal.GetPaginatedItems(ctx, projectKey, href, func(ctx context.Context, projectKey string, limit, offset *int64) (flags *ldapi.FeatureFlags, err error) {
68+
// loop until we do not get rate limited
6969
query := a.apiClient.FeatureFlagsApi.GetFeatureFlags(ctx, projectKey).Limit(100)
7070

7171
if limit != nil {
@@ -75,10 +75,7 @@ func (a apiClientApi) getFlags(ctx context.Context, projectKey string, href *str
7575
if offset != nil {
7676
query = query.Offset(*offset)
7777
}
78-
79-
flags, _, err := query.
80-
Execute()
81-
return flags, err
78+
return internal.Retry429s(query.Execute)
8279
})
8380
}
8481

@@ -105,60 +102,3 @@ func (a apiClientApi) getEnvironments(ctx context.Context, projectKey string, hr
105102

106103
return envs.Items, nil
107104
}
108-
109-
func getPaginatedItems[T any, R interface {
110-
GetItems() []T
111-
GetLinks() map[string]ldapi.Link
112-
}](ctx context.Context, projectKey string, href *string, fetchFunc func(context.Context, string, *int64, *int64) (R, error)) ([]T, error) {
113-
var result R
114-
var err error
115-
116-
if href == nil {
117-
result, err = fetchFunc(ctx, projectKey, nil, nil)
118-
if err != nil {
119-
return nil, err
120-
}
121-
} else {
122-
limit, offset, err := parseHref(*href)
123-
if err != nil {
124-
return nil, errors.Wrapf(err, "unable to parse href for next link: %s", *href)
125-
}
126-
result, err = fetchFunc(ctx, projectKey, &limit, &offset)
127-
if err != nil {
128-
return nil, err
129-
}
130-
}
131-
132-
items := result.GetItems()
133-
134-
if links := result.GetLinks(); links != nil {
135-
if next, ok := links["next"]; ok && next.Href != nil {
136-
newItems, err := getPaginatedItems(ctx, projectKey, next.Href, fetchFunc)
137-
if err != nil {
138-
return nil, err
139-
}
140-
items = append(items, newItems...)
141-
}
142-
}
143-
144-
return items, nil
145-
}
146-
147-
func parseHref(href string) (limit, offset int64, err error) {
148-
parsedUrl, err := url.Parse(href)
149-
if err != nil {
150-
return
151-
}
152-
l, err := strconv.Atoi(parsedUrl.Query().Get("limit"))
153-
if err != nil {
154-
return
155-
}
156-
o, err := strconv.Atoi(parsedUrl.Query().Get("offset"))
157-
if err != nil {
158-
return
159-
}
160-
161-
limit = int64(l)
162-
offset = int64(o)
163-
return
164-
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package internal
2+
3+
import (
4+
"context"
5+
"log"
6+
"net/http"
7+
"net/url"
8+
"strconv"
9+
"time"
10+
11+
"github.com/launchdarkly/api-client-go/v14"
12+
"github.com/pkg/errors"
13+
)
14+
15+
func GetPaginatedItems[T any, R interface {
16+
GetItems() []T
17+
GetLinks() map[string]ldapi.Link
18+
}](ctx context.Context, projectKey string, href *string, fetchFunc func(context.Context, string, *int64, *int64) (R, error)) ([]T, error) {
19+
var result R
20+
var err error
21+
22+
if href == nil {
23+
result, err = fetchFunc(ctx, projectKey, nil, nil)
24+
if err != nil {
25+
return nil, err
26+
}
27+
} else {
28+
limit, offset, err := parseHref(*href)
29+
if err != nil {
30+
return nil, errors.Wrapf(err, "unable to parse href for next link: %s", *href)
31+
}
32+
result, err = fetchFunc(ctx, projectKey, &limit, &offset)
33+
if err != nil {
34+
return nil, err
35+
}
36+
}
37+
38+
items := result.GetItems()
39+
40+
if links := result.GetLinks(); links != nil {
41+
if next, ok := links["next"]; ok && next.Href != nil {
42+
newItems, err := GetPaginatedItems(ctx, projectKey, next.Href, fetchFunc)
43+
if err != nil {
44+
return nil, err
45+
}
46+
items = append(items, newItems...)
47+
}
48+
}
49+
50+
return items, nil
51+
}
52+
53+
func parseHref(href string) (limit, offset int64, err error) {
54+
parsedUrl, err := url.Parse(href)
55+
if err != nil {
56+
return
57+
}
58+
l, err := strconv.Atoi(parsedUrl.Query().Get("limit"))
59+
if err != nil {
60+
return
61+
}
62+
o, err := strconv.Atoi(parsedUrl.Query().Get("offset"))
63+
if err != nil {
64+
return
65+
}
66+
67+
limit = int64(l)
68+
offset = int64(o)
69+
return
70+
}
71+
72+
//go:generate go run go.uber.org/mock/mockgen -destination mocks.go -package internal . MockableTime
73+
type MockableTime interface {
74+
Sleep(duration time.Duration)
75+
Now() time.Time
76+
}
77+
78+
type realTime struct{}
79+
80+
func (realTime) Sleep(duration time.Duration) {
81+
time.Sleep(duration)
82+
}
83+
84+
func (realTime) Now() time.Time {
85+
return time.Now()
86+
}
87+
88+
var timeImpl MockableTime = realTime{}
89+
90+
func Retry429s[T any](requester func() (T, *http.Response, error)) (result T, err error) {
91+
for {
92+
var res *http.Response
93+
result, res, err = requester()
94+
if res.StatusCode == 429 {
95+
resetUnixMillisString := res.Header.Get("X-Ratelimit-Reset")
96+
resetUnixMillis, strconvErr := strconv.ParseInt(resetUnixMillisString, 10, 64)
97+
if strconvErr != nil {
98+
err = errors.Wrapf(err, `unable to retry rate limited request: X-RateLimit-Reset: "%s" was not parsable`, resetUnixMillisString)
99+
return
100+
}
101+
sleep := resetUnixMillis - timeImpl.Now().UnixMilli()
102+
log.Printf("Got 429 in API response. Retrying in %d milliseconds.", sleep)
103+
timeImpl.Sleep(time.Duration(sleep) * time.Millisecond)
104+
} else {
105+
return
106+
}
107+
}
108+
}

internal/dev_server/adapters/api_test.go renamed to internal/dev_server/adapters/internal/api_util_test.go

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
package adapters
1+
package internal
22

33
import (
44
"context"
5+
"net/http"
56
"testing"
7+
"time"
68

79
ldapi "github.com/launchdarkly/api-client-go/v14"
810
"github.com/stretchr/testify/assert"
11+
"go.uber.org/mock/gomock"
912
)
1013

1114
type testItem struct {
@@ -131,7 +134,7 @@ func TestGetPaginatedItems(t *testing.T) {
131134
return result, nil
132135
}
133136

134-
items, err := getPaginatedItems(ctx, projectKey, nil, fetchFunc)
137+
items, err := GetPaginatedItems(ctx, projectKey, nil, fetchFunc)
135138

136139
if tc.expectedError {
137140
assert.Error(t, err)
@@ -143,6 +146,44 @@ func TestGetPaginatedItems(t *testing.T) {
143146
}
144147
}
145148

149+
func TestRetry429s(t *testing.T) {
150+
t.Run("it should call exactly once if not a 429", func(t *testing.T) {
151+
called := 0
152+
res, err := Retry429s(func() (string, *http.Response, error) {
153+
called++
154+
return "lol", &http.Response{StatusCode: 200}, nil
155+
})
156+
assert.Equal(t, "lol", res)
157+
assert.NoError(t, err)
158+
assert.Equal(t, 1, called)
159+
})
160+
161+
t.Run("it should retry when a 429 is received", func(t *testing.T) {
162+
ctrl := gomock.NewController(t)
163+
timeMock := NewMockMockableTime(ctrl)
164+
defer func() { ctrl.Finish() }()
165+
timeImpl = timeMock
166+
defer func() { timeImpl = realTime{} }()
167+
timeMock.EXPECT().Now().Return(time.UnixMilli(0))
168+
timeMock.EXPECT().Sleep(time.Duration(1000) * time.Millisecond)
169+
170+
called := 0
171+
res, err := Retry429s(func() (string, *http.Response, error) {
172+
called++
173+
if called > 1 {
174+
return "lol", &http.Response{StatusCode: 200}, nil
175+
} else {
176+
header := make(http.Header)
177+
header.Set("X-Ratelimit-Reset", "1000")
178+
return "", &http.Response{StatusCode: 429, Header: header}, nil
179+
}
180+
})
181+
assert.Equal(t, "lol", res)
182+
assert.NoError(t, err)
183+
assert.Equal(t, 2, called)
184+
})
185+
}
186+
146187
func strPtr(s string) *string {
147188
return &s
148189
}

internal/dev_server/adapters/internal/mocks.go

Lines changed: 66 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)