Skip to content

fix: Honor http.Client.Timeout in roundTripWithOptionalFollowRedirect - #4711

Open
JamBalaya56562 wants to merge 2 commits into
google:masterfrom
JamBalaya56562:fix/4584-redirect-timeout
Open

JamBalaya56562 wants to merge 2 commits into
google:masterfrom
JamBalaya56562:fix/4584-redirect-timeout

Conversation

@JamBalaya56562

Copy link
Copy Markdown
Contributor

Fixes #4584.

Closes: #4585
Closes: #4688

This takes over #4585, which is blocked on the CLA, and #4688.

roundTripWithOptionalFollowRedirect calls the transport directly, so the http.Client.Timeout set by WithTimeout or WithHTTPClient was never applied.

This PR follows the approach suggested in #4585 (review) (the context.WithTimeout block at the top of the helper):

  • http.Client.Do is not used, so transport errors are not wrapped in *url.Error, the cookie jar is not attached, and checkRedirectHost still runs first.
  • The opts ...RequestOption parameter is kept.
  • The tests are in github/github_test.go and use synctest instead of time.Sleep.

One difference from the suggested snippet: defer cancel() cancels the context as soon as the helper returns, but RepositoriesService.GetBranch decodes resp.Body after the helper returns. With a timeout set, that read fails with read tcp ...: use of closed network connection. So on success the cancel is moved to the response body and runs on Close; on every other exit it runs when the helper returns.

Tests:

  • TestRoundTripWithOptionalFollowRedirect_HonorsHTTPClientTimeout (the test from fix: Honor HTTP client timeouts in redirects #4585): the request ends with context.DeadlineExceeded after the client timeout. Fails on master (request took 2s, want client timeout 1s).
  • TestRoundTripWithOptionalFollowRedirect_KeepsTimeoutUntilBodyClosed: a 1 MiB body can still be read after the helper returns, and the request context is canceled once the body is closed. Fails on master, and also fails with a plain defer cancel().

@gmlewis gmlewis added the NeedsReview PR is awaiting a review before merging. label Nov 10, 2026
@codecov

codecov Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.64%. Comparing base (ccd0eea) to head (065cebd).
⚠️ Report is 3 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #4711   +/-   ##
=======================================
  Coverage   98.64%   98.64%           
=======================================
  Files         204      204           
  Lines       19080    19164   +84     
=======================================
+ Hits        18822    18905   +83     
- Misses        258      259    +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@mangeshraut712 mangeshraut712 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the deadline until Body.Close() is the right call, and it matches http.Client.Timeout's documented scope ("includes ... reading the response body"). I checked all six callers (downloadArtifactWithoutRateLimit, getArchiveLinkWithoutRateLimit, the workflow run/job log helpers, and repos.go:1428). Each one defer resp.Body.Close()s right after the call, so the wrapper's cancel always runs and no timer outlives the request.

Comment thread github/github.go
Comment on lines +2286 to +2297
if t := c.client.Timeout; t > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, t)
defer func() {
// Callers may still read the body, so keep the deadline until it is closed.
if resp != nil && resp.Body != nil {
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
return
}
cancel()
}()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the 301 branch recurses into this same function, every redirect hop adds another context.WithTimeout layer and wraps the body in another cancelOnCloseBody. That's correct: the inner deadline is min(remaining, t), so the whole chain still shares one budget like http.Client does, and closing the outer wrapper cancels every layer. But the reason isn't obvious from reading it. Moving the timeout setup into a thin entry point and having the 301 branch call an inner, timeout-free helper would make the one-budget-across-redirects semantics explicit and avoid the nesting. Either way, a test with maxRedirects >= 1 where the first hop uses part of the budget would pin that a redirect doesn't reset the timeout.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — you're right that each 301 hop nests another context.WithTimeout and cancelOnCloseBody. I added TestRoundTripWithOptionalFollowRedirect_RedirectDoesNotResetTimeout to pin that a redirect doesn't reset the budget (it fails if the inner call gets a fresh timeout: request took 1.6s, want client timeout 1s).
I kept the timeout block at the top of the helper to follow the minimal approach suggested in #4585 (review).
@gmlewis, would you prefer splitting it into an outer entry point and a timeout-free inner helper, or keeping the current shape?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

NeedsReview PR is awaiting a review before merging.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

http.Client.Timeout is ignored by redirect-aware requests

3 participants