feat(github): support HTTP-date format in Retry-After header #110
@@ -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).
|
||||
//
|
||||
|
|
||||
// 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
[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
[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
[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
|
||||
}
|
||||
|
sonnet-review-bot
commented
[MINOR] **[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
|
||||
}
|
||||
|
||||
|
sonnet-review-bot
commented
[MINOR] The **[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.
|
||||
|
sonnet-review-bot
commented
[MINOR] The **[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.
|
||||
|
gpt-review-bot
commented
[MINOR] The baseURL field is currently not used by doRequest/doGet. This may be confusing to callers; consider integrating baseURL into request construction or removing it until higher-level methods use it. **[MINOR]** The baseURL field is currently not used by doRequest/doGet. This may be confusing to callers; consider integrating baseURL into request construction or removing it until higher-level methods use it.
|
||||
// Higher-level exported methods (GetPullRequest, etc.) will use it to
|
||||
|
sonnet-review-bot
commented
[NIT] Struct field alignment: **[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
|
||||
|
gpt-review-bot
commented
[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.
|
||||
|
gpt-review-bot
commented
[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.
|
||||
|
gpt-review-bot
commented
[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 {
|
||||
|
gpt-review-bot
commented
[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},
|
||||
|
[MINOR] Authorization header is set on the client requests; while Go’s standard library drops Authorization on cross-host redirects in recent versions, consider explicitly configuring http.Client.CheckRedirect to prevent following cross-host redirects or to strip sensitive headers on redirects for defense-in-depth against token leakage. **[MINOR]** Authorization header is set on the client requests; while Go’s standard library drops Authorization on cross-host redirects in recent versions, consider explicitly configuring http.Client.CheckRedirect to prevent following cross-host redirects or to strip sensitive headers on redirects for defense-in-depth against token leakage.
gpt-review-bot
commented
[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
[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.
gpt-review-bot
commented
[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
|
||||
|
[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
|
||||
|
gpt-review-bot
commented
[MINOR] parseRetryAfter uses strconv.Atoi (int), which can overflow on 32-bit platforms for large values. Consider strconv.ParseInt with a 64-bit range to be robust to large Retry-After values. **[MINOR]** parseRetryAfter uses strconv.Atoi (int), which can overflow on 32-bit platforms for large values. Consider strconv.ParseInt with a 64-bit range to be robust to large Retry-After values.
|
||||
// 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) {
|
||||
|
sonnet-review-bot
commented
[NIT] The **[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.
|
||||
|
gpt-review-bot
commented
[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
|
||||
}
|
||||
|
gpt-review-bot
commented
[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.
|
||||
|
sonnet-review-bot
commented
[NIT] The **[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
|
||||
}
|
||||
|
gpt-review-bot
commented
[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
|
||||
}
|
||||
|
security-review-bot marked this conversation as resolved
Outdated
[MINOR] Unbounded successful response body read may allow resource exhaustion if a server returns a very large payload. While the GitHub API is typically bounded, for defense-in-depth consider imposing a reasonable limit or streaming with safeguards similar to the error path. **[MINOR]** Unbounded successful response body read may allow resource exhaustion if a server returns a very large payload. While the GitHub API is typically bounded, for defense-in-depth consider imposing a reasonable limit or streaming with safeguards similar to the error path.
|
||||
|
||||
|
gpt-review-bot
commented
[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) {
|
||||
|
[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) {
|
||||
|
sonnet-review-bot
commented
[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: **[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 {
|
||||
|
sonnet-review-bot
commented
[MINOR] The context cancellation test ( **[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)
|
||||
|
sonnet-review-bot
commented
[MINOR] Timer leak on zero delay path: when **[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():
|
||||
|
sonnet-review-bot
commented
[MINOR] When **[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()
|
||||
|
sonnet-review-bot
commented
[MINOR] The **[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.
gpt-review-bot
commented
[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 {
|
||||
|
sonnet-review-bot
commented
[MINOR] The Retry-After header override only applies when **[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`.
sonnet-review-bot
commented
[MINOR] The Retry-After override only applies when **[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 {
|
||||
|
sonnet-review-bot
commented
[MINOR] The Retry-After override logic stores the delay at **[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, "")
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package github
|
||||
|
gpt-review-bot
commented
[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"
|
||||
|
gpt-review-bot
commented
[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"
|
||||
|
sonnet-review-bot
commented
[NIT] Import ordering has **[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)
|
||||
|
sonnet-review-bot
commented
[NIT] **[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")
|
||||
|
sonnet-review-bot
commented
[NIT] **[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 {
|
||||
|
sonnet-review-bot
commented
[NIT] **[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"))
|
||||
|
sonnet-review-bot
commented
[MINOR] **[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()
|
||||
|
||||
|
sonnet-review-bot
commented
[NIT] Tests like **[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)
|
||||
}
|
||||
}
|
||||
|
||||
|
sonnet-review-bot
commented
[NIT] **[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()
|
||||
|
sonnet-review-bot
commented
[NIT] The **[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
|
||||
}
|
||||
}
|
||||
}
|
||||
[NIT] The
APIError.Bodyfield is exported and documented as storing up to 64 KiB of raw response body. HoweverError()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 localbodyvariable but the fielde.Bodyis not truncated — callers accessingerr.(*APIError).Bodydirectly 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.