Skip to content

Commit cc4c775

Browse files
authored
fix(intercom): retry transient read failures
1 parent d156495 commit cc4c775

3 files changed

Lines changed: 134 additions & 29 deletions

File tree

internal/cli/cli.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ type commandContext struct {
4444
stderr io.Writer
4545
}
4646

47-
const liveHTTPTimeout = 30 * time.Second
47+
const liveHTTPTimeout = 90 * time.Second
4848

4949
func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
5050
var cli app
@@ -272,10 +272,12 @@ func (cmd syncCmd) Run(ctx commandContext) error {
272272
}
273273
defer lck.Release()
274274
client := intercom.Client{
275-
BaseURL: config.IntercomBaseURL(),
276-
Token: config.IntercomToken(),
277-
Version: config.IntercomVersion(),
278-
HTTPClient: &http.Client{Timeout: liveHTTPTimeout},
275+
BaseURL: config.IntercomBaseURL(),
276+
Token: config.IntercomToken(),
277+
Version: config.IntercomVersion(),
278+
HTTPClient: &http.Client{Timeout: liveHTTPTimeout},
279+
MaxAttempts: 3,
280+
RetryBackoff: 2 * time.Second,
279281
Sleep: func(ctx context.Context, d time.Duration) error {
280282
timer := time.NewTimer(d)
281283
defer timer.Stop()

internal/intercom/client.go

Lines changed: 89 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"errors"
78
"fmt"
89
"io"
910
"net/http"
@@ -24,6 +25,8 @@ type Client struct {
2425
Sleep func(context.Context, time.Duration) error
2526
Now func() time.Time
2627
ThrottleBelow int
28+
MaxAttempts int
29+
RetryBackoff time.Duration
2730
}
2831

2932
type ConversationListItem struct {
@@ -178,48 +181,93 @@ func (c Client) doJSON(ctx context.Context, method, path string, query url.Value
178181
if len(query) > 0 {
179182
u.RawQuery = query.Encode()
180183
}
181-
var body io.Reader
184+
var bodyData []byte
182185
if requestBody != nil {
183186
data, err := json.Marshal(requestBody)
184187
if err != nil {
185188
return err
186189
}
187-
body = bytes.NewReader(data)
190+
bodyData = data
188191
}
189-
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
190-
if err != nil {
191-
return err
192-
}
193-
req.Header.Set("Accept", "application/json")
194-
req.Header.Set("Content-Type", "application/json")
195192
version := c.Version
196193
if version == "" {
197194
version = DefaultAPIVersion
198195
}
199-
req.Header.Set("Intercom-Version", version)
200-
if c.Token != "" {
201-
req.Header.Set("Authorization", "Bearer "+c.Token)
202-
}
203196
client := c.HTTPClient
204197
if client == nil {
205198
client = http.DefaultClient
206199
}
207-
resp, err := client.Do(req)
208-
if err != nil {
209-
return err
200+
attempts := c.MaxAttempts
201+
if attempts <= 0 {
202+
attempts = 1
210203
}
211-
defer resp.Body.Close()
212-
if resp.StatusCode == http.StatusTooManyRequests {
213-
return RateLimitError{StatusCode: resp.StatusCode, RetryAfter: retryAfter(resp.Header, c.now())}
204+
for attempt := 1; attempt <= attempts; attempt++ {
205+
var body io.Reader
206+
if bodyData != nil {
207+
body = bytes.NewReader(bodyData)
208+
}
209+
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
210+
if err != nil {
211+
return err
212+
}
213+
req.Header.Set("Accept", "application/json")
214+
req.Header.Set("Content-Type", "application/json")
215+
req.Header.Set("Intercom-Version", version)
216+
if c.Token != "" {
217+
req.Header.Set("Authorization", "Bearer "+c.Token)
218+
}
219+
resp, err := client.Do(req)
220+
if err != nil {
221+
if attempt < attempts && shouldRetryError(err) {
222+
if sleepErr := c.sleep(ctx, retryDelay(c.RetryBackoff, attempt)); sleepErr != nil {
223+
return sleepErr
224+
}
225+
continue
226+
}
227+
return err
228+
}
229+
if resp.StatusCode == http.StatusTooManyRequests {
230+
delay := retryAfter(resp.Header, c.now())
231+
resp.Body.Close()
232+
return RateLimitError{StatusCode: resp.StatusCode, RetryAfter: delay}
233+
}
234+
if resp.StatusCode >= 500 && resp.StatusCode <= 599 && attempt < attempts {
235+
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
236+
resp.Body.Close()
237+
if sleepErr := c.sleep(ctx, retryDelay(c.RetryBackoff, attempt)); sleepErr != nil {
238+
return sleepErr
239+
}
240+
continue
241+
}
242+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
243+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
244+
resp.Body.Close()
245+
return HTTPStatusError{Method: method, Path: path, StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
246+
}
247+
if err := c.maybeThrottle(ctx, resp.Header); err != nil {
248+
resp.Body.Close()
249+
return err
250+
}
251+
err = json.NewDecoder(resp.Body).Decode(responseBody)
252+
resp.Body.Close()
253+
return err
214254
}
215-
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
216-
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
217-
return HTTPStatusError{Method: method, Path: path, StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
255+
return nil
256+
}
257+
258+
func shouldRetryError(err error) bool {
259+
var netErr interface{ Timeout() bool }
260+
return errors.As(err, &netErr) && netErr.Timeout()
261+
}
262+
263+
func retryDelay(base time.Duration, attempt int) time.Duration {
264+
if base <= 0 {
265+
base = time.Second
218266
}
219-
if err := c.maybeThrottle(ctx, resp.Header); err != nil {
220-
return err
267+
if attempt <= 1 {
268+
return base
221269
}
222-
return json.NewDecoder(resp.Body).Decode(responseBody)
270+
return time.Duration(attempt) * base
223271
}
224272

225273
func decodeEntitiesPage(raw json.RawMessage, keys ...string) ([]Entity, string, error) {
@@ -357,6 +405,23 @@ func (c Client) maybeThrottle(ctx context.Context, header http.Header) error {
357405
return c.Sleep(ctx, delay)
358406
}
359407

408+
func (c Client) sleep(ctx context.Context, delay time.Duration) error {
409+
if delay <= 0 {
410+
return nil
411+
}
412+
if c.Sleep != nil {
413+
return c.Sleep(ctx, delay)
414+
}
415+
timer := time.NewTimer(delay)
416+
defer timer.Stop()
417+
select {
418+
case <-ctx.Done():
419+
return ctx.Err()
420+
case <-timer.C:
421+
return nil
422+
}
423+
}
424+
360425
func (c Client) now() time.Time {
361426
if c.Now != nil {
362427
return c.Now()

internal/intercom/client_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,44 @@ func TestRateLimitHandlingUsesResetHeader(t *testing.T) {
213213
}
214214
}
215215

216+
func TestSearchConversationsRetriesTransientServerErrors(t *testing.T) {
217+
var requests int
218+
var slept time.Duration
219+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
220+
requests++
221+
w.Header().Set("Content-Type", "application/json")
222+
if requests == 1 {
223+
http.Error(w, "try again", http.StatusBadGateway)
224+
return
225+
}
226+
w.Write([]byte(`{"conversations":[{"id":"conversation_1","updated_at":1770000000}],"pages":{"next":{}}}`))
227+
}))
228+
defer server.Close()
229+
client := Client{
230+
BaseURL: server.URL,
231+
HTTPClient: server.Client(),
232+
MaxAttempts: 2,
233+
RetryBackoff: 7 * time.Millisecond,
234+
Sleep: func(ctx context.Context, d time.Duration) error {
235+
slept = d
236+
return nil
237+
},
238+
}
239+
result, err := client.SearchConversations(context.Background(), time.Unix(1, 0), time.Unix(2, 0), "")
240+
if err != nil {
241+
t.Fatal(err)
242+
}
243+
if requests != 2 {
244+
t.Fatalf("requests = %d, want 2", requests)
245+
}
246+
if slept != 7*time.Millisecond {
247+
t.Fatalf("sleep = %s, want 7ms", slept)
248+
}
249+
if len(result.Conversations) != 1 {
250+
t.Fatalf("conversations = %#v", result.Conversations)
251+
}
252+
}
253+
216254
func TestLowRemainingBudgetSleepsUntilReset(t *testing.T) {
217255
now := time.Unix(100, 0)
218256
var slept time.Duration

0 commit comments

Comments
 (0)