Skip to content

Commit c3ff8e5

Browse files
committed
feat: ensure CLI will wait for auth workflow to complete and handle 429 rate limit errors with exponential back-off during polling
- Add SuppressRateLimitErrors flag to Client for configurable error logging - Implement exponential back-off (1s -> 30s max) when polling encounters 429s - Log rate limit events at DEBUG level when suppressed, ERROR otherwise - Fix nil pointer dereference bug when accessing resp.StatusCode on error - Enable rate limit suppression in login polling where 429s are expected - Add isRateLimitError() and calculateBackoff() helper functions
1 parent 8554cc5 commit c3ff8e5

File tree

2 files changed

+80
-11
lines changed

2 files changed

+80
-11
lines changed

pkg/hookdeck/client.go

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ type Client struct {
4747
// stdout.
4848
Verbose bool
4949

50+
// When this is enabled, HTTP 429 (rate limit) errors will be logged at
51+
// DEBUG level instead of ERROR level. Useful for polling scenarios where
52+
// rate limiting is expected.
53+
SuppressRateLimitErrors bool
54+
5055
// Cached HTTP client, lazily created the first time the Client is used to
5156
// send a request.
5257
httpClient *http.Client
@@ -117,20 +122,29 @@ func (c *Client) PerformRequest(ctx context.Context, req *http.Request) (*http.R
117122
"method": req.Method,
118123
"url": req.URL.String(),
119124
"error": err.Error(),
120-
"status": resp.StatusCode,
121125
}).Error("Failed to perform request")
122126
return nil, err
123127
}
124128

125129
err = checkAndPrintError(resp)
126130
if err != nil {
127-
log.WithFields(log.Fields{
128-
"prefix": "client.Client.PerformRequest 2",
129-
"method": req.Method,
130-
"url": req.URL.String(),
131-
"error": err.Error(),
132-
"status": resp.StatusCode,
133-
}).Error("Unexpected response")
131+
// Allow callers to suppress rate limit error logging for polling scenarios
132+
if c.SuppressRateLimitErrors && resp.StatusCode == http.StatusTooManyRequests {
133+
log.WithFields(log.Fields{
134+
"prefix": "client.Client.PerformRequest",
135+
"method": req.Method,
136+
"url": req.URL.String(),
137+
"status": resp.StatusCode,
138+
}).Debug("Rate limited")
139+
} else {
140+
log.WithFields(log.Fields{
141+
"prefix": "client.Client.PerformRequest 2",
142+
"method": req.Method,
143+
"url": req.URL.String(),
144+
"error": err.Error(),
145+
"status": resp.StatusCode,
146+
}).Error("Unexpected response")
147+
}
134148
return nil, err
135149
}
136150

pkg/login/poll.go

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ import (
66
"errors"
77
"io/ioutil"
88
"net/url"
9+
"strings"
910
"time"
1011

1112
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
13+
log "github.com/sirupsen/logrus"
1214
)
1315

1416
const maxAttemptsDefault = 2 * 60
15-
const intervalDefault = 1 * time.Second
17+
const intervalDefault = 2 * time.Second
18+
const maxBackoffInterval = 30 * time.Second
1619

1720
// PollAPIKeyResponse returns the data of the polling client login
1821
type PollAPIKeyResponse struct {
@@ -47,12 +50,42 @@ func PollForKey(pollURL string, interval time.Duration, maxAttempts int) (*PollA
4750
baseURL := &url.URL{Scheme: parsedURL.Scheme, Host: parsedURL.Host}
4851

4952
client := &hookdeck.Client{
50-
BaseURL: baseURL,
53+
BaseURL: baseURL,
54+
SuppressRateLimitErrors: true, // Rate limiting is expected during polling
5155
}
5256

5357
var count = 0
58+
currentInterval := interval
59+
consecutiveRateLimits := 0
60+
5461
for count < maxAttempts {
5562
res, err := client.Get(context.TODO(), parsedURL.Path, parsedURL.Query().Encode(), nil)
63+
64+
// Check if error is due to rate limiting (429)
65+
if err != nil && isRateLimitError(err) {
66+
consecutiveRateLimits++
67+
backoffInterval := calculateBackoff(currentInterval, consecutiveRateLimits)
68+
69+
log.WithFields(log.Fields{
70+
"attempt": count + 1,
71+
"max_attempts": maxAttempts,
72+
"backoff_interval": backoffInterval,
73+
"rate_limits": consecutiveRateLimits,
74+
}).Debug("Rate limited while polling, waiting before retry...")
75+
76+
time.Sleep(backoffInterval)
77+
currentInterval = backoffInterval
78+
count++
79+
continue
80+
}
81+
82+
// Reset back-off on successful request
83+
if err == nil {
84+
consecutiveRateLimits = 0
85+
currentInterval = interval
86+
}
87+
88+
// Handle other errors (non-429)
5689
if err != nil {
5790
return nil, err
5891
}
@@ -74,8 +107,30 @@ func PollForKey(pollURL string, interval time.Duration, maxAttempts int) (*PollA
74107
}
75108

76109
count++
77-
time.Sleep(interval)
110+
time.Sleep(currentInterval)
78111
}
79112

80113
return nil, errors.New("exceeded max attempts")
81114
}
115+
116+
// isRateLimitError checks if an error is a 429 rate limit error
117+
func isRateLimitError(err error) bool {
118+
if err == nil {
119+
return false
120+
}
121+
errMsg := err.Error()
122+
return strings.Contains(errMsg, "429") || strings.Contains(errMsg, "Too Many Requests")
123+
}
124+
125+
// calculateBackoff implements exponential back-off with a maximum cap
126+
func calculateBackoff(baseInterval time.Duration, consecutiveFailures int) time.Duration {
127+
// Exponential: baseInterval * 2^consecutiveFailures
128+
backoff := baseInterval * time.Duration(1<<uint(consecutiveFailures))
129+
130+
// Cap at maxBackoffInterval
131+
if backoff > maxBackoffInterval {
132+
backoff = maxBackoffInterval
133+
}
134+
135+
return backoff
136+
}

0 commit comments

Comments
 (0)