1
0
mirror of https://github.com/golang/go synced 2024-11-17 21:44:43 -07:00

net/http: make Transport retry GetBody requests if nothing written

This is another attempt at the change attempted in
https://golang.org/cl/27117 and rolled back in https://golang.org/cl/34134

The difference between this and the previous attempt is that this version only
retries if the new field GetBody is set on the Request.

Additionally, this allows retries of requests with idempotent methods even if
they have bodies, as long as GetBody is defined.

This also fixes an existing bug where readLoop could make a redundant call to
setReqCanceler for DELETE/POST/PUT/etc requests with no body with zero bytes
written.

This clarifies the existing TestRetryIdempotentRequestsOnError test (and changes
it into a test with 4 subtests).  When that test was written, it was in fact
testing "retry idempotent requests" logic, but the logic had changed since then,
and it was actually testing "retry requests with no body when no bytes have been
written". (You can confirm this by changing the existing test from a GET to a
DELETE; it passes without the changes in this CL.) We now test for the no-Body
and GetBody cases for both idempotent and nothing-written-non-idempotent
requests.

Fixes #18241
Fixes #17844

Change-Id: I69a48691796f6dc08c31f7aa7887b7dfd67e278a
Reviewed-on: https://go-review.googlesource.com/42142
Run-TryBot: Brad Fitzpatrick <bradfitz@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
This commit is contained in:
David Glasser 2017-04-28 16:40:39 -07:00 committed by Brad Fitzpatrick
parent c8ab8c1f99
commit eea8c88a09
6 changed files with 187 additions and 83 deletions

View File

@ -1727,8 +1727,8 @@ func (b issue18239Body) Close() error {
return nil
}
// Issue 18239: make sure the Transport doesn't retry requests with bodies.
// (Especially if Request.GetBody is not defined.)
// Issue 18239: make sure the Transport doesn't retry requests with bodies
// if Request.GetBody is not defined.
func TestTransportBodyReadError(t *testing.T) {
setParallel(t)
defer afterTest(t)

View File

@ -25,6 +25,7 @@ var (
ExportCloseWriteAndWait = (*conn).closeWriteAndWait
ExportErrRequestCanceled = errRequestCanceled
ExportErrRequestCanceledConn = errRequestCanceledConn
ExportErrServerClosedIdle = errServerClosedIdle
ExportServeFile = serveFile
ExportScanETag = scanETag
ExportHttp2ConfigureServer = http2ConfigureServer

View File

@ -1317,7 +1317,7 @@ func (r *Request) closeBody() {
}
func (r *Request) isReplayable() bool {
if r.Body == nil {
if r.Body == nil || r.Body == NoBody || r.GetBody != nil {
switch valueOrDefault(r.Method, "GET") {
case "GET", "HEAD", "OPTIONS", "TRACE":
return true

View File

@ -419,6 +419,18 @@ func (t *Transport) RoundTrip(req *Request) (*Response, error) {
return nil, err
}
testHookRoundTripRetried()
// Rewind the body if we're able to. (HTTP/2 does this itself so we only
// need to do it for HTTP/1.1 connections.)
if req.GetBody != nil && pconn.alt == nil {
newReq := *req
var err error
newReq.Body, err = req.GetBody()
if err != nil {
return nil, err
}
req = &newReq
}
}
}
@ -450,8 +462,9 @@ func (pc *persistConn) shouldRetryRequest(req *Request, err error) bool {
return false
}
if _, ok := err.(nothingWrittenError); ok {
// We never wrote anything, so it's safe to retry.
return true
// We never wrote anything, so it's safe to retry, if there's no body or we
// can "rewind" the body with GetBody.
return req.outgoingLength() == 0 || req.GetBody != nil
}
if !req.isReplayable() {
// Don't retry non-idempotent requests.
@ -1475,7 +1488,7 @@ func (pc *persistConn) mapRoundTripError(req *transportRequest, startBytesWritte
}
if pc.isBroken() {
<-pc.writeLoopDone
if pc.nwrite == startBytesWritten && req.outgoingLength() == 0 {
if pc.nwrite == startBytesWritten {
return nothingWrittenError{err}
}
return fmt.Errorf("net/http: HTTP/1.x transport connection broken: %v", err)
@ -1544,16 +1557,6 @@ func (pc *persistConn) readLoop() {
err = fmt.Errorf("net/http: server response headers exceeded %d bytes; aborted", pc.maxHeaderResponseSize())
}
// If we won't be able to retry this request later (from the
// roundTrip goroutine), mark it as done now.
// BEFORE the send on rc.ch, as the client might re-use the
// same *Request pointer, and we don't want to set call
// t.setReqCanceler from this persistConn while the Transport
// potentially spins up a different persistConn for the
// caller's subsequent request.
if !pc.shouldRetryRequest(rc.req, err) {
pc.t.setReqCanceler(rc.req, nil)
}
select {
case rc.ch <- responseAndError{err: err}:
case <-rc.callerGone:
@ -1768,7 +1771,7 @@ func (pc *persistConn) writeLoop() {
}
if err != nil {
wr.req.Request.closeBody()
if pc.nwrite == startBytesWritten && wr.req.outgoingLength() == 0 {
if pc.nwrite == startBytesWritten {
err = nothingWrittenError{err}
}
}

View File

@ -9,6 +9,7 @@ package http
import (
"errors"
"net"
"strings"
"testing"
)
@ -81,6 +82,19 @@ func dummyRequest(method string) *Request {
}
return req
}
func dummyRequestWithBody(method string) *Request {
req, err := NewRequest(method, "http://fake.tld/", strings.NewReader("foo"))
if err != nil {
panic(err)
}
return req
}
func dummyRequestWithBodyNoGetBody(method string) *Request {
req := dummyRequestWithBody(method)
req.GetBody = nil
return req
}
func TestTransportShouldRetryRequest(t *testing.T) {
tests := []struct {
@ -132,6 +146,18 @@ func TestTransportShouldRetryRequest(t *testing.T) {
err: errServerClosedIdle,
want: true,
},
7: {
pc: &persistConn{reused: true},
req: dummyRequestWithBody("POST"),
err: nothingWrittenError{},
want: true,
},
8: {
pc: &persistConn{reused: true},
req: dummyRequestWithBodyNoGetBody("POST"),
err: nothingWrittenError{},
want: false,
},
}
for i, tt := range tests {
got := tt.pc.shouldRetryRequest(tt.req, tt.err)

View File

@ -2601,86 +2601,160 @@ type writerFuncConn struct {
func (c writerFuncConn) Write(p []byte) (n int, err error) { return c.write(p) }
// Issue 4677. If we try to reuse a connection that the server is in the
// process of closing, we may end up successfully writing out our request (or a
// portion of our request) only to find a connection error when we try to read
// from (or finish writing to) the socket.
// Issues 4677, 18241, and 17844. If we try to reuse a connection that the
// server is in the process of closing, we may end up successfully writing out
// our request (or a portion of our request) only to find a connection error
// when we try to read from (or finish writing to) the socket.
//
// NOTE: we resend a request only if the request is idempotent, we reused a
// keep-alive connection, and we haven't yet received any header data. This
// automatically prevents an infinite resend loop because we'll run out of the
// cached keep-alive connections eventually.
func TestRetryIdempotentRequestsOnError(t *testing.T) {
defer afterTest(t)
var (
mu sync.Mutex
logbuf bytes.Buffer
)
logf := func(format string, args ...interface{}) {
mu.Lock()
defer mu.Unlock()
fmt.Fprintf(&logbuf, format, args...)
logbuf.WriteByte('\n')
// NOTE: we resend a request only if:
// - we reused a keep-alive connection
// - we haven't yet received any header data
// - either we wrote no bytes to the server, or the request is idempotent
// This automatically prevents an infinite resend loop because we'll run out of
// the cached keep-alive connections eventually.
func TestRetryRequestsOnError(t *testing.T) {
newRequest := func(method, urlStr string, body io.Reader) *Request {
req, err := NewRequest(method, urlStr, body)
if err != nil {
t.Fatal(err)
}
return req
}
ts := httptest.NewServer(HandlerFunc(func(w ResponseWriter, r *Request) {
logf("Handler")
w.Header().Set("X-Status", "ok")
}))
defer ts.Close()
var writeNumAtomic int32
c := ts.Client()
c.Transport.(*Transport).Dial = func(network, addr string) (net.Conn, error) {
logf("Dial")
c, err := net.Dial(network, ts.Listener.Addr().String())
if err != nil {
logf("Dial error: %v", err)
return nil, err
}
return &writerFuncConn{
Conn: c,
write: func(p []byte) (n int, err error) {
if atomic.AddInt32(&writeNumAtomic, 1) == 2 {
logf("intentional write failure")
return 0, errors.New("second write fails")
}
logf("Write(%q)", p)
return c.Write(p)
testCases := []struct {
name string
failureN int
failureErr error
// Note that we can't just re-use the Request object across calls to c.Do
// because we need to rewind Body between calls. (GetBody is only used to
// rewind Body on failure and redirects, not just because it's done.)
req func() *Request
reqString string
}{
{
name: "IdempotentNoBodySomeWritten",
// Believe that we've written some bytes to the server, so we know we're
// not just in the "retry when no bytes sent" case".
failureN: 1,
// Use the specific error that shouldRetryRequest looks for with idempotent requests.
failureErr: ExportErrServerClosedIdle,
req: func() *Request {
return newRequest("GET", "http://fake.golang", nil)
},
}, nil
reqString: `GET / HTTP/1.1\r\nHost: fake.golang\r\nUser-Agent: Go-http-client/1.1\r\nAccept-Encoding: gzip\r\n\r\n`,
},
{
name: "IdempotentGetBodySomeWritten",
// Believe that we've written some bytes to the server, so we know we're
// not just in the "retry when no bytes sent" case".
failureN: 1,
// Use the specific error that shouldRetryRequest looks for with idempotent requests.
failureErr: ExportErrServerClosedIdle,
req: func() *Request {
return newRequest("GET", "http://fake.golang", strings.NewReader("foo\n"))
},
reqString: `GET / HTTP/1.1\r\nHost: fake.golang\r\nUser-Agent: Go-http-client/1.1\r\nContent-Length: 4\r\nAccept-Encoding: gzip\r\n\r\nfoo\n`,
},
{
name: "NothingWrittenNoBody",
// It's key that we return 0 here -- that's what enables Transport to know
// that nothing was written, even though this is a non-idempotent request.
failureN: 0,
failureErr: errors.New("second write fails"),
req: func() *Request {
return newRequest("DELETE", "http://fake.golang", nil)
},
reqString: `DELETE / HTTP/1.1\r\nHost: fake.golang\r\nUser-Agent: Go-http-client/1.1\r\nAccept-Encoding: gzip\r\n\r\n`,
},
{
name: "NothingWrittenGetBody",
// It's key that we return 0 here -- that's what enables Transport to know
// that nothing was written, even though this is a non-idempotent request.
failureN: 0,
failureErr: errors.New("second write fails"),
// Note that NewRequest will set up GetBody for strings.Reader, which is
// required for the retry to occur
req: func() *Request {
return newRequest("POST", "http://fake.golang", strings.NewReader("foo\n"))
},
reqString: `POST / HTTP/1.1\r\nHost: fake.golang\r\nUser-Agent: Go-http-client/1.1\r\nContent-Length: 4\r\nAccept-Encoding: gzip\r\n\r\nfoo\n`,
},
}
SetRoundTripRetried(func() {
logf("Retried.")
})
defer SetRoundTripRetried(nil)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
defer afterTest(t)
for i := 0; i < 3; i++ {
res, err := c.Get("http://fake.golang/")
if err != nil {
t.Fatalf("i=%d: Get = %v", i, err)
}
res.Body.Close()
}
var (
mu sync.Mutex
logbuf bytes.Buffer
)
logf := func(format string, args ...interface{}) {
mu.Lock()
defer mu.Unlock()
fmt.Fprintf(&logbuf, format, args...)
logbuf.WriteByte('\n')
}
mu.Lock()
got := logbuf.String()
mu.Unlock()
const want = `Dial
Write("GET / HTTP/1.1\r\nHost: fake.golang\r\nUser-Agent: Go-http-client/1.1\r\nAccept-Encoding: gzip\r\n\r\n")
ts := httptest.NewServer(HandlerFunc(func(w ResponseWriter, r *Request) {
logf("Handler")
w.Header().Set("X-Status", "ok")
}))
defer ts.Close()
var writeNumAtomic int32
c := ts.Client()
c.Transport.(*Transport).Dial = func(network, addr string) (net.Conn, error) {
logf("Dial")
c, err := net.Dial(network, ts.Listener.Addr().String())
if err != nil {
logf("Dial error: %v", err)
return nil, err
}
return &writerFuncConn{
Conn: c,
write: func(p []byte) (n int, err error) {
if atomic.AddInt32(&writeNumAtomic, 1) == 2 {
logf("intentional write failure")
return tc.failureN, tc.failureErr
}
logf("Write(%q)", p)
return c.Write(p)
},
}, nil
}
SetRoundTripRetried(func() {
logf("Retried.")
})
defer SetRoundTripRetried(nil)
for i := 0; i < 3; i++ {
res, err := c.Do(tc.req())
if err != nil {
t.Fatalf("i=%d: Do = %v", i, err)
}
res.Body.Close()
}
mu.Lock()
got := logbuf.String()
mu.Unlock()
want := fmt.Sprintf(`Dial
Write("%s")
Handler
intentional write failure
Retried.
Dial
Write("GET / HTTP/1.1\r\nHost: fake.golang\r\nUser-Agent: Go-http-client/1.1\r\nAccept-Encoding: gzip\r\n\r\n")
Write("%s")
Handler
Write("GET / HTTP/1.1\r\nHost: fake.golang\r\nUser-Agent: Go-http-client/1.1\r\nAccept-Encoding: gzip\r\n\r\n")
Write("%s")
Handler
`
if got != want {
t.Errorf("Log of events differs. Got:\n%s\nWant:\n%s", got, want)
`, tc.reqString, tc.reqString, tc.reqString)
if got != want {
t.Errorf("Log of events differs. Got:\n%s\nWant:\n%s", got, want)
}
})
}
}