Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to set a per-attempt timeout #8

Merged
merged 1 commit into from
Oct 7, 2023

Conversation

justinrixx
Copy link
Contributor

@justinrixx justinrixx commented Sep 22, 2023

See #6

I haven't done any testing for go 1.6, but as called out in this change set setting a per-attempt timeout on go < 1.7 should be a no-op.

I also haven't tested this using go 1.7 (I'm running go 1.21), but I did my best to check the go versions the context and http request functions were added in and believe it's correct.

LMK if the cancel reader stuff doesn't make sense, I'm happy to further explain why it's necessary and / or add more to the docs (although it shouldn't matter at all to external consumers).

@justinrixx
Copy link
Contributor Author

just rebased to latest master

Copy link
Member

@mna mna left a comment

Choose a reason for hiding this comment

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

Thanks for the PR! Took a first look at it, it looks great! Just left some minor things that I think should be addressed before merging.

rehttp.go Outdated
@@ -280,6 +282,21 @@ type Transport struct {
// is non-nil.
PreventRetryWithBody bool

// PerAttemptTimeout can be optionally set to change the timeouts to be
// per-attempt instead of overall. For example, a per-attempt timeout of
Copy link
Member

Choose a reason for hiding this comment

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

It doesn't "change" the timeout to be per-attempt, right? It sets a per-attempt timeout, potentially in addition to the overall timeout as I understand it?

rehttp.go Outdated
res, err := t.RoundTripper.RoundTrip(req)
var cancel context.CancelFunc = func() {} // empty unless a timeout is set
reqWithTimeout := req
if t.PerAttemptTimeout != time.Duration(0) {
Copy link
Member

Choose a reason for hiding this comment

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

I'd simplify to this:

Suggested change
if t.PerAttemptTimeout != time.Duration(0) {
if t.PerAttemptTimeout != 0 {


func TestTransport_RoundTripTimeouts(t *testing.T) {
attemptCountMu := sync.Mutex{}
attemptCount := 0
Copy link
Member

Choose a reason for hiding this comment

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

I'd simplify this a bit by using an int32 and the atomic package, e.g. https://pkg.go.dev/sync/[email protected]#AddInt32

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If I use a buffered channel for synchronization (to eliminate the sleep) each of these will already effectively assert 4 attempts (because the test won't move on until 4 channel reads). That could totally eliminate the need for the attemptCount. Downside is that if this test ever breaks it'll break by hanging (waiting on a channel read), which won't make the failure mode obvious. Do you have a preference between the two? (eliminating attemptCount, its increments, and any assertions on it vs keeping it around but using atomic addition so that an assertion can fail with a clearer message before the channel reads?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually keeping attemptCount doesn't make sense because if its value is checked before the channel reads the attempt count isn't final yet (server hasn't served them all). Checking its value after the channel reads is unhelpful because the channel reads already assert the correct number of attempts.

func(attempt Attempt) bool { // retry context deadline exceeded errors
return attempt.Error != nil && attempt.Error == context.DeadlineExceeded // errors.Is requires go 1.13+
})),
ConstDelay(time.Duration(0)),
Copy link
Member

Choose a reason for hiding this comment

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

I don't think you need the type conversion here, I think the const will take the proper type:

Suggested change
ConstDelay(time.Duration(0)),
ConstDelay(0),

}

_, err = client.Do(req)
time.Sleep(time.Millisecond * 100) // let the server finish sleeping
Copy link
Member

Choose a reason for hiding this comment

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

Not a fan of having sleeps in there as this is so often a source of flaky tests, but this might get a bit tricky to do otherwise (e.g. with a channel, waiting for it to be closed here). I'm fine keeping it as-is if the alternative is too complex.

t.Errorf("status code does not match expected: got %d, want %d", res.StatusCode, http.StatusTooManyRequests)
}
attemptCountMu.Unlock()
}
Copy link
Member

Choose a reason for hiding this comment

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

What I think would be good to have is a test with both a per-attempt and an overall timeout, and a case where the overall triggers instead of the per-attempt?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added this in TestTransport_RoundTripOverallTimeout

// preventing the race-case of being unable to read the body due to a
// preemptively-canceled context.
func TestCancelReader(t *testing.T) {
rt := NewTransport(http.DefaultTransport, RetryMaxRetries(1), ConstDelay(time.Duration(0)))
Copy link
Member

Choose a reason for hiding this comment

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

Same here, time.Duration conversion probably not needed.

}
if res == nil {
t.Error("unexpected nil response")
}
Copy link
Member

Choose a reason for hiding this comment

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

I don't think client.Do will ever return nil if err != nil? I think those res==nil or res != nil checks can savely be removed.

@justinrixx
Copy link
Contributor Author

Thanks for the PR! Took a first look at it, it looks great! Just left some minor things that I think should be addressed before merging.

All reasonable feedback, I’ll try to get this revised over the next week or so.

@justinrixx
Copy link
Contributor Author

Hmm these tests passed locally so I thought it was ok.

Might be flakiness with the per-attempt + overall timeout test making fewer or more attempts than there are channel slots. I'll take a look at revising these soon.

@justinrixx
Copy link
Contributor Author

I believe I've addressed all of your comments now. Let me know if I've missed something or if you have any other feedback.

@mna
Copy link
Member

mna commented Oct 4, 2023

Thanks! I'll try to take another look this weekend to get it merged.

Copy link
Member

@mna mna left a comment

Choose a reason for hiding this comment

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

Thanks, that looks good! There's a change I want to make in how the test captures the number of requests so that it's a little more robust, I'll try to find some time to get this done soon.

ch := make(chan bool, 4)

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ch <- true
Copy link
Member

Choose a reason for hiding this comment

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

The problem with this approach is that the test could block forever (well, until go test times out) if the assumptions are not met, instead of failing fast (i.e. if the client happens to make 5 requests, the 5th one would block forever because the channel is full and no receives have been done yet).

I have an idea on how to make the test more robust, I'll try to take a shot at it when I have a moment, the general idea would be to spin a goroutine that receives in a for range on the channel, and close the channel after the call to client.Do so that the goroutine exits, and unblocks another channel (or a WaitGroup, but the nice thing with a channel is that the test could do a select on that channel and a timer so that it fails fast if for some reason it takes longer than expected to run the test).

That's not super easy to explain, that's why I say I'll take a shot at implementing it when I have a moment.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm having a hard time understanding exactly what you're describing, but I agree that if this does fail it ends up in the undesirable state of blocking on the channel until the test times out. I don't have a great idea for solving this, but will take a look at your change set to hopefully learn something when you add it in.

Thanks for spending the time to read, review, and re-review this.

t.Errorf("got unexpected error doing request: %s", err)
}
if res == nil || res.StatusCode != http.StatusTooManyRequests {
t.Errorf("status code does not match expected: got %d, want %d", res.StatusCode, http.StatusTooManyRequests)
Copy link
Member

Choose a reason for hiding this comment

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

This will panic if res is nil. I suggest calling t.Fatalf on line 68 above, so that after this condition res is necessarily not nil. (I can take care of this change when I implement the channel thing I mentioned above).

@mna
Copy link
Member

mna commented Oct 7, 2023

Actually, it's just an improvement in the test, the feature looks good so I'll make the minor adjustments I mentioned and get it merged, we can always make the test more robust later on. I don' t know when I'll have time to get back to this and I don't want to leave your great work pending just for that.

@mna mna merged commit 1b9ff12 into PuerkitoBio:master Oct 7, 2023
6 checks passed
@justinrixx justinrixx deleted the per-attempt-timeouts branch October 8, 2023 02:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants