Skip to content

Commit bfd9b94

Browse files
committed
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 <[email protected]> TryBot-Result: Gobot Gobot <[email protected]> Reviewed-by: Dmitri Shuralyov <[email protected]>
1 parent 0c7762c commit bfd9b94

File tree

7 files changed

+111
-2
lines changed

7 files changed

+111
-2
lines changed

src/net/http/export_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,3 +242,5 @@ func ExportSetH2GoawayTimeout(d time.Duration) (restore func()) {
242242
http2goAwayTimeout = d
243243
return func() { http2goAwayTimeout = old }
244244
}
245+
246+
func (r *Request) ExportIsReplayable() bool { return r.isReplayable() }

src/net/http/header.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ func (h Header) get(key string) string {
5252
return ""
5353
}
5454

55+
// has reports whether h has the provided key defined, even if it's
56+
// set to 0-length slice.
57+
func (h Header) has(key string) bool {
58+
_, ok := h[key]
59+
return ok
60+
}
61+
5562
// Del deletes the values associated with key.
5663
// The key is case insensitive; it is canonicalized by
5764
// textproto.CanonicalMIMEHeaderKey.

src/net/http/request.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ func (r *Request) write(w io.Writer, usingProxy bool, extraHeaders Header, waitF
579579
// Use the defaultUserAgent unless the Header contains one, which
580580
// may be blank to not send the header.
581581
userAgent := defaultUserAgent
582-
if _, ok := r.Header["User-Agent"]; ok {
582+
if r.Header.has("User-Agent") {
583583
userAgent = r.Header.Get("User-Agent")
584584
}
585585
if userAgent != "" {
@@ -1345,6 +1345,12 @@ func (r *Request) isReplayable() bool {
13451345
case "GET", "HEAD", "OPTIONS", "TRACE":
13461346
return true
13471347
}
1348+
// The Idempotency-Key, while non-standard, is widely used to
1349+
// mean a POST or other request is idempotent. See
1350+
// https://golang.org/issue/19943#issuecomment-421092421
1351+
if r.Header.has("Idempotency-Key") || r.Header.has("X-Idempotency-Key") {
1352+
return true
1353+
}
13481354
}
13491355
return false
13501356
}

src/net/http/requestwrite_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,38 @@ var reqWriteTests = []reqWriteTest{
544544
"User-Agent: Go-http-client/1.1\r\n" +
545545
"\r\n",
546546
},
547+
548+
// Verify that a nil header value doesn't get written.
549+
23: {
550+
Req: Request{
551+
Method: "GET",
552+
URL: mustParseURL("/foo"),
553+
Header: Header{
554+
"X-Foo": []string{"X-Bar"},
555+
"X-Idempotency-Key": nil,
556+
},
557+
},
558+
559+
WantWrite: "GET /foo HTTP/1.1\r\n" +
560+
"Host: \r\n" +
561+
"User-Agent: Go-http-client/1.1\r\n" +
562+
"X-Foo: X-Bar\r\n\r\n",
563+
},
564+
24: {
565+
Req: Request{
566+
Method: "GET",
567+
URL: mustParseURL("/foo"),
568+
Header: Header{
569+
"X-Foo": []string{"X-Bar"},
570+
"X-Idempotency-Key": []string{},
571+
},
572+
},
573+
574+
WantWrite: "GET /foo HTTP/1.1\r\n" +
575+
"Host: \r\n" +
576+
"User-Agent: Go-http-client/1.1\r\n" +
577+
"X-Foo: X-Bar\r\n\r\n",
578+
},
547579
}
548580

549581
func TestRequestWrite(t *testing.T) {

src/net/http/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1390,7 +1390,7 @@ func (cw *chunkWriter) writeHeader(p []byte) {
13901390
}
13911391
}
13921392

1393-
if _, ok := header["Date"]; !ok {
1393+
if !header.has("Date") {
13941394
setHeader.date = appendTime(cw.res.dateBuf[:0], time.Now())
13951395
}
13961396

src/net/http/transport.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,15 @@ func init() {
9191
// considered a terminal status and returned by RoundTrip. To see the
9292
// ignored 1xx responses, use the httptrace trace package's
9393
// ClientTrace.Got1xxResponse.
94+
//
95+
// Transport only retries a request upon encountering a network error
96+
// if the request is idempotent and either has no body or has its
97+
// Request.GetBody defined. HTTP requests are considered idempotent if
98+
// they have HTTP methods GET, HEAD, OPTIONS, or TRACE; or if their
99+
// Header map contains an "Idempotency-Key" or "X-Idempotency-Key"
100+
// entry. If the idempotency key value is an zero-length slice, the
101+
// request is treated as idempotent but the header is not sent on the
102+
// wire.
94103
type Transport struct {
95104
idleMu sync.Mutex
96105
wantIdle bool // user has requested to close all idle conns

src/net/http/transport_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4952,3 +4952,56 @@ func TestTransportCONNECTBidi(t *testing.T) {
49524952
}
49534953
}
49544954
}
4955+
4956+
func TestTransportRequestReplayable(t *testing.T) {
4957+
someBody := ioutil.NopCloser(strings.NewReader(""))
4958+
tests := []struct {
4959+
name string
4960+
req *Request
4961+
want bool
4962+
}{
4963+
{
4964+
name: "GET",
4965+
req: &Request{Method: "GET"},
4966+
want: true,
4967+
},
4968+
{
4969+
name: "GET_http.NoBody",
4970+
req: &Request{Method: "GET", Body: NoBody},
4971+
want: true,
4972+
},
4973+
{
4974+
name: "GET_body",
4975+
req: &Request{Method: "GET", Body: someBody},
4976+
want: false,
4977+
},
4978+
{
4979+
name: "POST",
4980+
req: &Request{Method: "POST"},
4981+
want: false,
4982+
},
4983+
{
4984+
name: "POST_idempotency-key",
4985+
req: &Request{Method: "POST", Header: Header{"Idempotency-Key": {"x"}}},
4986+
want: true,
4987+
},
4988+
{
4989+
name: "POST_x-idempotency-key",
4990+
req: &Request{Method: "POST", Header: Header{"X-Idempotency-Key": {"x"}}},
4991+
want: true,
4992+
},
4993+
{
4994+
name: "POST_body",
4995+
req: &Request{Method: "POST", Header: Header{"Idempotency-Key": {"x"}}, Body: someBody},
4996+
want: false,
4997+
},
4998+
}
4999+
for _, tt := range tests {
5000+
t.Run(tt.name, func(t *testing.T) {
5001+
got := tt.req.ExportIsReplayable()
5002+
if got != tt.want {
5003+
t.Errorf("replyable = %v; want %v", got, tt.want)
5004+
}
5005+
})
5006+
}
5007+
}

0 commit comments

Comments
 (0)