Skip to content

Commit dc730e4

Browse files
committed
✨ feat(auth): retry installation token fetches on rate limit
Adds a single automatic retry to createInstallationToken when GitHub returns 429, or 403 with Retry-After / X-RateLimit-Reset headers. Sleep honors ctx cancellation and is capped at 60s. Errors on terminal throttle wrap ErrRateLimited so callers can branch with errors.Is. Opt out via WithRetryOnThrottle(false).
1 parent 599f97f commit dc730e4

3 files changed

Lines changed: 522 additions & 17 deletions

File tree

auth.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,24 @@ func WithContext(ctx context.Context) InstallationTokenSourceOpt {
306306
}
307307
}
308308

309+
// WithRetryOnThrottle enables or disables a single automatic retry when
310+
// GitHub returns a throttled response (HTTP 429, or 403 with rate-limit
311+
// headers) for the installation token POST. Enabled by default.
312+
//
313+
// On a throttled response the client sleeps the duration hinted by
314+
// Retry-After or x-ratelimit-reset (capped at 60s, honoring ctx cancellation)
315+
// and retries once. Subsequent failures bubble up unchanged. On a terminal
316+
// throttle the returned error wraps ErrRateLimited so callers can branch with
317+
// errors.Is.
318+
//
319+
// Disable this when the caller implements its own backoff or when deterministic
320+
// latency matters more than transient rate-limit resilience.
321+
func WithRetryOnThrottle(enabled bool) InstallationTokenSourceOpt {
322+
return func(i *installationTokenSource) {
323+
i.client.retryOnThrottle = enabled
324+
}
325+
}
326+
309327
// WithInstallationExpirySkew overrides the default early-refresh window
310328
// (DefaultExpirySkew, 30s) applied to the installation token cache returned
311329
// by NewInstallationTokenSource. Installation tokens live 1 hour, so the 30s

github.go

Lines changed: 135 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,33 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"errors"
78
"fmt"
89
"io"
910
"net/http"
1011
"net/url"
12+
"strconv"
1113
"strings"
1214
"time"
1315
)
1416

1517
const (
1618
// defaultBaseURL is the default GitHub API base URL.
1719
defaultBaseURL = "https://api.github.com/"
20+
21+
// maxRetrySleep caps sleeps between retries so a misbehaving or hostile
22+
// server cannot stall callers indefinitely via Retry-After.
23+
maxRetrySleep = 60 * time.Second
24+
25+
// defaultThrottleBackoff is the fallback delay when GitHub returns 429
26+
// without any retry hint header.
27+
defaultThrottleBackoff = 1 * time.Second
1828
)
1929

30+
// ErrRateLimited wraps errors returned when GitHub has throttled a request
31+
// (HTTP 429 or 403 with rate-limit headers). Callers can branch with errors.Is.
32+
var ErrRateLimited = errors.New("github API rate limited")
33+
2034
// InstallationTokenOptions specifies options for creating an installation token.
2135
type InstallationTokenOptions struct {
2236
// Repositories is a list of repository names that the token should have access to.
@@ -81,17 +95,19 @@ type Repository struct {
8195

8296
// githubClient is a simple GitHub API client for creating installation tokens.
8397
type githubClient struct {
84-
baseURL *url.URL
85-
client *http.Client
98+
baseURL *url.URL
99+
client *http.Client
100+
retryOnThrottle bool
86101
}
87102

88103
// newGitHubClient creates a new GitHub API client.
89104
func newGitHubClient(httpClient *http.Client) *githubClient {
90105
baseURL, _ := url.Parse(defaultBaseURL)
91106

92107
return &githubClient{
93-
baseURL: baseURL,
94-
client: httpClient,
108+
baseURL: baseURL,
109+
client: httpClient,
110+
retryOnThrottle: true,
95111
}
96112
}
97113

@@ -118,6 +134,10 @@ func (c *githubClient) withEnterpriseURL(baseURL string) (*githubClient, error)
118134
}
119135

120136
// createInstallationToken creates an installation access token for a GitHub App.
137+
// When retryOnThrottle is enabled, a single retry is performed on 429 or on 403
138+
// responses that carry Retry-After / x-ratelimit-reset headers. The sleep is
139+
// capped at maxRetrySleep and honors ctx cancellation.
140+
//
121141
// API documentation: https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app
122142
func (c *githubClient) createInstallationToken(ctx context.Context, installationID int64, opts *InstallationTokenOptions) (*InstallationToken, error) {
123143
endpoint := fmt.Sprintf("app/installations/%d/access_tokens", installationID)
@@ -126,42 +146,140 @@ func (c *githubClient) createInstallationToken(ctx context.Context, installation
126146
return nil, fmt.Errorf("failed to parse endpoint URL: %w", err)
127147
}
128148

129-
var body io.Reader
149+
var bodyBytes []byte
130150
if opts != nil {
131-
jsonBody, err := json.Marshal(opts)
151+
bodyBytes, err = json.Marshal(opts)
132152
if err != nil {
133153
return nil, fmt.Errorf("failed to marshal request body: %w", err)
134154
}
135-
body = bytes.NewReader(jsonBody)
136155
}
137156

138-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), body)
157+
token, delay, err := c.doCreateInstallationToken(ctx, u.String(), bodyBytes)
158+
if err == nil {
159+
return token, nil
160+
}
161+
if !c.retryOnThrottle || !errors.Is(err, ErrRateLimited) {
162+
return nil, err
163+
}
164+
165+
if sleepErr := sleepCtx(ctx, delay); sleepErr != nil {
166+
return nil, sleepErr
167+
}
168+
169+
token, _, err = c.doCreateInstallationToken(ctx, u.String(), bodyBytes)
170+
return token, err
171+
}
172+
173+
// doCreateInstallationToken performs a single POST attempt. On a throttled
174+
// response it returns the desired retry delay in addition to the error so the
175+
// caller can decide whether to retry. A zero delay indicates the error is not
176+
// retryable.
177+
func (c *githubClient) doCreateInstallationToken(ctx context.Context, reqURL string, bodyBytes []byte) (*InstallationToken, time.Duration, error) {
178+
var body io.Reader
179+
if bodyBytes != nil {
180+
body = bytes.NewReader(bodyBytes)
181+
}
182+
183+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, body)
139184
if err != nil {
140-
return nil, fmt.Errorf("failed to create request: %w", err)
185+
return nil, 0, fmt.Errorf("failed to create request: %w", err)
141186
}
142187

143188
req.Header.Set("Accept", "application/vnd.github+json")
144189
req.Header.Set("Content-Type", "application/json")
145190

146191
resp, err := c.client.Do(req)
147192
if err != nil {
148-
return nil, fmt.Errorf("failed to execute request: %w", err)
193+
return nil, 0, fmt.Errorf("failed to execute request: %w", err)
149194
}
150195
defer func() {
151196
_ = resp.Body.Close()
152197
}()
153198

154-
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
155-
bodyBytes, _ := io.ReadAll(resp.Body)
156-
return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(bodyBytes))
199+
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
200+
var token InstallationToken
201+
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
202+
return nil, 0, fmt.Errorf("failed to decode response: %w", err)
203+
}
204+
return &token, 0, nil
205+
}
206+
207+
bodyResp, _ := io.ReadAll(resp.Body)
208+
if delay, ok := c.throttleDelay(resp); ok {
209+
return nil, delay, fmt.Errorf("%w: GitHub API returned status %d: %s", ErrRateLimited, resp.StatusCode, string(bodyResp))
157210
}
158211

159-
var token InstallationToken
160-
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
161-
return nil, fmt.Errorf("failed to decode response: %w", err)
212+
return nil, 0, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(bodyResp))
213+
}
214+
215+
// throttleDelay inspects a non-2xx response and reports the retry hint from
216+
// GitHub's rate-limit headers. The bool is true when the response is considered
217+
// retryable (429 always, 403 only when a parseable retry header is present).
218+
// The returned duration is capped at maxRetrySleep. An unparseable header is
219+
// treated as absent — a malformed hint must not silently flip a terminal 403
220+
// into a retry.
221+
func (c *githubClient) throttleDelay(resp *http.Response) (time.Duration, bool) {
222+
if resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode != http.StatusForbidden {
223+
return 0, false
162224
}
163225

164-
return &token, nil
226+
if v := resp.Header.Get("Retry-After"); v != "" {
227+
if d, ok := parseRetryAfter(v, time.Now()); ok {
228+
return capDelay(d), true
229+
}
230+
}
231+
232+
if v := resp.Header.Get("X-RateLimit-Reset"); v != "" {
233+
if reset, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil {
234+
return capDelay(time.Until(time.Unix(reset, 0))), true
235+
}
236+
}
237+
238+
if resp.StatusCode == http.StatusTooManyRequests {
239+
return defaultThrottleBackoff, true
240+
}
241+
242+
return 0, false
243+
}
244+
245+
// parseRetryAfter accepts either integer seconds ("30") or an HTTP-date
246+
// ("Fri, 31 Dec 1999 23:59:59 GMT"), per RFC 7231 §7.1.3. The bool is false
247+
// when the value is neither form, so callers can distinguish "no hint" from
248+
// "zero seconds".
249+
func parseRetryAfter(v string, now time.Time) (time.Duration, bool) {
250+
v = strings.TrimSpace(v)
251+
if secs, err := strconv.Atoi(v); err == nil {
252+
return time.Duration(secs) * time.Second, true
253+
}
254+
if t, err := http.ParseTime(v); err == nil {
255+
return t.Sub(now), true
256+
}
257+
return 0, false
258+
}
259+
260+
func capDelay(d time.Duration) time.Duration {
261+
if d > maxRetrySleep {
262+
return maxRetrySleep
263+
}
264+
if d < 0 {
265+
return 0
266+
}
267+
return d
268+
}
269+
270+
// sleepCtx sleeps for d or until ctx is cancelled, whichever comes first.
271+
func sleepCtx(ctx context.Context, d time.Duration) error {
272+
if d <= 0 {
273+
return nil
274+
}
275+
t := time.NewTimer(d)
276+
defer t.Stop()
277+
select {
278+
case <-ctx.Done():
279+
return ctx.Err()
280+
case <-t.C:
281+
return nil
282+
}
165283
}
166284

167285
// Ptr is a helper function to create a pointer to a value.

0 commit comments

Comments
 (0)