Skip to content

Commit 650c6a7

Browse files
feat: respect X-Ratelimit-Reset when retrying API requests (#434)
1 parent df5ffdf commit 650c6a7

File tree

2 files changed

+67
-3
lines changed

2 files changed

+67
-3
lines changed

internal/ld/ld.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,32 @@ func IsTransient(err error) bool {
7474
return !errors.As(err, &e)
7575
}
7676

77+
// LaunchDarkly API uses the X-Ratelimit-Reset header to communicate when to retry after a 429
78+
// Fallback to default backoff if header can't be parsed
79+
// https://apidocs.launchdarkly.com/#section/Overview/Rate-limiting
80+
// Method is curried in order to avoid stubbing the time package and fallback Backoff in unit tests
81+
func RateLimitBackoff(now func() time.Time, fallbackBackoff h.Backoff) func(time.Duration, time.Duration, int, *http.Response) time.Duration {
82+
return func(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
83+
if resp != nil {
84+
if resp.StatusCode == http.StatusTooManyRequests {
85+
if s, ok := resp.Header["X-Ratelimit-Reset"]; ok {
86+
if sleepUntil, err := strconv.ParseInt(s[0], 10, 64); err == nil {
87+
sleep := sleepUntil - now().UnixMilli()
88+
89+
if sleep > 0 {
90+
return time.Millisecond * time.Duration(sleep)
91+
} else {
92+
return time.Duration(0)
93+
}
94+
}
95+
}
96+
}
97+
}
98+
99+
return fallbackBackoff(min, max, attemptNum, resp)
100+
}
101+
}
102+
77103
func InitApiClient(options ApiOptions) ApiClient {
78104
if options.BaseUri == "" {
79105
options.BaseUri = "https://app.launchdarkly.com"
@@ -83,9 +109,7 @@ func InitApiClient(options ApiOptions) ApiClient {
83109
if options.RetryMax != nil && *options.RetryMax >= 0 {
84110
client.RetryMax = *options.RetryMax
85111
}
86-
client.RetryWaitMin = 1 * time.Second
87-
client.RetryWaitMax = 30 * time.Second
88-
client.Backoff = h.LinearJitterBackoff
112+
client.Backoff = RateLimitBackoff(time.Now, h.LinearJitterBackoff)
89113

90114
return ApiClient{
91115
ldClient: ldapi.NewAPIClient(&ldapi.Configuration{

internal/ld/ld_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"net/http/httptest"
66
"os"
77
"testing"
8+
"time"
89

10+
h "github.com/hashicorp/go-retryablehttp"
911
"github.com/stretchr/testify/require"
1012

1113
"github.com/launchdarkly/ld-find-code-refs/v2/internal/log"
@@ -234,3 +236,41 @@ func TestCountByProjectAndFlag(t *testing.T) {
234236
require.Equal(t, count, want)
235237

236238
}
239+
240+
func TestRateLimitBackoff(t *testing.T) {
241+
// Backoff instance where the time is always 0
242+
backoff := RateLimitBackoff(func() time.Time { return time.Unix(0, 0) }, h.DefaultBackoff)
243+
244+
defaultBackoff := time.Second * time.Duration(1)
245+
246+
invalidRateLimitReset := "abc"
247+
validRateLimitReset := "2000"
248+
pastRateLimitReset := "-1000"
249+
specs := []struct {
250+
name string
251+
status int
252+
rateLimitReset *string
253+
expected time.Duration
254+
}{
255+
{"falls back to default backoff due to status", http.StatusBadGateway, nil, defaultBackoff},
256+
{"falls back to default backoff due to missing header", http.StatusTooManyRequests, nil, defaultBackoff},
257+
{"falls back to default backoff due to invalid header", http.StatusTooManyRequests, &invalidRateLimitReset, defaultBackoff},
258+
{"returns difference between reset and current time", http.StatusTooManyRequests, &validRateLimitReset, time.Second * time.Duration(2)},
259+
{"returns 0 because reset is in past", http.StatusTooManyRequests, &pastRateLimitReset, time.Duration(0)},
260+
}
261+
for _, tt := range specs {
262+
t.Run(tt.name, func(t *testing.T) {
263+
resp := &http.Response{
264+
StatusCode: tt.status,
265+
Header: make(http.Header),
266+
}
267+
268+
if tt.rateLimitReset != nil {
269+
resp.Header.Set("X-Ratelimit-Reset", *tt.rateLimitReset)
270+
}
271+
272+
actual := backoff(defaultBackoff, time.Second*time.Duration(10), 0, resp)
273+
require.Equal(t, tt.expected, actual)
274+
})
275+
}
276+
}

0 commit comments

Comments
 (0)