Commit bfd9b940 authored by Brad Fitzpatrick's avatar Brad Fitzpatrick

net/http: make Transport respect {X-,}Idempotency-Key header

Fixes #19943

Change-Id: I5e0fefe44791d7b3556095d726c2a753ec551ef2
Reviewed-on: https://go-review.googlesource.com/c/147457
Run-TryBot: Brad Fitzpatrick <bradfitz@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: 's avatarDmitri Shuralyov <dmitshur@golang.org>
parent 0c7762cd
......@@ -242,3 +242,5 @@ func ExportSetH2GoawayTimeout(d time.Duration) (restore func()) {
http2goAwayTimeout = d
return func() { http2goAwayTimeout = old }
}
func (r *Request) ExportIsReplayable() bool { return r.isReplayable() }
......@@ -52,6 +52,13 @@ func (h Header) get(key string) string {
return ""
}
// has reports whether h has the provided key defined, even if it's
// set to 0-length slice.
func (h Header) has(key string) bool {
_, ok := h[key]
return ok
}
// Del deletes the values associated with key.
// The key is case insensitive; it is canonicalized by
// textproto.CanonicalMIMEHeaderKey.
......
......@@ -579,7 +579,7 @@ func (r *Request) write(w io.Writer, usingProxy bool, extraHeaders Header, waitF
// Use the defaultUserAgent unless the Header contains one, which
// may be blank to not send the header.
userAgent := defaultUserAgent
if _, ok := r.Header["User-Agent"]; ok {
if r.Header.has("User-Agent") {
userAgent = r.Header.Get("User-Agent")
}
if userAgent != "" {
......@@ -1345,6 +1345,12 @@ func (r *Request) isReplayable() bool {
case "GET", "HEAD", "OPTIONS", "TRACE":
return true
}
// The Idempotency-Key, while non-standard, is widely used to
// mean a POST or other request is idempotent. See
// https://golang.org/issue/19943#issuecomment-421092421
if r.Header.has("Idempotency-Key") || r.Header.has("X-Idempotency-Key") {
return true
}
}
return false
}
......
......@@ -544,6 +544,38 @@ var reqWriteTests = []reqWriteTest{
"User-Agent: Go-http-client/1.1\r\n" +
"\r\n",
},
// Verify that a nil header value doesn't get written.
23: {
Req: Request{
Method: "GET",
URL: mustParseURL("/foo"),
Header: Header{
"X-Foo": []string{"X-Bar"},
"X-Idempotency-Key": nil,
},
},
WantWrite: "GET /foo HTTP/1.1\r\n" +
"Host: \r\n" +
"User-Agent: Go-http-client/1.1\r\n" +
"X-Foo: X-Bar\r\n\r\n",
},
24: {
Req: Request{
Method: "GET",
URL: mustParseURL("/foo"),
Header: Header{
"X-Foo": []string{"X-Bar"},
"X-Idempotency-Key": []string{},
},
},
WantWrite: "GET /foo HTTP/1.1\r\n" +
"Host: \r\n" +
"User-Agent: Go-http-client/1.1\r\n" +
"X-Foo: X-Bar\r\n\r\n",
},
}
func TestRequestWrite(t *testing.T) {
......
......@@ -1390,7 +1390,7 @@ func (cw *chunkWriter) writeHeader(p []byte) {
}
}
if _, ok := header["Date"]; !ok {
if !header.has("Date") {
setHeader.date = appendTime(cw.res.dateBuf[:0], time.Now())
}
......
......@@ -91,6 +91,15 @@ func init() {
// considered a terminal status and returned by RoundTrip. To see the
// ignored 1xx responses, use the httptrace trace package's
// ClientTrace.Got1xxResponse.
//
// Transport only retries a request upon encountering a network error
// if the request is idempotent and either has no body or has its
// Request.GetBody defined. HTTP requests are considered idempotent if
// they have HTTP methods GET, HEAD, OPTIONS, or TRACE; or if their
// Header map contains an "Idempotency-Key" or "X-Idempotency-Key"
// entry. If the idempotency key value is an zero-length slice, the
// request is treated as idempotent but the header is not sent on the
// wire.
type Transport struct {
idleMu sync.Mutex
wantIdle bool // user has requested to close all idle conns
......
......@@ -4952,3 +4952,56 @@ func TestTransportCONNECTBidi(t *testing.T) {
}
}
}
func TestTransportRequestReplayable(t *testing.T) {
someBody := ioutil.NopCloser(strings.NewReader(""))
tests := []struct {
name string
req *Request
want bool
}{
{
name: "GET",
req: &Request{Method: "GET"},
want: true,
},
{
name: "GET_http.NoBody",
req: &Request{Method: "GET", Body: NoBody},
want: true,
},
{
name: "GET_body",
req: &Request{Method: "GET", Body: someBody},
want: false,
},
{
name: "POST",
req: &Request{Method: "POST"},
want: false,
},
{
name: "POST_idempotency-key",
req: &Request{Method: "POST", Header: Header{"Idempotency-Key": {"x"}}},
want: true,
},
{
name: "POST_x-idempotency-key",
req: &Request{Method: "POST", Header: Header{"X-Idempotency-Key": {"x"}}},
want: true,
},
{
name: "POST_body",
req: &Request{Method: "POST", Header: Header{"Idempotency-Key": {"x"}}, Body: someBody},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.req.ExportIsReplayable()
if got != tt.want {
t.Errorf("replyable = %v; want %v", got, tt.want)
}
})
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment