@@ -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
1517const (
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.
2135type 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.
8397type 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.
89104func 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
122142func (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