feat(github): support HTTP-date format in Retry-After header #110

Merged
aweiker merged 4 commits from review-bot-issue-94 into main 2026-05-13 15:34:26 +00:00
2 changed files with 669 additions and 0 deletions
+260
View File
@@ -0,0 +1,260 @@
// Package github provides a client for the GitHub API.
// It supports pull request operations, file content retrieval,
// and review submission for both github.com and GitHub Enterprise.
package github
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const (
defaultBaseURL = "https://api.github.com"
// maxRetryAttempts is the number of times doRequest will attempt a request.
maxRetryAttempts = 3
// maxRetryAfter caps the maximum delay from a Retry-After header to prevent
// a server from stalling the client indefinitely.
maxRetryAfter = 60 * time.Second
// maxErrorBodyBytes limits how much of an error response body we read
// to protect against malicious servers sending unbounded data.
maxErrorBodyBytes = 64 * 1024 // 64 KB
// maxResponseBodyBytes limits how much of a successful response body we read
// for defense-in-depth against servers returning excessively large payloads.
maxResponseBodyBytes = 10 * 1024 * 1024 // 10 MB
)
// APIError represents an HTTP error response from the GitHub API.
// It carries the status code so callers can distinguish between
// different failure modes (e.g. 404 vs 500).
//
Review

[NIT] The APIError.Body field is exported and documented as storing up to 64 KiB of raw response body. However Error() truncates to 200 bytes. The truncation happens after newline sanitization, meaning the sanitized string could still be 214+ chars. More importantly, truncation is applied to the local body variable but the field e.Body is not truncated — callers accessing err.(*APIError).Body directly get the full 64 KiB. This is documented in the comment, so it's intentional, but the ordering (truncate then sanitize) means a body that is exactly 200 bytes of content followed by newlines won't have those newlines sanitized if they fall within the first 200 chars. This is correct as-is but worth noting.

**[NIT]** The `APIError.Body` field is exported and documented as storing up to 64 KiB of raw response body. However `Error()` truncates to 200 bytes. The truncation happens after newline sanitization, meaning the sanitized string could still be 214+ chars. More importantly, truncation is applied to the local `body` variable but the field `e.Body` is not truncated — callers accessing `err.(*APIError).Body` directly get the full 64 KiB. This is documented in the comment, so it's intentional, but the ordering (truncate then sanitize) means a body that is exactly 200 bytes of content followed by newlines won't have those newlines sanitized if they fall within the first 200 chars. This is correct as-is but worth noting.
// The Body field stores up to 64 KiB of the raw response for programmatic
// inspection. Error() truncates to 200 bytes for safe logging, but callers
// should avoid logging or propagating Body directly in production since it may
security-review-bot marked this conversation as resolved
Review

[NIT] Error message sanitization removes newlines but other control characters or ANSI escape sequences could still facilitate log injection in some environments. Consider further sanitization (e.g., stripping non-printable characters) or using structured logging consistently to mitigate log injection risks.

**[NIT]** Error message sanitization removes newlines but other control characters or ANSI escape sequences could still facilitate log injection in some environments. Consider further sanitization (e.g., stripping non-printable characters) or using structured logging consistently to mitigate log injection risks.
// contain sensitive details from the upstream server.
type APIError struct {
StatusCode int
Body string
}
func (e *APIError) Error() string {
security-review-bot marked this conversation as resolved
Review

[MINOR] Log injection hardening only sanitizes \n and \r. Other control characters (e.g., tabs, non-printable ASCII, ANSI escape sequences) could still affect log rendering. Consider stripping or escaping a broader set of control characters before formatting the error.

**[MINOR]** Log injection hardening only sanitizes \n and \r. Other control characters (e.g., tabs, non-printable ASCII, ANSI escape sequences) could still affect log rendering. Consider stripping or escaping a broader set of control characters before formatting the error.
body := e.Body
if len(body) > 200 {
security-review-bot marked this conversation as resolved
Review

[MINOR] APIError.Error() includes up to 200 bytes of upstream response body in the error string. If callers log this error, it may leak sensitive information returned by the upstream service. Consider omitting the body from Error() or gating detailed content behind a debug flag.

**[MINOR]** APIError.Error() includes up to 200 bytes of upstream response body in the error string. If callers log this error, it may leak sensitive information returned by the upstream service. Consider omitting the body from Error() or gating detailed content behind a debug flag.
body = body[:200] + "...(truncated)"
}
// Sanitize newlines to prevent log injection from upstream response bodies.
body = strings.ReplaceAll(body, "\n", " ")
body = strings.ReplaceAll(body, "\r", " ")
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, body)
}
// IsNotFound reports whether an error is an API 404 response.
func IsNotFound(err error) bool {
if apiErr, ok := asAPIError(err); ok {
return apiErr.StatusCode == http.StatusNotFound
}
return false
}
// IsUnauthorized reports whether an error is an API 401 response.
func IsUnauthorized(err error) bool {
if apiErr, ok := asAPIError(err); ok {
return apiErr.StatusCode == http.StatusUnauthorized
}
return false
}
func asAPIError(err error) (*APIError, bool) {
if err == nil {
return nil, false
}
Review

[MINOR] asAPIError is a helper used only by IsNotFound and IsUnauthorized. These two callers could directly use errors.As inline (2 lines each), making asAPIError unnecessary indirection. By the 'internal/ Packages' pattern, if it's only used locally unexported it's fine, but it adds no abstraction value here. NIT-level — not a real problem.

**[MINOR]** `asAPIError` is a helper used only by `IsNotFound` and `IsUnauthorized`. These two callers could directly use `errors.As` inline (2 lines each), making `asAPIError` unnecessary indirection. By the 'internal/ Packages' pattern, if it's only used locally unexported it's fine, but it adds no abstraction value here. NIT-level — not a real problem.
var target *APIError
if errors.As(err, &target) {
return target, true
}
return nil, false
}
Review

[MINOR] The http field name shadows the standard net/http package import within method bodies. While Go resolves this correctly (the import is referenced as http.Client, http.StatusNotFound, etc. at package level), naming a struct field http is a recognized source of confusion and a go vet / staticcheck warning in some configurations. Prefer httpClient or hc as the field name.

**[MINOR]** The `http` field name shadows the standard `net/http` package import within method bodies. While Go resolves this correctly (the import is referenced as `http.Client`, `http.StatusNotFound`, etc. at package level), naming a struct field `http` is a recognized source of confusion and a go vet / staticcheck warning in some configurations. Prefer `httpClient` or `hc` as the field name.
// Client interacts with the GitHub API.
// A Client is safe for concurrent use by multiple goroutines.
Review

[MINOR] The now field is unexported and directly mutated in tests (c.now = func() time.Time { ... }). Per the documented pattern in concurrency.md and the conventions (SetHTTPClient / SetRetryBackoff are "test setup only" with no synchronization), this is acceptable. However, unlike SetHTTPClient and SetRetryBackoff, there is no setter for now, so tests must access the unexported field directly — which requires white-box tests in the same package. Since tests are in package github (not package github_test), this works fine. Just noting the inconsistency: the two other test-setup fields have exported setters, but now does not.

**[MINOR]** The `now` field is unexported and directly mutated in tests (`c.now = func() time.Time { ... }`). Per the documented pattern in concurrency.md and the conventions (SetHTTPClient / SetRetryBackoff are "test setup only" with no synchronization), this is acceptable. However, unlike `SetHTTPClient` and `SetRetryBackoff`, there is no setter for `now`, so tests must access the unexported field directly — which requires white-box tests in the same package. Since tests are in `package github` (not `package github_test`), this works fine. Just noting the inconsistency: the two other test-setup fields have exported setters, but `now` does not.
// SetHTTPClient and SetRetryBackoff are intended for test setup only and must
// be called before any goroutines issue requests; they have no synchronization.
type Client struct {
// TODO: baseURL is populated by NewClient but not yet consumed by doRequest/doGet.
// Higher-level exported methods (GetPullRequest, etc.) will use it to
Review

[NIT] Struct field alignment: httpClient *http.Client is not aligned with the other fields (baseURL and token). gofmt will handle tab alignment within struct definitions, but the blank line between token and httpClient creates a visual group separation that might be intentional (separating identity fields from transport config). Either way this is fine.

**[NIT]** Struct field alignment: `httpClient *http.Client` is not aligned with the other fields (`baseURL` and `token`). `gofmt` will handle tab alignment within struct definitions, but the blank line between `token` and `httpClient` creates a visual group separation that might be intentional (separating identity fields from transport config). Either way this is fine.
// construct request URLs; remove this field if those methods end up
// accepting full URLs instead.
baseURL string
token string
httpClient *http.Client
Review

[NIT] Using strings.TrimRight(baseURL, "/") removes all trailing slashes. If the intent is to remove only a single trailing slash, strings.TrimSuffix is more precise. Current behavior is acceptable but slightly broader than needed.

**[NIT]** Using strings.TrimRight(baseURL, "/") removes all trailing slashes. If the intent is to remove only a single trailing slash, strings.TrimSuffix is more precise. Current behavior is acceptable but slightly broader than needed.
// retryBackoff defines the delays between retry attempts for 429 responses.
// retryBackoff[i] is the delay before attempt i+1 (after attempt i fails).
// If nil, defaults to {1s, 2s}.
retryBackoff []time.Duration
// now returns the current time. Defaults to time.Now.
// Override in tests to control HTTP-date Retry-After calculations.
Review

[MINOR] SetHTTPClient is an exported function but lacks direct test coverage, which conflicts with the repository convention to test every exported function. Consider adding a test that injects a custom *http.Client (or Transport) and verifies it is used.

**[MINOR]** SetHTTPClient is an exported function but lacks direct test coverage, which conflicts with the repository convention to test every exported function. Consider adding a test that injects a custom *http.Client (or Transport) and verifies it is used.
now func() time.Time
}
// NewClient creates a new GitHub API client.
// If baseURL is empty, it defaults to https://api.github.com.
Review

[MINOR] parseRetryAfter does not trim surrounding whitespace from the header value before parsing. Headers can contain incidental whitespace; applying strings.TrimSpace(value) before Atoi/ParseTime would make parsing more robust.

**[MINOR]** parseRetryAfter does not trim surrounding whitespace from the header value before parsing. Headers can contain incidental whitespace; applying strings.TrimSpace(value) before Atoi/ParseTime would make parsing more robust.
// For GitHub Enterprise, pass the API base URL (e.g. https://github.concur.com/api/v3).
func NewClient(token, baseURL string) *Client {
Review

[MINOR] parseRetryAfter treats "0" seconds as invalid (seconds > 0). RFC 7231 allows delta-seconds of 0 to indicate immediate retry. Consider treating 0 as a valid value and returning a 0 duration to honor servers requesting immediate retry.

**[MINOR]** parseRetryAfter treats "0" seconds as invalid (seconds > 0). RFC 7231 allows delta-seconds of 0 to indicate immediate retry. Consider treating 0 as a valid value and returning a 0 duration to honor servers requesting immediate retry.
if baseURL == "" {
baseURL = defaultBaseURL
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
httpClient: &http.Client{Timeout: 30 * time.Second},
Review

[MINOR] parseRetryAfter uses strconv.Atoi (int) for delta-seconds; on 32-bit architectures or very large values this can overflow and fail to parse. Prefer strconv.ParseInt(value, 10, 64) to parse into int64, then convert to time.Duration and cap, ensuring architecture-independent robustness.

**[MINOR]** parseRetryAfter uses strconv.Atoi (int) for delta-seconds; on 32-bit architectures or very large values this can overflow and fail to parse. Prefer strconv.ParseInt(value, 10, 64) to parse into int64, then convert to time.Duration and cap, ensuring architecture-independent robustness.
now: time.Now,
}
}
// SetHTTPClient sets the underlying HTTP client used for requests.
// This is intended for testing to inject mock transports.
func (c *Client) SetHTTPClient(hc *http.Client) {
c.httpClient = hc
}
// SetRetryBackoff sets the delays between retry attempts.
security-review-bot marked this conversation as resolved
Review

[MINOR] Potential race condition: doRequest uses and mutates the shared c.retryBackoff slice (e.g., later at line 193) when non-nil. Since Client is intended to be safe for concurrent use, concurrent calls could race and cause unpredictable backoff behavior. Consider copying the slice per request (e.g., append([]time.Duration(nil), c.retryBackoff...)) before mutating.

**[MINOR]** Potential race condition: doRequest uses and mutates the shared c.retryBackoff slice (e.g., later at line 193) when non-nil. Since Client is intended to be safe for concurrent use, concurrent calls could race and cause unpredictable backoff behavior. Consider copying the slice per request (e.g., append([]time.Duration(nil), c.retryBackoff...)) before mutating.
Review

[MAJOR] Potential data race: backoff references c.retryBackoff and is mutated in-place later (e.g., backoff[attempt] = delay at line ~194). Since Client is documented as safe for concurrent use, concurrent doRequest calls with a non-nil c.retryBackoff will race and interfere with each other's state. Make a per-request copy of the slice (e.g., backoff = append([]time.Duration(nil), c.retryBackoff...)) or avoid mutating the slice by tracking next delay in a local variable.

**[MAJOR]** Potential data race: backoff references c.retryBackoff and is mutated in-place later (e.g., backoff[attempt] = delay at line ~194). Since Client is documented as safe for concurrent use, concurrent doRequest calls with a non-nil c.retryBackoff will race and interfere with each other's state. Make a per-request copy of the slice (e.g., backoff = append([]time.Duration(nil), c.retryBackoff...)) or avoid mutating the slice by tracking next delay in a local variable.
// This is intended for testing to speed up retry tests.
//
// Note: if an empty non-nil slice is provided, Retry-After delays parsed from
// server responses will be computed and capped but not applied (because
// attempt < len(backoff) is always false). This is acceptable for the
Review

[MINOR] doRequest accepts an arbitrary reqURL and unconditionally applies the Authorization header (line 167). If future internal callers inadvertently pass non-GitHub or user-influenced URLs (e.g., pre-signed S3 links), this could lead to SSRF and/or token leakage to third-party hosts. Consider constraining requests to c.baseURL or verifying the host before attaching Authorization.

**[MINOR]** doRequest accepts an arbitrary reqURL and unconditionally applies the Authorization header (line 167). If future internal callers inadvertently pass non-GitHub or user-influenced URLs (e.g., pre-signed S3 links), this could lead to SSRF and/or token leakage to third-party hosts. Consider constraining requests to c.baseURL or verifying the host before attaching Authorization.
// test-only use case but callers should be aware of this edge case.
func (c *Client) SetRetryBackoff(backoff []time.Duration) {
c.retryBackoff = backoff
}
// parseRetryAfter parses a Retry-After header value, supporting both integer
// seconds (e.g. "120") and HTTP-date format (e.g. "Thu, 01 Dec 2025 16:00:00 GMT")
// as specified in RFC 7231 §7.1.3.
//
// For integer values, it returns the duration directly.
// For HTTP-date values, it computes the delay as the difference between the
// parsed time and now. If the date is in the past, it returns 0.
//
// Returns (0, false) if the value cannot be parsed as either format.
func (c *Client) parseRetryAfter(value string) (time.Duration, bool) {
Review

[NIT] The doRequest signature has accept string as a named parameter after reqURL string. Per the style patterns, prefer consistent parameter naming. Minor: the internal copy of backoff via append([]time.Duration(nil), c.retryBackoff...) is idiomatic but slightly obscure; slices.Clone (Go 1.21+) would be clearer if the project targets that version.

**[NIT]** The `doRequest` signature has `accept string` as a named parameter after `reqURL string`. Per the style patterns, prefer consistent parameter naming. Minor: the internal copy of `backoff` via `append([]time.Duration(nil), c.retryBackoff...)` is idiomatic but slightly obscure; `slices.Clone` (Go 1.21+) would be clearer if the project targets that version.
value = strings.TrimSpace(value)
// Try integer seconds first (most common from GitHub).
// RFC 7231 allows delta-seconds of 0 to indicate immediate retry.
Review

[NIT] Calling timer.Stop() after the timer channel has already fired is unnecessary. While harmless, you can omit Stop() in the <-timer.C branch to follow common timer patterns.

**[NIT]** Calling timer.Stop() after the timer channel has already fired is unnecessary. While harmless, you can omit Stop() in the <-timer.C branch to follow common timer patterns.
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second, true
}
Review

[NIT] Authorization header is set even when the token is empty ("Bearer "). Consider only setting the header when c.token != "" to avoid sending a malformed Authorization header.

**[NIT]** Authorization header is set even when the token is empty ("Bearer "). Consider only setting the header when c.token != "" to avoid sending a malformed Authorization header.
// Try HTTP-date format (RFC 7231 §7.1.3).
// http.ParseTime handles RFC 1123, RFC 850, and ASCTIME formats.
Review

[NIT] The doRequest function signature uses a named parameter accept string rather than acceptHeader string or similar. The single-word name is fine, but worth noting the parameter shadows no built-in. Minor style nit — no action required.

**[NIT]** The `doRequest` function signature uses a named parameter `accept string` rather than `acceptHeader string` or similar. The single-word name is fine, but worth noting the parameter shadows no built-in. Minor style nit — no action required.
if retryAt, err := http.ParseTime(value); err == nil {
delay := retryAt.Sub(c.now())
if delay < 0 {
delay = 0
}
return delay, true
}
Review

[NIT] Authorization header is set unconditionally. If token is empty, the request will send "Bearer " which may be undesirable. Consider setting the header only when token is non-empty.

**[NIT]** Authorization header is set unconditionally. If token is empty, the request will send "Bearer " which may be undesirable. Consider setting the header only when token is non-empty.
return 0, false
}
Review

[NIT] Consider setting a User-Agent header (e.g., "application name/version") as many APIs, including GitHub, recommend identifying clients. Not required for correctness but can aid debugging and compliance.

**[NIT]** Consider setting a User-Agent header (e.g., "application name/version") as many APIs, including GitHub, recommend identifying clients. Not required for correctness but can aid debugging and compliance.
// doRequest performs an HTTP request with retry on 429 rate limit responses.
// It respects the Retry-After header when present, supporting both integer
// seconds and HTTP-date formats (capped at maxRetryAfter).
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
Review

[MINOR] doRequest accepts a full URL string. Although unexported, ensure that exported higher-level methods only construct URLs from the trusted baseURL (or enforce an allowlist) so this primitive cannot be used with untrusted user-provided URLs, which could otherwise enable SSRF if misused.

**[MINOR]** doRequest accepts a full URL string. Although unexported, ensure that exported higher-level methods only construct URLs from the trusted baseURL (or enforce an allowlist) so this primitive cannot be used with untrusted user-provided URLs, which could otherwise enable SSRF if misused.
var backoff []time.Duration
if c.retryBackoff != nil {
backoff = append([]time.Duration(nil), c.retryBackoff...)
} else {
backoff = []time.Duration{1 * time.Second, 2 * time.Second}
}
var lastErr error
for attempt := 0; attempt < maxRetryAttempts; attempt++ {
if attempt > 0 {
var delay time.Duration
if attempt-1 < len(backoff) {
Review

[MINOR] timer.Stop() is called after <-timer.C has already fired, which is harmless but unnecessary. The canonical pattern for a one-shot timer in a select is: defer timer.Stop() before the select statement. In the current code, calling timer.Stop() after draining the channel is a no-op. This is not a bug, just slightly misleading.

**[MINOR]** timer.Stop() is called after <-timer.C has already fired, which is harmless but unnecessary. The canonical pattern for a one-shot timer in a select is: `defer timer.Stop()` before the select statement. In the current code, calling timer.Stop() after draining the channel is a no-op. This is not a bug, just slightly misleading.
delay = backoff[attempt-1]
}
if delay > 0 {
Review

[MINOR] The context cancellation test (TestDoRequest_ContextCanceled) cancels the context before calling doGet, which means the first HTTP request itself will likely fail with a context error rather than the retry timer being cancelled. The timer-cancellation path (the select with timer.C and ctx.Done()) is not actually exercised. Consider cancelling the context after the first 429 response is received (e.g. via a channel signalled from the handler) to properly test the timer interruption path.

**[MINOR]** The context cancellation test (`TestDoRequest_ContextCanceled`) cancels the context before calling `doGet`, which means the first HTTP request itself will likely fail with a context error rather than the retry timer being cancelled. The timer-cancellation path (the `select` with `timer.C` and `ctx.Done()`) is not actually exercised. Consider cancelling the context after the first 429 response is received (e.g. via a channel signalled from the handler) to properly test the timer interruption path.
timer := time.NewTimer(delay)
Review

[MINOR] Timer leak on zero delay path: when delay == 0, the code skips the timer entirely (correct), but when delay > 0 and the timer fires normally (the case <-timer.C branch), timer.Stop() is never called. The Go docs recommend calling Stop and draining the channel if Stop returns false to prevent goroutine leaks. The idiomatic pattern is defer timer.Stop() immediately after time.NewTimer(delay). The current code only calls timer.Stop() on the context-cancellation path.

**[MINOR]** Timer leak on zero delay path: when `delay == 0`, the code skips the timer entirely (correct), but when delay > 0 and the timer fires normally (the `case <-timer.C` branch), `timer.Stop()` is never called. The Go docs recommend calling Stop and draining the channel if Stop returns false to prevent goroutine leaks. The idiomatic pattern is `defer timer.Stop()` immediately after `time.NewTimer(delay)`. The current code only calls `timer.Stop()` on the context-cancellation path.
select {
case <-timer.C:
timer.Stop()
case <-ctx.Done():
Review

[MINOR] When delay == 0 (e.g., HTTP-date in the past, or Retry-After: 0), the timer is never created and the goroutine proceeds without sleeping — correct. However, when context is already cancelled and delay is 0, the cancelled context won't be checked before the next http.NewRequestWithContext call. This is fine because http.NewRequestWithContext will itself fail fast on a cancelled context, but it does mean a 429 response with a past HTTP-date on a cancelled context won't return ctx.Err() cleanly from the sleep select — it will instead return an HTTP transport error wrapped with 'do request'. The TestDoRequest_ContextCanceled test sets a non-zero backoff which masks this. Consider adding a select { case <-ctx.Done(): return nil, ctx.Err(); default: } guard at the top of each loop iteration, or simply accept the current behavior as the wrapped error is still propagated.

**[MINOR]** When `delay == 0` (e.g., HTTP-date in the past, or Retry-After: 0), the timer is never created and the goroutine proceeds without sleeping — correct. However, when context is already cancelled and delay is 0, the cancelled context won't be checked before the next `http.NewRequestWithContext` call. This is fine because `http.NewRequestWithContext` will itself fail fast on a cancelled context, but it does mean a 429 response with a past HTTP-date on a cancelled context won't return `ctx.Err()` cleanly from the sleep select — it will instead return an HTTP transport error wrapped with 'do request'. The `TestDoRequest_ContextCanceled` test sets a non-zero backoff which masks this. Consider adding a `select { case <-ctx.Done(): return nil, ctx.Err(); default: }` guard at the top of each loop iteration, or simply accept the current behavior as the wrapped error is still propagated.
timer.Stop()
Review

[MINOR] The doRequest method mutates the backoff slice in-place (backoff[attempt] = delay) when a Retry-After header is received. When c.retryBackoff is non-nil, backoff is a direct reference to it (not a copy), so this mutation modifies the Client's shared retryBackoff slice. The doc comment says the Client is safe for concurrent use, but concurrent requests could race on this write. Even for sequential use, the backoff values persist across calls. Either copy the backoff slice at the top of doRequest, or use a local variable for the delay rather than mutating the slice.

**[MINOR]** The `doRequest` method mutates the `backoff` slice in-place (`backoff[attempt] = delay`) when a Retry-After header is received. When `c.retryBackoff` is non-nil, `backoff` is a direct reference to it (not a copy), so this mutation modifies the Client's shared `retryBackoff` slice. The doc comment says the Client is safe for concurrent use, but concurrent requests could race on this write. Even for sequential use, the backoff values persist across calls. Either copy the backoff slice at the top of `doRequest`, or use a local variable for the delay rather than mutating the slice.
Review

[MINOR] Authorization header uses the "Bearer" scheme. GitHub REST commonly accepts both "token" and "Bearer" depending on token type, but to maximize compatibility with PATs consider using "token " or making the scheme configurable.

**[MINOR]** Authorization header uses the "Bearer" scheme. GitHub REST commonly accepts both "token" and "Bearer" depending on token type, but to maximize compatibility with PATs consider using "token " or making the scheme configurable.
return nil, ctx.Err()
}
}
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
if accept != "" {
req.Header.Set("Accept", accept)
} else {
req.Header.Set("Accept", "application/vnd.github+json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
Review

[MINOR] The Retry-After header override only applies when attempt < len(backoff). With maxRetryAttempts = 3 and the default backoff slice of length 2, this works for attempts 0 and 1 (covering both possible retries). But if SetRetryBackoff is called with an empty slice []time.Duration{}, the Retry-After delay is computed but never applied, silently falling back to a zero delay. The comment in the code says the delay is set via backoff[attempt] = delay, but if backoff is empty this branch is skipped. This is a subtle edge case that could confuse callers of SetRetryBackoff.

**[MINOR]** The Retry-After header override only applies when `attempt < len(backoff)`. With `maxRetryAttempts = 3` and the default backoff slice of length 2, this works for attempts 0 and 1 (covering both possible retries). But if `SetRetryBackoff` is called with an empty slice `[]time.Duration{}`, the Retry-After delay is computed but never applied, silently falling back to a zero delay. The comment in the code says the delay is set via `backoff[attempt] = delay`, but if backoff is empty this branch is skipped. This is a subtle edge case that could confuse callers of `SetRetryBackoff`.
Review

[MINOR] The Retry-After override only applies when attempt < len(backoff), which means on the last retryable attempt (attempt == maxRetryAttempts-2 == 1, with default backoff len 2) the cap and assignment do apply. However if the server returns 429 on attempt 1 (second attempt) and len(backoff) == 2, then attempt(1) < len(backoff)(2) is true and backoff[1] is set — but attempt 1 is the last attempt that will retry (attempt 2 would be maxRetryAttempts-1 == 2 which is NOT < maxRetryAttempts-1), so the stored backoff[1] is never consumed. This is a logic gap: the Retry-After value is parsed and stored but the delay is never applied on the final retry cycle. This is a subtle correctness issue but low impact since it only means the cap is computed unnecessarily on the last attempt.

**[MINOR]** The Retry-After override only applies when `attempt < len(backoff)`, which means on the last retryable attempt (attempt == maxRetryAttempts-2 == 1, with default backoff len 2) the cap and assignment do apply. However if the server returns 429 on attempt 1 (second attempt) and `len(backoff) == 2`, then `attempt(1) < len(backoff)(2)` is true and backoff[1] is set — but attempt 1 is the last attempt that will retry (attempt 2 would be maxRetryAttempts-1 == 2 which is NOT < maxRetryAttempts-1), so the stored backoff[1] is never consumed. This is a logic gap: the Retry-After value is parsed and stored but the delay is never applied on the final retry cycle. This is a subtle correctness issue but low impact since it only means the cap is computed unnecessarily on the last attempt.
return nil, fmt.Errorf("do request: %w", err)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
resp.Body.Close()
if err != nil {
Review

[MINOR] The Retry-After override logic stores the delay at backoff[attempt] but the delay is consumed at backoff[attempt-1] on the next iteration (when attempt-1 == current_attempt). Since the current attempt is 0 when we first hit a 429, the delay gets stored at backoff[0] and consumed when attempt=1 reads backoff[0]. This happens to be correct, but the off-by-one relationship between where the delay is stored and where it is read is subtle and not explained in a comment. A comment clarifying that backoff[attempt] is the delay before attempt+1 would help future maintainers.

**[MINOR]** The Retry-After override logic stores the delay at `backoff[attempt]` but the delay is consumed at `backoff[attempt-1]` on the *next* iteration (when `attempt-1 == current_attempt`). Since the current attempt is 0 when we first hit a 429, the delay gets stored at `backoff[0]` and consumed when `attempt=1` reads `backoff[0]`. This happens to be correct, but the off-by-one relationship between where the delay is stored and where it is read is subtle and not explained in a comment. A comment clarifying that `backoff[attempt]` is the delay *before* attempt+1 would help future maintainers.
return nil, fmt.Errorf("read response body: %w", err)
}
return body, nil
}
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
resp.Body.Close()
lastErr = &APIError{StatusCode: resp.StatusCode, Body: string(errBody)}
// Retry on 429 rate limit
if resp.StatusCode == http.StatusTooManyRequests && attempt < maxRetryAttempts-1 {
// Check for Retry-After header and override backoff if present.
// Supports both integer seconds (common) and HTTP-date format (RFC 7231).
if ra := resp.Header.Get("Retry-After"); ra != "" {
if delay, ok := c.parseRetryAfter(ra); ok {
if delay > maxRetryAfter {
delay = maxRetryAfter
}
if attempt < len(backoff) {
backoff[attempt] = delay
}
}
}
continue
}
// Don't retry other errors
return nil, lastErr
}
return nil, lastErr
}
// doGet is a convenience wrapper for GET requests with the default Accept header.
func (c *Client) doGet(ctx context.Context, url string) ([]byte, error) {
return c.doRequest(ctx, http.MethodGet, url, "")
}
+409
View File
@@ -0,0 +1,409 @@
package github
Review

[NIT] Tests are comprehensive, but there is no integration test asserting that a large Retry-After value in a 429 response is capped at maxRetryAfter in doRequest. Adding such a test would exercise the capping logic.

**[NIT]** Tests are comprehensive, but there is no integration test asserting that a large Retry-After value in a 429 response is capped at maxRetryAfter in doRequest. Adding such a test would exercise the capping logic.
import (
"context"
Review

[NIT] Import list is not sorted ("errors" appears before "context"). gofmt/goimports would order imports lexicographically within the group. Running gofmt/goimports would address this.

**[NIT]** Import list is not sorted ("errors" appears before "context"). gofmt/goimports would order imports lexicographically within the group. Running gofmt/goimports would address this.
"errors"
"net/http"
"net/http/httptest"
Review

[NIT] Import ordering has "errors" before "context", which violates the standard goimports ordering (stdlib packages in alphabetical order). This would be caught by goimports/gofmt and should be fixed for consistency.

**[NIT]** Import ordering has `"errors"` before `"context"`, which violates the standard goimports ordering (stdlib packages in alphabetical order). This would be caught by `goimports`/`gofmt` and should be fixed for consistency.
"testing"
"time"
)
func TestNewClient_DefaultBaseURL(t *testing.T) {
c := NewClient("tok", "")
if c.baseURL != defaultBaseURL {
t.Errorf("baseURL = %q, want %q", c.baseURL, defaultBaseURL)
}
}
func TestNewClient_CustomBaseURL(t *testing.T) {
c := NewClient("tok", "https://github.concur.com/api/v3/")
if c.baseURL != "https://github.concur.com/api/v3" {
t.Errorf("baseURL = %q, want trailing slash stripped", c.baseURL)
}
}
func TestDoRequest_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Errorf("Authorization = %q, want Bearer test-token", got)
Review

[NIT] w.Write([]byte(...)) return values are silently ignored in test handlers. This is idiomatic in test code, but some linters flag it. Adding //nolint or using fmt.Fprint is not necessary — just noting it follows the project's test style.

**[NIT]** `w.Write([]byte(...))` return values are silently ignored in test handlers. This is idiomatic in test code, but some linters flag it. Adding `//nolint` or using `fmt.Fprint` is not necessary — just noting it follows the project's test style.
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := NewClient("test-token", srv.URL)
body, err := c.doGet(context.Background(), srv.URL+"/test")
Review

[NIT] w.Write(...) return values are ignored in test handlers throughout. This is standard for httptest handlers, but adding //nolint or accepting the convention is fine. Not a real issue.

**[NIT]** `w.Write(...)` return values are ignored in test handlers throughout. This is standard for httptest handlers, but adding `//nolint` or accepting the convention is fine. Not a real issue.
if err != nil {
Review

[NIT] w.Write([]byte(...)) return values are silently ignored in test handlers. In tests this is fine, but adding //nolint comments or using fmt.Fprint might suppress any linter warnings about unchecked errors. Not a real issue.

**[NIT]** `w.Write([]byte(...))` return values are silently ignored in test handlers. In tests this is fine, but adding `//nolint` comments or using `fmt.Fprint` might suppress any linter warnings about unchecked errors. Not a real issue.
t.Fatalf("unexpected error: %v", err)
}
if string(body) != `{"ok":true}` {
t.Errorf("body = %q, want %q", body, `{"ok":true}`)
}
}
func TestDoRequest_429_RetryAfter_IntegerSeconds(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte("rate limited"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
Review

[MINOR] TestDoRequest_429_RetryAfter_IntegerSeconds contains a large block of commented-out reasoning text and dead code (the first srv is created, a client c is set up but never used, then srv.Close() is called prematurely). This leaves a closed server and an unused client in the test. The test logic is repeated via srv2/c2. The function should be cleaned up to remove the dead first half and just use the working second server directly. The comments read like unresolved thinking rather than explanatory documentation.

**[MINOR]** `TestDoRequest_429_RetryAfter_IntegerSeconds` contains a large block of commented-out reasoning text and dead code (the first `srv` is created, a client `c` is set up but never used, then `srv.Close()` is called prematurely). This leaves a closed server and an unused client in the test. The test logic is repeated via `srv2`/`c2`. The function should be cleaned up to remove the dead first half and just use the working second server directly. The comments read like unresolved thinking rather than explanatory documentation.
}))
defer srv.Close()
Review

[NIT] Tests like TestDoRequest_429_RetryAfter_IntegerSeconds set Retry-After: 1 on the server but then override backoff to {0, 0}. The Retry-After: 1 value from the server will override backoff[0] to 1s (via parseRetryAfter), meaning the test will actually sleep for 1 second. This contradicts the intent of SetRetryBackoff([]time.Duration{0, 0}) to make the test fast. The test passes but takes ~1s longer than expected. Consider setting Retry-After: 0 in the server handler for this test, or verify this is intentional.

**[NIT]** Tests like `TestDoRequest_429_RetryAfter_IntegerSeconds` set `Retry-After: 1` on the server but then override backoff to `{0, 0}`. The `Retry-After: 1` value from the server will override `backoff[0]` to `1s` (via `parseRetryAfter`), meaning the test will actually sleep for 1 second. This contradicts the intent of `SetRetryBackoff([]time.Duration{0, 0})` to make the test fast. The test passes but takes ~1s longer than expected. Consider setting `Retry-After: 0` in the server handler for this test, or verify this is intentional.
c := NewClient("tok", srv.URL)
c.SetRetryBackoff([]time.Duration{0, 0})
body, err := c.doGet(context.Background(), srv.URL+"/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(body) != "success" {
t.Errorf("body = %q, want %q", body, "success")
}
if attempts != 2 {
t.Errorf("attempts = %d, want 2", attempts)
}
}
func TestDoRequest_429_RetryAfter_HTTPDate(t *testing.T) {
// Fix "now" to a known time for deterministic testing.
fixedNow := time.Date(2025, 12, 1, 15, 59, 59, 0, time.UTC)
retryAt := "Mon, 01 Dec 2025 16:00:00 GMT" // 1 second in the future
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.Header().Set("Retry-After", retryAt)
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte("rate limited"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
}))
defer srv.Close()
c := NewClient("tok", srv.URL)
c.now = func() time.Time { return fixedNow }
// Initial backoff is 0; the HTTP-date parser will compute 1s and override.
c.SetRetryBackoff([]time.Duration{0, 0})
body, err := c.doGet(context.Background(), srv.URL+"/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(body) != "success" {
t.Errorf("body = %q, want %q", body, "success")
}
if attempts != 2 {
t.Errorf("attempts = %d, want 2", attempts)
}
}
func TestDoRequest_429_RetryAfter_HTTPDate_InPast(t *testing.T) {
// If the HTTP-date is in the past, delay should be 0 (retry immediately).
fixedNow := time.Date(2025, 12, 1, 17, 0, 0, 0, time.UTC)
retryAt := "Mon, 01 Dec 2025 16:00:00 GMT" // 1 hour in the past
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.Header().Set("Retry-After", retryAt)
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte("rate limited"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
}))
defer srv.Close()
c := NewClient("tok", srv.URL)
c.now = func() time.Time { return fixedNow }
c.SetRetryBackoff([]time.Duration{0, 0})
body, err := c.doGet(context.Background(), srv.URL+"/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(body) != "success" {
t.Errorf("body = %q, want %q", body, "success")
}
}
func TestDoRequest_429_NoRetryAfter_UsesDefaultBackoff(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte("rate limited"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
}))
defer srv.Close()
c := NewClient("tok", srv.URL)
c.SetRetryBackoff([]time.Duration{0, 0})
body, err := c.doGet(context.Background(), srv.URL+"/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(body) != "success" {
t.Errorf("body = %q, want %q", body, "success")
}
if attempts != 2 {
t.Errorf("attempts = %d, want 2", attempts)
}
}
func TestDoRequest_429_InvalidRetryAfter_UsesDefaultBackoff(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.Header().Set("Retry-After", "not-a-number-or-date")
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte("rate limited"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
}))
defer srv.Close()
c := NewClient("tok", srv.URL)
c.SetRetryBackoff([]time.Duration{0, 0})
body, err := c.doGet(context.Background(), srv.URL+"/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(body) != "success" {
t.Errorf("body = %q, want %q", body, "success")
}
}
func TestDoRequest_404_NoRetry(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
}))
defer srv.Close()
c := NewClient("tok", srv.URL)
_, err := c.doGet(context.Background(), srv.URL+"/test")
if err == nil {
t.Fatal("expected error, got nil")
}
if !IsNotFound(err) {
t.Errorf("expected IsNotFound, got %v", err)
}
if attempts != 1 {
t.Errorf("attempts = %d, want 1 (no retry on 404)", attempts)
}
}
Review

[NIT] TestDoRequest_429_RetryAfter_HTTPDate sets initial backoff to {0, 0} and a Retry-After of 1 second — this means the test will actually sleep for 1 second (the override sets backoff[0]=1s, and that delay is applied before attempt 1). This is a real 1-second test. For a fast test suite, consider using Retry-After: 0 or mocking the sleep mechanism, or accepting this as an intentional slow test and documenting it.

**[NIT]** `TestDoRequest_429_RetryAfter_HTTPDate` sets initial backoff to `{0, 0}` and a Retry-After of 1 second — this means the test will actually sleep for 1 second (the override sets `backoff[0]=1s`, and that delay is applied before attempt 1). This is a real 1-second test. For a fast test suite, consider using `Retry-After: 0` or mocking the sleep mechanism, or accepting this as an intentional slow test and documenting it.
func TestDoRequest_401_NoRetry(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("unauthorized"))
}))
defer srv.Close()
c := NewClient("tok", srv.URL)
_, err := c.doGet(context.Background(), srv.URL+"/test")
if err == nil {
t.Fatal("expected error, got nil")
}
if !IsUnauthorized(err) {
t.Errorf("expected IsUnauthorized, got %v", err)
}
if attempts != 1 {
t.Errorf("attempts = %d, want 1 (no retry on 401)", attempts)
}
}
func TestDoRequest_ContextCanceled(t *testing.T) {
// This test exercises the timer-cancel path in the retry select:
// select { case <-timer.C; case <-ctx.Done() }
// The server returns 429 with a long Retry-After, and we cancel the
// context shortly after the first response so that cancellation races
// against the timer rather than preventing the initial HTTP round-trip.
requestReceived := make(chan struct{}, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case requestReceived <- struct{}{}:
default:
}
w.Header().Set("Retry-After", "10")
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
c := NewClient("tok", srv.URL)
c.SetRetryBackoff([]time.Duration{10 * time.Second, 10 * time.Second})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
Review

[NIT] The TestDoRequest_ContextCanceled test uses time.Sleep(50 * time.Millisecond) to create a timing window, which is a potential source of flakiness on very slow CI machines. A more robust approach would use a channel to signal that the select is about to be entered. However, since the retry backoff is 10 seconds and the sleep is 50ms, the window is generous enough to be reliable in practice.

**[NIT]** The `TestDoRequest_ContextCanceled` test uses `time.Sleep(50 * time.Millisecond)` to create a timing window, which is a potential source of flakiness on very slow CI machines. A more robust approach would use a channel to signal that the select is about to be entered. However, since the retry backoff is 10 seconds and the sleep is 50ms, the window is generous enough to be reliable in practice.
// Cancel the context after the first request completes, while the
// client is blocked in the retry timer select.
go func() {
<-requestReceived
// Small delay to ensure we're inside the timer select.
time.Sleep(50 * time.Millisecond)
cancel()
}()
_, err := c.doGet(ctx, srv.URL+"/test")
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want context.Canceled", err)
}
}
func TestParseRetryAfter_IntegerSeconds(t *testing.T) {
c := NewClient("tok", "")
delay, ok := c.parseRetryAfter("42")
if !ok {
t.Fatal("expected ok=true")
}
if delay != 42*time.Second {
t.Errorf("delay = %v, want 42s", delay)
}
}
func TestParseRetryAfter_ZeroSeconds(t *testing.T) {
c := NewClient("tok", "")
delay, ok := c.parseRetryAfter("0")
if !ok {
t.Fatal("expected ok=true for zero seconds (RFC 7231 allows immediate retry)")
}
if delay != 0 {
t.Errorf("delay = %v, want 0", delay)
}
}
func TestParseRetryAfter_NegativeSeconds(t *testing.T) {
c := NewClient("tok", "")
_, ok := c.parseRetryAfter("-5")
if ok {
t.Error("expected ok=false for negative seconds")
}
}
func TestParseRetryAfter_HTTPDate_Future(t *testing.T) {
fixedNow := time.Date(2025, 12, 1, 15, 59, 50, 0, time.UTC)
c := NewClient("tok", "")
c.now = func() time.Time { return fixedNow }
delay, ok := c.parseRetryAfter("Mon, 01 Dec 2025 16:00:00 GMT")
if !ok {
t.Fatal("expected ok=true")
}
// Should be 10 seconds in the future.
if delay != 10*time.Second {
t.Errorf("delay = %v, want 10s", delay)
}
}
func TestParseRetryAfter_HTTPDate_Past(t *testing.T) {
fixedNow := time.Date(2025, 12, 1, 17, 0, 0, 0, time.UTC)
c := NewClient("tok", "")
c.now = func() time.Time { return fixedNow }
delay, ok := c.parseRetryAfter("Mon, 01 Dec 2025 16:00:00 GMT")
if !ok {
t.Fatal("expected ok=true")
}
if delay != 0 {
t.Errorf("delay = %v, want 0 (past date)", delay)
}
}
func TestParseRetryAfter_RFC850_Format(t *testing.T) {
fixedNow := time.Date(2025, 12, 1, 15, 59, 50, 0, time.UTC)
c := NewClient("tok", "")
c.now = func() time.Time { return fixedNow }
// RFC 850 format
delay, ok := c.parseRetryAfter("Monday, 01-Dec-25 16:00:00 GMT")
if !ok {
t.Fatal("expected ok=true for RFC 850 format")
}
if delay != 10*time.Second {
t.Errorf("delay = %v, want 10s", delay)
}
}
func TestParseRetryAfter_Invalid(t *testing.T) {
c := NewClient("tok", "")
_, ok := c.parseRetryAfter("not-valid")
if ok {
t.Error("expected ok=false for invalid value")
}
}
func TestParseRetryAfter_EmptyString(t *testing.T) {
c := NewClient("tok", "")
_, ok := c.parseRetryAfter("")
if ok {
t.Error("expected ok=false for empty string")
}
}
func TestParseRetryAfter_MaxCap(t *testing.T) {
// Verify that parseRetryAfter returns the raw value (capping is done by caller).
c := NewClient("tok", "")
delay, ok := c.parseRetryAfter("3600")
if !ok {
t.Fatal("expected ok=true")
}
if delay != 3600*time.Second {
t.Errorf("delay = %v, want 3600s (caller is responsible for capping)", delay)
}
}
func TestAPIError_Error_Truncation(t *testing.T) {
longBody := make([]byte, 300)
for i := range longBody {
longBody[i] = 'x'
}
apiErr := &APIError{StatusCode: 500, Body: string(longBody)}
msg := apiErr.Error()
if len(msg) > 250 {
// "HTTP 500: " (10) + 200 + "...(truncated)" (14) = 224
t.Errorf("error message too long: %d chars", len(msg))
}
}
func TestAPIError_Error_NewlineSanitized(t *testing.T) {
apiErr := &APIError{StatusCode: 400, Body: "line1\nline2\rline3"}
msg := apiErr.Error()
for _, c := range msg {
if c == '\n' || c == '\r' {
t.Errorf("error message contains unsanitized newline: %q", msg)
break
}
}
}