feat(github): support HTTP-date format in Retry-After header #110
Reference in New Issue
Block a user
Delete Branch "review-bot-issue-94"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Implement the
githubpackage client with Retry-After header parsing that supports both integer seconds and HTTP-date format per RFC 7231 §7.1.3.Problem
The Retry-After header can be sent in two formats:
Retry-After: 120Retry-After: Thu, 01 Dec 2025 16:00:00 GMTOnly integer parsing was planned for the original github client. This implements both from the start.
Changes
github/client.go: Minimal GitHub API client withdoRequestthat handles 429 rate limiting with Retry-After support for both formatsgithub/client_test.go: Comprehensive tests covering integer seconds, HTTP-date (future and past), RFC 850 format, invalid values, cap behavior, and error classificationDesign Decisions
http.ParseTime()from stdlib which handles RFC 1123, RFC 850, and ASCTIME formatsmaxRetryAfter) to prevent servers from stalling the client indefinitelyc.nowfield (defaults totime.Now) allows deterministic testing of HTTP-date calculationsparseRetryAfteris a separate method for unit testabilityCloses #94
Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
41e1d48b)Sonnet Review
Summary
Well-structured GitHub API client with solid Retry-After parsing implementation. CI passes. A few minor issues: mutation of the shared backoff slice is a latent bug for concurrent use (contradicting the documented concurrency safety), and one test function is messy with dead code and comments, but these are minor in context.
Findings
github/client.godoRequestmethod mutates thebackoffslice in-place (backoff[attempt] = delay) when a Retry-After header is received. Whenc.retryBackoffis non-nil,backoffis a direct reference to it (not a copy), so this mutation modifies the Client's sharedretryBackoffslice. 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 ofdoRequest, or use a local variable for the delay rather than mutating the slice.github/client.goTestDoRequest_ContextCanceled) cancels the context before callingdoGet, 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 (theselectwithtimer.Candctx.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.github/client_test.goTestDoRequest_429_RetryAfter_IntegerSecondscontains a large block of commented-out reasoning text and dead code (the firstsrvis created, a clientcis set up but never used, thensrv.Close()is called prematurely). This leaves a closed server and an unused client in the test. The test logic is repeated viasrv2/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.github/client_test.go"errors"before"context", which violates the standard goimports ordering (stdlib packages in alphabetical order). This would be caught bygoimports/gofmtand should be fixed for consistency.github/client.godoRequestfunction signature uses a named parameteraccept stringrather thanacceptHeader stringor similar. The single-word name is fine, but worth noting the parameter shadows no built-in. Minor style nit — no action required.github/client_test.goTestDoRequest_429_RetryAfter_HTTPDatesets initial backoff to{0, 0}and a Retry-After of 1 second — this means the test will actually sleep for 1 second (the override setsbackoff[0]=1s, and that delay is applied before attempt 1). This is a real 1-second test. For a fast test suite, consider usingRetry-After: 0or mocking the sleep mechanism, or accepting this as an intentional slow test and documenting it.Recommendation
APPROVE — Approve with the suggestion to fix the backoff slice mutation bug before this code goes into heavier use. The mutation of
c.retryBackoffvia the shared slice reference is a real correctness issue for concurrent callers and also causes state leakage between sequential calls — a copy of the backoff slice should be made at the top ofdoRequest. The messy test function (TestDoRequest_429_RetryAfter_IntegerSeconds) should be cleaned up to remove the dead code block. The import ordering nit should be fixed by runninggoimports. None of these are blockers given CI passes and the overall design is sound, but the backoff mutation in particular should be addressed soon.Review by sonnet
Evaluated against
41e1d48b@@ -0,0 +158,4 @@// 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) {backoff := c.retryBackoff[NIT] The
doRequestfunction signature uses a named parameteraccept stringrather thanacceptHeader stringor similar. The single-word name is fine, but worth noting the parameter shadows no built-in. Minor style nit — no action required.@@ -0,0 +188,4 @@req.Header.Set("Authorization", "Bearer "+c.token)if accept != "" {req.Header.Set("Accept", accept)} else {[MINOR] The context cancellation test (
TestDoRequest_ContextCanceled) cancels the context before callingdoGet, 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 (theselectwithtimer.Candctx.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.@@ -0,0 +194,4 @@resp, err := c.http.Do(req)if err != nil {return nil, fmt.Errorf("do request: %w", err)[MINOR] The
doRequestmethod mutates thebackoffslice in-place (backoff[attempt] = delay) when a Retry-After header is received. Whenc.retryBackoffis non-nil,backoffis a direct reference to it (not a copy), so this mutation modifies the Client's sharedretryBackoffslice. 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 ofdoRequest, or use a local variable for the delay rather than mutating the slice.@@ -0,0 +4,4 @@"errors""context""net/http""net/http/httptest"[NIT] Import ordering has
"errors"before"context", which violates the standard goimports ordering (stdlib packages in alphabetical order). This would be caught bygoimports/gofmtand should be fixed for consistency.@@ -0,0 +54,4 @@return}w.WriteHeader(http.StatusOK)w.Write([]byte("success"))[MINOR]
TestDoRequest_429_RetryAfter_IntegerSecondscontains a large block of commented-out reasoning text and dead code (the firstsrvis created, a clientcis set up but never used, thensrv.Close()is called prematurely). This leaves a closed server and an unused client in the test. The test logic is repeated viasrv2/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.@@ -0,0 +218,4 @@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")[NIT]
TestDoRequest_429_RetryAfter_HTTPDatesets initial backoff to{0, 0}and a Retry-After of 1 second — this means the test will actually sleep for 1 second (the override setsbackoff[0]=1s, and that delay is applied before attempt 1). This is a real 1-second test. For a fast test suite, consider usingRetry-After: 0or mocking the sleep mechanism, or accepting this as an intentional slow test and documenting it.Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
41e1d48b)Security Review
Summary
The implementation correctly supports both integer and HTTP-date formats for Retry-After, includes sensible caps and context-aware timing, and sanitizes error output to reduce log injection risks. CI passed and no exploitable vulnerabilities were identified.
Findings
github/client.gogithub/client.gogithub/client.goRecommendation
APPROVE — Overall the changes are sound, correctly implement Retry-After parsing for both formats, and include sensible caps and context cancellation. To improve robustness and security, consider copying the retryBackoff slice per request to avoid concurrent mutation and potential race conditions, add a limit or streaming approach for successful response bodies to reduce the risk of resource exhaustion, and enhance error string sanitization beyond newlines to mitigate log injection in diverse logging environments. With CI passing and no major security issues, this PR is approved.
Review by security
Evaluated against
41e1d48b@@ -0,0 +39,4 @@// contain sensitive details from the upstream server.type APIError struct {StatusCode intBody string[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.
@@ -0,0 +128,4 @@// 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.[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.
@@ -0,0 +168,4 @@if attempt > 0 {var delay time.Durationif attempt-1 < len(backoff) {delay = backoff[attempt-1][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.
Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
41e1d48b)Gpt Review
Summary
Solid implementation of Retry-After parsing (integer and HTTP-date) with comprehensive tests and sensible safeguards. However, the doRequest logic mutates a shared backoff slice from the Client, which violates the stated concurrency safety and can cause data races across concurrent requests.
Findings
github/client.gogithub/client.gogithub/client.gogithub/client.gogithub/client_test.goRecommendation
REQUEST_CHANGES — The overall approach is good and tests are thorough. However, to uphold the documented concurrency guarantee and avoid cross-request interference, update doRequest to avoid mutating shared retryBackoff state: copy c.retryBackoff into a per-request slice before use (e.g., backoff = append([]time.Duration(nil), c.retryBackoff...)) or store the computed Retry-After delay in a local variable used for the next iteration instead of writing into the slice. Additionally, make parseRetryAfter more robust by trimming whitespace and consider honoring a Retry-After value of "0" as an immediate retry per RFC 7231. Optionally, only set the Authorization header when a token is provided, and run gofmt/goimports to normalize import ordering in tests. Addressing these items will make the client conform better to concurrency safety expectations and protocol semantics.
Review by gpt
Evaluated against
41e1d48b@@ -0,0 +108,4 @@}return &Client{baseURL: strings.TrimRight(baseURL, "/"),token: token,[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.
@@ -0,0 +110,4 @@baseURL: strings.TrimRight(baseURL, "/"),token: token,http: &http.Client{Timeout: 30 * time.Second},now: time.Now,[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.
@@ -0,0 +128,4 @@// 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.[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.
@@ -0,0 +155,4 @@}// doRequest performs an HTTP request with retry on 429 rate limit responses.// It respects the Retry-After header when present, supporting both integer[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.
@@ -0,0 +1,431 @@package githubimport ("errors"[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.
Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
e414471a)Sonnet Review
Summary
Well-structured GitHub API client with correct RFC 7231 Retry-After parsing, good security defaults (body size limits, log injection prevention), and comprehensive test coverage. CI passes. A few minor issues worth noting but none blocking.
Findings
github/client.godelay == 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 nexthttp.NewRequestWithContextcall. This is fine becausehttp.NewRequestWithContextwill 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 returnctx.Err()cleanly from the sleep select — it will instead return an HTTP transport error wrapped with 'do request'. TheTestDoRequest_ContextCanceledtest sets a non-zero backoff which masks this. Consider adding aselect { 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.github/client.goattempt < len(backoff). WithmaxRetryAttempts = 3and the default backoff slice of length 2, this works for attempts 0 and 1 (covering both possible retries). But ifSetRetryBackoffis 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 viabackoff[attempt] = delay, but if backoff is empty this branch is skipped. This is a subtle edge case that could confuse callers ofSetRetryBackoff.github/client.goasAPIErroris a helper used only byIsNotFoundandIsUnauthorized. These two callers could directly useerrors.Asinline (2 lines each), makingasAPIErrorunnecessary 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.github/client_test.goTestDoRequest_429_RetryAfter_IntegerSecondssetRetry-After: 1on the server but then override backoff to{0, 0}. TheRetry-After: 1value from the server will overridebackoff[0]to1s(viaparseRetryAfter), meaning the test will actually sleep for 1 second. This contradicts the intent ofSetRetryBackoff([]time.Duration{0, 0})to make the test fast. The test passes but takes ~1s longer than expected. Consider settingRetry-After: 0in the server handler for this test, or verify this is intentional.github/client_test.gow.Write([]byte(...))return values are silently ignored in test handlers. This is idiomatic in test code, but some linters flag it. Adding//nolintor usingfmt.Fprintis not necessary — just noting it follows the project's test style.Recommendation
APPROVE — Approve. The implementation is correct, well-documented, follows Go patterns (errors.As, http.ParseTime, injected clock for testability, context propagation, body size limits), and CI passes. The most actionable finding is the test in
TestDoRequest_429_RetryAfter_IntegerSecondsthat setsRetry-After: 1on the server while expecting the retry to use the zeroed backoff — the Retry-After header will actually override the backoff to 1s, making that test ~1 second slower than intended. This won't cause failures but is worth fixing for test speed. The other findings are truly minor and don't affect correctness.Review by sonnet
Evaluated against
e414471a@@ -0,0 +76,4 @@func asAPIError(err error) (*APIError, bool) {if err == nil {return nil, false}[MINOR]
asAPIErroris a helper used only byIsNotFoundandIsUnauthorized. These two callers could directly useerrors.Asinline (2 lines each), makingasAPIErrorunnecessary 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.@@ -0,0 +193,4 @@req, err := http.NewRequestWithContext(ctx, method, reqURL, nil)if err != nil {return nil, fmt.Errorf("create request: %w", err)}[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 nexthttp.NewRequestWithContextcall. This is fine becausehttp.NewRequestWithContextwill 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 returnctx.Err()cleanly from the sleep select — it will instead return an HTTP transport error wrapped with 'do request'. TheTestDoRequest_ContextCanceledtest sets a non-zero backoff which masks this. Consider adding aselect { 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.@@ -0,0 +212,4 @@if err != nil {return nil, fmt.Errorf("read response body: %w", err)}return body, nil[MINOR] The Retry-After header override only applies when
attempt < len(backoff). WithmaxRetryAttempts = 3and the default backoff slice of length 2, this works for attempts 0 and 1 (covering both possible retries). But ifSetRetryBackoffis 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 viabackoff[attempt] = delay, but if backoff is empty this branch is skipped. This is a subtle edge case that could confuse callers ofSetRetryBackoff.@@ -0,0 +26,4 @@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)[NIT]
w.Write([]byte(...))return values are silently ignored in test handlers. This is idiomatic in test code, but some linters flag it. Adding//nolintor usingfmt.Fprintis not necessary — just noting it follows the project's test style.@@ -0,0 +57,4 @@w.Write([]byte("success"))}))defer srv.Close()[NIT] Tests like
TestDoRequest_429_RetryAfter_IntegerSecondssetRetry-After: 1on the server but then override backoff to{0, 0}. TheRetry-After: 1value from the server will overridebackoff[0]to1s(viaparseRetryAfter), meaning the test will actually sleep for 1 second. This contradicts the intent ofSetRetryBackoff([]time.Duration{0, 0})to make the test fast. The test passes but takes ~1s longer than expected. Consider settingRetry-After: 0in the server handler for this test, or verify this is intentional.Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
e414471a)Security Review
Summary
Solid implementation with careful handling of Retry-After (including HTTP-date), bounded reads to mitigate DoS, context-aware retries, and basic log-injection defenses. CI passed and no exploitable vulnerabilities were found.
Findings
github/client.gogithub/client.goRecommendation
APPROVE — The change correctly supports both integer and HTTP-date Retry-After formats, caps retry delays to prevent stalling, limits response body sizes, and respects context cancellation. Given that CI passed and no exploitable issues were found, this can be merged. As defense in depth, consider reducing information exposure in APIError.Error by removing or gating the response body from the formatted error message, and enhance sanitization to strip other control characters (e.g., tabs, 0x00–0x1F, 0x7F, and ANSI escapes) to further mitigate log injection risks.
Review by security
Evaluated against
e414471a@@ -0,0 +46,4 @@Body string}func (e *APIError) Error() string {[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.
@@ -0,0 +48,4 @@func (e *APIError) Error() string {body := e.Bodyif len(body) > 200 {[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.
Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
e414471a)Gpt Review
Summary
Well-structured minimal GitHub client with robust Retry-After parsing for both integer and HTTP-date formats. Error handling, context usage, and test coverage are solid and idiomatic.
Findings
github/client.gogithub/client.gogithub/client.gogithub/client_test.goRecommendation
APPROVE — The implementation is clean and adheres to Go patterns: clear package docs, proper error wrapping, context-aware retries with cancellable timers, limited body reads, and strong unit tests for Retry-After parsing and retry behavior. Minor improvements include considering "token" vs "Bearer" authorization scheme or making it configurable, using strconv.ParseInt in parseRetryAfter for robustness on 32-bit architectures, and either utilizing or removing the currently unused baseURL field. Optionally, add a test to verify the maxRetryAfter cap is enforced. With these minor considerations noted, the PR is good to merge.
Review by gpt
Evaluated against
e414471a@@ -0,0 +89,4 @@// 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 {baseURL string[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.
@@ -0,0 +144,4 @@// Try integer seconds first (most common from GitHub).// RFC 7231 allows delta-seconds of 0 to indicate immediate retry.if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {[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.
@@ -0,0 +194,4 @@if err != nil {return nil, fmt.Errorf("create request: %w", err)}req.Header.Set("Authorization", "Bearer "+c.token)[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.
@@ -0,0 +1,391 @@package github[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.
Self-Review: PR #110
Self-review against
e414471a162d4fd83160c0059f4bd33e8dd8a1c5Phase 1: Independent Findings
github/client.gobaseURLfield is set inNewClientbut never used bydoRequest/doGet. All callers must build full URLs themselves. This is not a bug (all methods are unexported), but it means the field is dead weight until higher-level exported methods are added. Worth a TODO comment or removal until needed.github/client_test.goTestDoRequest_ContextCanceledcancels the context before callingdoGet, so the first HTTP request fails with a context error rather than reaching the retry sleep path. The timer-cancellation branch (select { case <-timer.C; case <-ctx.Done() }) is not actually exercised by this test. The path is likely correct but remains untested.github/client_test.goTestDoRequest_429_RetryAfter_IntegerSecondssetsRetry-After: 1on the server whileSetRetryBackoff({0, 0})is intended to make the test fast. TheRetry-After: 1value overridesbackoff[0]to1s, so the test sleeps ~1s anyway. Consider usingRetry-After: 0here to keep the suite fast.github/client.goSetRetryBackoff([]time.Duration{})is called (empty, non-nil), the Retry-After delay is computed and capped but silently dropped (attempt < len(backoff)is0 < 0). Not a real-world concern given the test-only setter, but worth a doc note.Phase 2: Prior Review Verification
Reviews against old HEAD
41e1d48b— verifying against current HEADe414471a:backoff = append([]time.Duration(nil), c.retryBackoff...)— per-request copy is presentparseRetryAfterdoes not trim whitespace [MINOR]value = strings.TrimSpace(value)added at top of functionparseRetryAftertreats0seconds as invalid [MINOR]seconds > 0toseconds >= 0io.LimitReader(resp.Body, maxResponseBodyBytes)added on success pathTestDoRequest_429_RetryAfter_IntegerSeconds[MINOR]errorsbeforecontext[NIT]contextnow appears beforeerrorsin test importsReviews against current HEAD
e414471a(second-round reviews):ctx.Err()cleanly [MINOR]SetRetryBackoffsilently drops Retry-After delay [MINOR]Retry-After: 1test actually sleeps 1s despite zero backoff [NIT]Bearervstokenauth scheme [MINOR]strconv.Atoioverflow on 32-bit [MINOR]baseURLunused [MINOR]Assessment: ⚠️ Needs attention
The critical findings from the initial review (data race, whitespace trimming, zero-seconds handling, unbounded success read, dead test code, import ordering) are all correctly resolved. The implementation is solid Go — well-structured, idiomatic, context-aware, defensively bounded, and well-tested.
Remaining items are all minor-or-below: the unused
baseURLfield is the most actionable (a TODO comment or removal before it silently misleads future contributors), and theTestDoRequest_ContextCanceledtest not exercising the timer-cancel path is worth noting for test coverage completeness. The slow test (Retry-After: 1overriding zero backoff) is a quality-of-life nit.None of these are blockers. With the dead
baseURLfield acknowledged and the timer-cancellation test gap noted, this is mergeable.Sonnet Review
Summary
Well-structured GitHub client implementation with solid RFC 7231 Retry-After support, good test coverage, and thoughtful defensive programming. CI passes. A few minor issues worth noting but nothing blocking.
Findings
github/client.godelay == 0, the code skips the timer entirely (correct), but when delay > 0 and the timer fires normally (thecase <-timer.Cbranch),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 isdefer timer.Stop()immediately aftertime.NewTimer(delay). The current code only callstimer.Stop()on the context-cancellation path.github/client.goattempt < 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) andlen(backoff) == 2, thenattempt(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.github/client.gohttpfield name shadows the standardnet/httppackage import within method bodies. While Go resolves this correctly (the import is referenced ashttp.Client,http.StatusNotFound, etc. at package level), naming a struct fieldhttpis a recognized source of confusion and a go vet / staticcheck warning in some configurations. PreferhttpClientorhcas the field name.github/client.goAPIError.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.github/client_test.gow.Write(...)return values are ignored in test handlers throughout. This is standard for httptest handlers, but adding//nolintor accepting the convention is fine. Not a real issue.github/client.godoRequestsignature hasaccept stringas a named parameter afterreqURL string. Per the style patterns, prefer consistent parameter naming. Minor: the internal copy ofbackoffviaappend([]time.Duration(nil), c.retryBackoff...)is idiomatic but slightly obscure;slices.Clone(Go 1.21+) would be clearer if the project targets that version.Recommendation
APPROVE — Approve with the suggestion to fix the timer leak (MINOR finding #1) before or shortly after merge. The timer should have
defer timer.Stop()added aftertime.NewTimer(delay)to prevent the goroutine leak on the happy path. The field naming collision betweenhttp(struct field) andnet/http(import) is a real readability issue worth fixing but not blocking. The Retry-After override-on-last-attempt issue is a logic gap but low real-world impact given the backoff array is already consumed correctly for earlier attempts. The implementation is otherwise clean, well-documented, correctly useserrors.Asfor error unwrapping, properly limits response body reads, sanitizes log injection vectors, and has comprehensive test coverage including the context cancellation race path.Review by sonnet
Evaluated against
31a28b1d@@ -0,0 +36,4 @@// 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).//[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.@@ -0,0 +83,4 @@}return nil, false}[MINOR] The
httpfield name shadows the standardnet/httppackage import within method bodies. While Go resolves this correctly (the import is referenced ashttp.Client,http.StatusNotFound, etc. at package level), naming a struct fieldhttpis a recognized source of confusion and a go vet / staticcheck warning in some configurations. PreferhttpClientorhcas the field name.@@ -0,0 +148,4 @@// 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) {[NIT] The
doRequestsignature hasaccept stringas a named parameter afterreqURL string. Per the style patterns, prefer consistent parameter naming. Minor: the internal copy ofbackoffviaappend([]time.Duration(nil), c.retryBackoff...)is idiomatic but slightly obscure;slices.Clone(Go 1.21+) would be clearer if the project targets that version.@@ -0,0 +189,4 @@delay = backoff[attempt-1]}if delay > 0 {timer := time.NewTimer(delay)[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 (thecase <-timer.Cbranch),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 isdefer timer.Stop()immediately aftertime.NewTimer(delay). The current code only callstimer.Stop()on the context-cancellation path.@@ -0,0 +212,4 @@resp, err := c.http.Do(req)if err != nil {return nil, fmt.Errorf("do request: %w", err)[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) andlen(backoff) == 2, thenattempt(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.@@ -0,0 +34,4 @@defer srv.Close()c := NewClient("test-token", srv.URL)body, err := c.doGet(context.Background(), srv.URL+"/test")[NIT]
w.Write(...)return values are ignored in test handlers throughout. This is standard for httptest handlers, but adding//nolintor accepting the convention is fine. Not a real issue.Gpt Review
Summary
Solid implementation of a minimal GitHub client with robust Retry-After parsing (integer and HTTP-date), sensible caps, and clear error handling. Concurrency, error wrapping, and testing are handled well and CI passed.
Recommendation
APPROVE — The changes adhere to documented Go patterns and repository conventions. APIError provides structured errors with safe logging, retry logic respects Retry-After (with capping), and context cancellation is handled correctly. Tests are comprehensive and deterministic. Consider minor future refinements such as skipping the Authorization header when the token is empty and using strconv.ParseInt for Retry-After to avoid int overflow on 32-bit systems, but these are not blockers.
Review by gpt
Evaluated against
31a28b1dSecurity Review
Summary
The GitHub client adds robust Retry-After parsing (integer and HTTP-date), caps retry delays, enforces body size limits, and sanitizes error messages. Overall, the implementation is sound with good defensive measures and tests passing.
Findings
github/client.gogithub/client.goRecommendation
APPROVE — The implementation correctly handles Retry-After per RFC 7231, limits response sizes to mitigate resource exhaustion, and sanitizes error messages to reduce log injection risks. Tests comprehensively cover expected behaviors, and CI has passed. For added hardening, explicitly configure a CheckRedirect policy to prevent forwarding the Authorization header on cross-host redirects and ensure future exported methods constrain requests to the configured baseURL to avoid potential SSRF misuse. With these considerations noted, the PR is approved.
Review by security
Evaluated against
31a28b1d@@ -0,0 +117,4 @@return &Client{baseURL: strings.TrimRight(baseURL, "/"),token: token,http: &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.
@@ -0,0 +173,4 @@// 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.
Self-Review: PR #110
Self-review against
31a28b1dd5428c16878153be49cff668f4da5a7ePhase 1: Independent Findings
github/client.gotimer.Stop()is called only on thectx.Done()branch. When the timer fires normally (case <-timer.C),Stop()is never called. Per Go docs, callers should calltimer.Stop()and drain the channel ifStop()returns false to prevent internal goroutine leaks. The idiomatic fix isdefer timer.Stop()immediately aftertime.NewTimer(delay).github/client.gohttpfield shadowsnet/httpimport: The struct field is namedhttp *http.Client, which shadows thenet/httppackage name within method bodies. While Go resolves this correctly (package-level references use the import alias), this naming is a recognized readability issue. PreferhttpClientorhc.github/client_test.goTestDoRequest_429_RetryAfter_HTTPDatesleeps 1s: The test usesRetry-After1 second in the future, which overrides the zeroed backoff to 1s. The comment acknowledges this ("the HTTP-date parser will compute 1s and override"), so it's intentional, but it makes the test suite 1s slower. Consider using a 0s gap or a separate timing assertion. Not a correctness issue.Phase 2: Prior Review Verification
Reviews from first round (against
41e1d48b) — all addressed ine414471a:retryBackoffmutated indoRequest[MAJOR]backoff = append([]time.Duration(nil), c.retryBackoff...)— per-request copy presentparseRetryAfterdoesn't trim whitespace [MINOR]strings.TrimSpace(value)addedparseRetryAfterrejects0seconds [MINOR]seconds >= 0io.LimitReader(resp.Body, maxResponseBodyBytes)appliedTestDoRequest_429_RetryAfter_IntegerSeconds[MINOR]contextnow beforeerrorsReviews from second round (against
e414471a) — verifying in current HEAD31a28b1d:TestDoRequest_ContextCanceleddidn't exercise timer-cancel path [MINOR]requestReceivedchannel and cancels after first request, properly racing the timer selectRetry-After: 1overrides zero backoff causing slow test [NIT]TestDoRequest_429_RetryAfter_IntegerSecondsnow usesRetry-After: 0baseURLunused [MINOR]SetRetryBackoffsilently drops Retry-After [MINOR]SetRetryBackoffdoc acknowledges this edge case explicitlytimer.Stop()missing on happy pathhttpfield shadows package name [MINOR]http *http.ClientTestDoRequest_429_RetryAfter_HTTPDatesleeps 1s [NIT]Bearervstokenauth scheme [MINOR]strconv.Atoioverflow on 32-bit [MINOR]intis 64-bit on all realistic targetsAssessment: ⚠️ Needs attention
Good incremental progress since last self-review: the context cancellation test is now correctly exercising the timer-select path, the slow
Retry-After: 1test nit is fixed, and thebaseURLfield has a TODO comment. The core MAJOR finding (data race onretryBackoff) remains resolved.Two items from sonnet's latest review (3207) are still present: the timer leak (missing
defer timer.Stop()on the normal timer-fire path) is the most actionable — it's a one-line fix. Thehttpfield shadowing thenet/httppackage name is a minor readability issue. Neither is a correctness blocker, but the timerStop()gap is worth fixing before the client sees production load.Self-review findings addressed (commit
9f8e9aa)Timer leak on normal path — Added
timer.Stop()to thecase <-timer.Cbranch indoRequest. Both select branches now callStop(), ensuring the timer is cleaned up regardless of which path fires.httpfield shadowsnet/httpimport — Renamed struct field fromhttptohttpClientand updated all 4 references (NewClientinitializer,SetHTTPClient,doRequest).All tests pass (
go test ./...✅).Sonnet Review
Summary
Well-implemented GitHub API client with solid RFC 7231 Retry-After parsing. The code follows Go idioms, is well-documented, and has comprehensive test coverage. A few minor issues worth noting but nothing blocking.
Findings
github/client.godefer 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.github/client.gobackoff[attempt]but the delay is consumed atbackoff[attempt-1]on the next iteration (whenattempt-1 == current_attempt). Since the current attempt is 0 when we first hit a 429, the delay gets stored atbackoff[0]and consumed whenattempt=1readsbackoff[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 thatbackoff[attempt]is the delay before attempt+1 would help future maintainers.github/client.gonowfield 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, unlikeSetHTTPClientandSetRetryBackoff, there is no setter fornow, so tests must access the unexported field directly — which requires white-box tests in the same package. Since tests are inpackage github(notpackage github_test), this works fine. Just noting the inconsistency: the two other test-setup fields have exported setters, butnowdoes not.github/client.gohttpClient *http.Clientis not aligned with the other fields (baseURLandtoken).gofmtwill handle tab alignment within struct definitions, but the blank line betweentokenandhttpClientcreates a visual group separation that might be intentional (separating identity fields from transport config). Either way this is fine.github/client_test.gow.Write([]byte(...))return values are silently ignored in test handlers. In tests this is fine, but adding//nolintcomments or usingfmt.Fprintmight suppress any linter warnings about unchecked errors. Not a real issue.github/client_test.goTestDoRequest_ContextCanceledtest usestime.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.Recommendation
APPROVE — Approve. The implementation is correct, idiomatic Go. The RFC 7231 Retry-After parsing is properly implemented using
http.ParseTime, the cap at 60s is a sensible defense, context cancellation is handled correctly, and response bodies are bounded. Thec.nowinjection pattern for deterministic time testing is a well-established approach. Tests are comprehensive and usehttptestper the project conventions. The minor findings are all non-blocking: the timer.Stop() call after drain is harmless, the backoff index relationship is correct (just subtle), and the missing setter fornowis an acceptable inconsistency given the white-box test package.Review by sonnet
Evaluated against
9f8e9aa8@@ -0,0 +85,4 @@}// Client interacts with the GitHub API.// A Client is safe for concurrent use by multiple goroutines.[MINOR] The
nowfield 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, unlikeSetHTTPClientandSetRetryBackoff, there is no setter fornow, so tests must access the unexported field directly — which requires white-box tests in the same package. Since tests are inpackage github(notpackage github_test), this works fine. Just noting the inconsistency: the two other test-setup fields have exported setters, butnowdoes not.@@ -0,0 +90,4 @@// 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[NIT] Struct field alignment:
httpClient *http.Clientis not aligned with the other fields (baseURLandtoken).gofmtwill handle tab alignment within struct definitions, but the blank line betweentokenandhttpClientcreates a visual group separation that might be intentional (separating identity fields from transport config). Either way this is fine.@@ -0,0 +185,4 @@for attempt := 0; attempt < maxRetryAttempts; attempt++ {if attempt > 0 {var delay time.Durationif attempt-1 < len(backoff) {[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.@@ -0,0 +219,4 @@if resp.StatusCode >= 200 && resp.StatusCode < 300 {body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))resp.Body.Close()if err != nil {[MINOR] The Retry-After override logic stores the delay at
backoff[attempt]but the delay is consumed atbackoff[attempt-1]on the next iteration (whenattempt-1 == current_attempt). Since the current attempt is 0 when we first hit a 429, the delay gets stored atbackoff[0]and consumed whenattempt=1readsbackoff[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 thatbackoff[attempt]is the delay before attempt+1 would help future maintainers.@@ -0,0 +35,4 @@c := NewClient("test-token", srv.URL)body, err := c.doGet(context.Background(), srv.URL+"/test")if err != nil {[NIT]
w.Write([]byte(...))return values are silently ignored in test handlers. In tests this is fine, but adding//nolintcomments or usingfmt.Fprintmight suppress any linter warnings about unchecked errors. Not a real issue.@@ -0,0 +262,4 @@c.SetRetryBackoff([]time.Duration{10 * time.Second, 10 * time.Second})ctx, cancel := context.WithCancel(context.Background())defer cancel()[NIT] The
TestDoRequest_ContextCanceledtest usestime.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.Gpt Review
Summary
Well-structured, idiomatic implementation with robust Retry-After parsing for both integer and HTTP-date formats, solid error handling, and comprehensive tests. Minor improvements are suggested around integer parsing robustness, header handling, timer usage, and test coverage for one exported setter.
Findings
github/client.gogithub/client.gogithub/client.gogithub/client.gogithub/client.gogithub/client.goRecommendation
APPROVE — The implementation is correct, idiomatic, and well-tested, with Retry-After parsing aligned to RFC 7231 using http.ParseTime and sensible caps. CI has passed. I recommend approval. For follow-up refinements: (1) switch integer parsing in parseRetryAfter to strconv.ParseInt for architecture-independent robustness; (2) add a small test to exercise SetHTTPClient per repo conventions; (3) conditionally set the Authorization header only when token is non-empty; (4) simplify timer handling by not calling Stop after receiving from timer.C; (5) optionally use TrimSuffix for baseURL sanitation and add a User-Agent header. These are minor and do not block merging.
Review by gpt
Evaluated against
9f8e9aa8@@ -0,0 +95,4 @@// accepting full URLs instead.baseURL stringtoken stringhttpClient *http.Client[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.
@@ -0,0 +103,4 @@retryBackoff []time.Duration// now returns the current time. Defaults to time.Now.// Override in tests to control HTTP-date Retry-After calculations.[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.
@@ -0,0 +117,4 @@return &Client{baseURL: strings.TrimRight(baseURL, "/"),token: token,httpClient: &http.Client{Timeout: 30 * time.Second},[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.
@@ -0,0 +152,4 @@value = strings.TrimSpace(value)// Try integer seconds first (most common from GitHub).// RFC 7231 allows delta-seconds of 0 to indicate immediate retry.[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.
@@ -0,0 +165,4 @@delay = 0}return delay, true}[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.
@@ -0,0 +169,4 @@return 0, false}[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.
Security Review
Summary
The new GitHub client implements robust Retry-After parsing with sensible caps and strong DOS protections (timeouts and body size limits). Error handling avoids log injection and limits exposure. No exploitable vulnerabilities are evident, and CI has passed.
Findings
github/client.goRecommendation
APPROVE — Overall the implementation is sound and security-conscious (bounded reads, capped retry delays, sanitized error messages, and timeouts). Approve the PR as-is. For defense-in-depth, consider modifying doRequest to only attach the Authorization header when the request host matches the configured baseURL host, and/or keep doRequest strictly internal to GitHub-hosted endpoints to reduce the chance of SSRF/token exposure if misused in the future.
Review by security
Evaluated against
9f8e9aa8@@ -0,0 +133,4 @@//// 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.
Self-Review: PR #110
Self-review against
9f8e9aa8d3c1d641d608ca8d1f7b5dea8f2d1688Phase 1: Independent Findings
github/client_test.goTestDoRequest_429_RetryAfter_HTTPDatesetsRetry-Afterto 1 second in the future and usesSetRetryBackoff([]time.Duration{0, 0}). The Retry-After value will overridebackoff[0]to 1s, so the test still sleeps ~1s. A comment acknowledges this is intentional, but it makes the suite slower than necessary.github/client.gotimer.Stop()is called after<-timer.Cfires. While correct (prevents any edge-case leak), callingStop()after the channel has drained is a no-op per Go docs. Not harmful, just slightly unusual —defer timer.Stop()before the select is the more common idiom but would complicate the ctx.Done branch's Stop call. Current approach is fine.None — diff looks clean overall. The implementation is well-structured, idiomatic, and defensive.
Phase 2: Prior Review Verification
retryBackoffmutated indoRequest[MAJOR]append([]time.Duration(nil), c.retryBackoff...)present at line 165parseRetryAfterdoesn't trim whitespace [MINOR]strings.TrimSpace(value)at top of functionparseRetryAfterrejects0seconds [MINOR]seconds >= 0io.LimitReader(resp.Body, maxResponseBodyBytes)on success pathcontextbeforeerrorstimer.Stop()now called incase <-timer.Cbranchhttpfield shadowsnet/httpimport [MINOR]httpClientTestDoRequest_ContextCanceleddidn't exercise timer-cancel path [MINOR]requestReceivedchannel, cancels after first requestRetry-After: 1overrides zero backoff causing slow test [NIT]TestDoRequest_429_RetryAfter_IntegerSecondsnow usesRetry-After: 0baseURLunused [MINOR]SetRetryBackoffsilently drops Retry-After [MINOR]SetRetryBackoffdoc commentTestDoRequest_429_RetryAfter_HTTPDatesleeps 1s [NIT]Bearervstokenauth scheme [MINOR]strconv.Atoioverflow on 32-bit [MINOR]intis 64-bit on all realistic deployment targetsdoRequestmisused with untrusted URLs [MINOR]doRequestis unexported; future exported methods will constrain tobaseURLSetHTTPClientlacks direct test [MINOR]httptest.NewServerin every test using custom transportAssessment: ✅ Clean
All actionable findings from prior reviews have been resolved. The data race (MAJOR) is fixed with a per-request slice copy. The timer leak is fixed. The
httpfield shadowing is fixed. The context cancellation test properly exercises the timer-select path. The remaining items are all accepted design decisions or out-of-scope improvements that don't affect correctness or safety.The implementation is solid, idiomatic Go with good defensive practices (bounded reads, retry caps, log injection mitigation, context propagation) and comprehensive test coverage.