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

httpbp: Fix Retries middleware #647

Merged
merged 1 commit into from
Apr 18, 2024
Merged

httpbp: Fix Retries middleware #647

merged 1 commit into from
Apr 18, 2024

Conversation

fishy
Copy link
Member

@fishy fishy commented Apr 17, 2024

When we set GetBody in http.Request, it's expected that Body is also
set, add special handling in Retries to make sure we also set Body when
retrying when GetBody is also set before each retry attempt.

Also always clone the request before each retry attempt to avoid some
subtle errors, and skip the Retries middleware altogether if Body is set
but GetBody is not.

@fishy fishy requested a review from a team as a code owner April 17, 2024 16:25
@fishy fishy requested review from kylelemons, konradreiche and pacejackson and removed request for a team April 17, 2024 16:25
if len(retryOptions) == 0 {
retryOptions = []retry.Option{retry.Attempts(1)}
}
return func(next http.RoundTripper) http.RoundTripper {
return roundTripperFunc(func(req *http.Request) (resp *http.Response, err error) {
if req.GetBody != nil {
Copy link
Contributor

Choose a reason for hiding this comment

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

if GetBody is nil for a non-get / bodyful request, should we just hand off to next and do a once-printed error that retries are disabled for this request?

Copy link
Member Author

Choose a reason for hiding this comment

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

that in most cases would result in 4xx errors anyways, I don't think we should try to get too smart.

maybe after the retry attempt, check if it's 4xx and the method is non-get and GetBody is nil, print a log message saying this is failed likely because you didn't set GetBody, but doing anything beyond the logging would be "too smart" in my books :)

also since http.NewRequest(-WithContext) auto populates GetBody with *bytes.Buffer/*bytes.Reader/*strings.Reader which should already covers like 95% of the cases how people give the request a body, I won't worry too much about that.

Copy link
Contributor

@kylelemons kylelemons Apr 17, 2024

Choose a reason for hiding this comment

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

a few things:

  • we shouldn't assume that sending a request without a body is even safe, let alone that we'll be able to distinguish the error the server will return.
  • what if it's a DELETE /some/resource and the body specifies that only part of the resource was to be deleted, and an empty body deletes the whole thing?
  • failing to have a GetBody can fail even before you have to do a retry, if the request redirects, so having the possibility of preventing more bugs seems like an added benefit of the log message
  • there are tons of instances of &http.Request{ that are not calling NewRequest. Honestly I rarely call it, because I don't like handling the error.

I think the right thing to do is to skip the middleware (with a log message indicating why) if we're not going to be able to function properly, since we can't do the better thing and return an error immediately (since that would be a breaking change).

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think there's anywhere in the RFC to say that a POST request must have a body. A server can be lenient on the method (want GET but don't complain if it's a POST instead) and the client can misuse POST instead of GET without a body. So being too smart can break RFC compliant code (although definitely not ideal code).

But I guess rejecting requests with non-nil Body and nil GetBody is probably safe enough.

Copy link
Member

Choose a reason for hiding this comment

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

I struggle to draw the line here between useful defaults and not trying to be too clever.

At least I can see a few cases where POST and nil-body makes sense:

  • REST endpoint to create an entity but the server side creates it with default attributes, no body needed
  • Upload of an empty file

While the latter maybe should be an error I'd think it's not for the middleware to decide that.

But maybe Kyle knows of instances where this just leads to more headaches.

@fishy fishy requested a review from kylelemons April 17, 2024 17:50
err = retrybp.Do(req.Context(), func() error {
// include ClientErrorWrapper to ensure retry is applied for
// some HTTP 5xx responses
resp, err = ClientErrorWrapper(limit)(next).RoundTrip(req)
resp, err = ClientErrorWrapper(maxErrorReadAhead)(next).RoundTrip(req)
Copy link
Contributor

Choose a reason for hiding this comment

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

also, shouldn't this be cloning the request? or does ClientErrorWrapper do that?

Copy link
Member Author

Choose a reason for hiding this comment

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

is cloning the request required? the doccomment of http.Request.Clone doesn't say a request cannot be reused explicitly (the unit test also works, but maybe some more complicated features like trailer would fail).

Copy link
Member

Choose a reason for hiding this comment

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

Looking over the fields of http.Request I can see there are a couple of fields including a channel for cancellation where it's unclear how it would behave if we don't always clone the request.

When we set GetBody in http.Request, it's expected that Body is also
set, add special handling in Retries to make sure we also set Body when
retrying when GetBody is also set before each retry attempt.

Also always clone the request before each retry attempt to avoid some
subtle errors, and skip the Retries middleware altogether if Body is set
but GetBody is not.
@fishy fishy changed the title httpbp: Fix retry request body httpbp: Fix Retries middleware Apr 18, 2024
@fishy
Copy link
Member Author

fishy commented Apr 18, 2024

💇 Fixed this for real.

func TestRetry(t *testing.T) {
t.Run("retry for timeout", func(t *testing.T) {
Copy link
Member Author

Choose a reason for hiding this comment

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

this subtest is removed because we don't really retry for timeouts (there's no timeout budget left for any retries), the expected attempt in this subtest is also 1.

return roundTripperFunc(func(req *http.Request) (resp *http.Response, err error) {
if req.Body != nil && req.Body != http.NoBody && req.GetBody == nil {
slog.WarnContext(
Copy link
Contributor

Choose a reason for hiding this comment

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

🔕 do you want to this as a once or do it on a rate limit?

Copy link
Member Author

Choose a reason for hiding this comment

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

this will only log once per request (it's not inside the retry loop), so if it's logged multiple times that means users have multiple requests constructed incorrectly, which I think it's warranted to log multiple times (for each incorrect request)

and we are using slog, so if users really want to rate-limit it/suppress it they can do so in their slog handler :)

wdyt?

Copy link
Contributor

Choose a reason for hiding this comment

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

🤷

@fishy fishy merged commit fb57fd8 into master Apr 18, 2024
2 checks passed
@fishy fishy deleted the httpbp-fix-retry branch April 18, 2024 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

3 participants