結論

  • http.Client は 301 / 302 / 303 の転送に従うと、DELETE・PUT・POST を本文なしの GET にして転送先へ送る。DELETE への 302 が一覧へ転送されると、一覧の GET が 200 を返し、何も消さずに成功として返る。307 / 308 はメソッドと本文を保つ
  • 再利用した接続が応答の前に切られると、HTTP/1.1 の Transport は GET などと Idempotency-Key 付きの要求だけを、新しい接続で送り直す。HTTP/2 では GET でも送り直さず、connection reset by peer を返す
  • 本文を送り直せるのは GetBody があるときだけで、http.NewRequest が GetBody を付けるのは *bytes.Buffer / *bytes.Reader / *strings.Reader だけ。それ以外の io.Reader では、307 は転送されずにそのまま返り、読み切った要求をもう一度 Do すると空の本文がエラーなしに届く
  • HTTP/2 の接続は MaxIdleConnsPerHost の数を超えても閉じられない。取り置き 2 本の設定で、張った 8 本を残して使い回した。MaxConnsPerHost は同時に張りに行く接続を絞るが、HTTP/2 の接続の数の上限にはならない (サーバの同時ストリームが 2 のとき、1 にしても 4-6 本張った)
  • 失敗は *url.Error で返り、Error() にクエリ込みの URL が入る。%w で包んでも、文字列にすれば URL が出る。接続リセットも接続拒否も net.Error を満たすので、タイムアウトかどうかは Timeout() で分ける

以下は go1.26.4 / linux/amd64 で実測した。サーバは net/http/httptest で手元に立て、HTTP/2 は EnableHTTP2 と StartTLS で話させた。各節のコードは main.go 1 つで、go run で動く。

前提: 1 回の Do の中で、要求は作り直され、送り直される

Client は転送のたびに新しい要求を作り、Transport は接続が切れると同じ要求を送り直す。HTTP/2 の相手には別の実装が動き、失敗は *url.Error に包まれて返る

Client.Do は、応答が転送 (3xx) なら、CheckRedirect が許すかぎり Location へ新しい要求を作って送る。1 つ 1 つの要求は Transport が送る。https の相手が ALPN で h2 を選ぶと、その接続の要求は HTTP/2 の実装 (net/http/h2_bundle.go) が送り、HTTP/1.1 の実装 (net/http/transport.go) とは別のコードが動く。HTTP/1.1 の Transport は、再利用した接続が切れたとき、条件を満たす要求を新しい接続で送り直す。失敗は *url.Error に包まれて Do から返る。

以下の節は、Client の転送、Transport の送り直し、送り直しに要る本文 (GetBody)、HTTP/2 の接続の数、返るエラー (net.Error と *url.Error) の順に扱う。

301 / 302 / 303 の転送で、DELETE・PUT・POST は本文なしの GET になる

301 / 302 / 303 は GET か HEAD に変わり、本文が落ちる。307 / 308 はメソッドと本文を保つ。DELETE への 302 は一覧の GET の 200 で返る

/items/1 を ?code= の状態コードで一覧 /items へ転送するサーバを立て、一覧が受けたメソッドと本文の長さをヘッダで返させた。HEAD 以外には 8 バイトの本文 ({"id":1}) を付けて送った。

redirect/main.go (抜粋。HEAD は本文なしで送る)
mux.HandleFunc("/items/1", func(w http.ResponseWriter, r *http.Request) {
	code, _ := strconv.Atoi(r.URL.Query().Get("code"))
	http.Redirect(w, r, "/items", code)
})
mux.HandleFunc("/items", func(w http.ResponseWriter, r *http.Request) {
	b, _ := io.ReadAll(r.Body)
	w.Header().Set("X-Got", fmt.Sprintf("%s body=%dB", r.Method, len(b)))
})
// ...
req, _ := http.NewRequest(method, fmt.Sprintf("%s/items/1?code=%d", srv.URL, code), strings.NewReader(`{"id":1}`))
res, err := http.DefaultClient.Do(req)
$ go run ./redirect
# 既定の Client
301 DELETE => GET body=0B      status=200
301 PUT    => GET body=0B      status=200
301 POST   => GET body=0B      status=200
301 HEAD   => HEAD body=0B     status=200
302 DELETE => GET body=0B      status=200
302 PUT    => GET body=0B      status=200
302 POST   => GET body=0B      status=200
302 HEAD   => HEAD body=0B     status=200
303 DELETE => GET body=0B      status=200
303 PUT    => GET body=0B      status=200
303 POST   => GET body=0B      status=200
303 HEAD   => HEAD body=0B     status=200
307 DELETE => DELETE body=8B   status=200
307 PUT    => PUT body=8B      status=200
307 POST   => POST body=8B     status=200
307 HEAD   => HEAD body=0B     status=200
308 DELETE => DELETE body=8B   status=200
308 PUT    => PUT body=8B      status=200
308 POST   => POST body=8B     status=200
308 HEAD   => HEAD body=0B     status=200

Client.Do のドキュメントは次のとおり書いている。

If permitted, a 301, 302, or 303 redirect causes subsequent requests to use HTTP method GET
(or HEAD if the original request was HEAD), with no body.
A 307 or 308 redirect preserves the original HTTP method and body,
provided that the [Request.GetBody] function is defined.

HTTP の仕様が 301 / 302 でメソッドの書き換えを認めているのは、POST から GET だけ (RFC 9110 15.4.3: “For historical reasons, a user agent MAY change the request method from POST to GET”)。フォームの POST への 302 を、ブラウザが昔から GET で出し直してきた慣習を仕様が追認したもので、メソッドを保ったまま転送したいサーバのために 307 / 308 が後から作られた。Go の Client は、この書き換えを DELETE と PUT にも広げている。転送先を決めるのはサーバの Location ヘッダで、Client はそこへ行き直すだけ。

書き込みに使う Client は転送に従わない。CheckRedirect で http.ErrUseLastResponse を返すと、302 がそのまま返る。

noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
	return http.ErrUseLastResponse
}}
# CheckRedirect で http.ErrUseLastResponse を返す Client
302 DELETE => (転送しない)          status=302
  • PITFALL: 転送先の GET は正常に 200 を返すので、テストでもログでも異常に見えない。2xx を成功とみなすコードでは、DELETE が何も消さずに成功になる
  • PITFALL: 書き換える範囲は HTTP クライアントの実装ごとに違う。「仕様どおりの挙動だから他の言語でも同じ」とは限らない

再利用した接続が切られると、HTTP/1.1 は冪等な要求だけを送り直し、HTTP/2 は送り直さない

再利用した接続を RST で切ると、HTTP/1.1 は GET と Idempotency-Key 付きの POST を送り直して成功し、POST と HTTP/2 の GET はエラーになる

テストサーバは、2 回目の要求 (1 回目と同じ接続を再利用した要求) で、応答を返さずに TCP を RST で切る。SetLinger(0) で Close すると、FIN ではなく RST が送られる。HTTP/2 の接続は Hijack できないので、ConnContext で接続を ctx に載せておく。

resend/main.go (抜粋。本文とヘッダを付ける分岐は省いた)
// 応答を返さずに TCP を RST で切る (SetLinger(0) で Close すると FIN ではなく RST)
func rst(c net.Conn) {
	if tc, ok := c.(*tls.Conn); ok {
		c = tc.NetConn()
	}
	c.(*net.TCPConn).SetLinger(0)
	c.Close()
}

srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	if calls.Add(1) == 2 { // 2 回目 = 再利用した接続の上の要求
		rst(r.Context().Value(connKey{}).(net.Conn))
		return
	}
	io.WriteString(w, "ok")
}))
srv.Config.ConnContext = func(ctx context.Context, c net.Conn) context.Context {
	return context.WithValue(ctx, connKey{}, c) // HTTP/2 は Hijack できないので接続を ctx に載せる
}
srv.EnableHTTP2 = o.h2
srv.StartTLS()

for range 2 {
	req, _ := http.NewRequest(o.method, srv.URL, nil)
	res, e := srv.Client().Do(req)
	if err = e; err == nil {
		io.Copy(io.Discard, res.Body) // 読み切らないと接続が再利用されない
		res.Body.Close()
	}
}

calls はサーバのハンドラが呼ばれた回数。3 なら、Transport が切られた 2 回目を新しい接続で送り直し、それが成功している。

$ go run ./resend
HTTP/1.1 GET                       proto=HTTP/1.1 calls=3 err=<nil>
HTTP/1.1 POST                      proto=HTTP/1.1 calls=2 err=Post "https://127.0.0.1:36697": read tcp 127.0.0.1:40284->127.0.0.1:36697: read: connection reset by peer
HTTP/1.1 POST + Idempotency-Key    proto=HTTP/1.1 calls=3 err=<nil>
HTTP/1.1 POST + Idempotency-Key + 本文 proto=HTTP/1.1 calls=3 err=<nil>
HTTP/1.1 POST + Idempotency-Key + 本文(io.Reader) proto=HTTP/1.1 calls=2 err=Post "https://127.0.0.1:33239": write tcp 127.0.0.1:35862->127.0.0.1:33239: write: connection reset by peer
HTTP/2 GET                         proto=HTTP/2.0 calls=2 err=Get "https://127.0.0.1:45489": read tcp 127.0.0.1:59756->127.0.0.1:45489: read: connection reset by peer
HTTP/1.1 GET (1 回目の本文を読み切らない)      proto=HTTP/1.1 calls=2 err=Get "https://127.0.0.1:46749": read tcp 127.0.0.1:33944->127.0.0.1:46749: read: connection reset by peer

本文(io.Reader) の行は、要求の書き込み (write) の段階で失敗している。文言は実行ごとに write: connection reset by peer と use of closed network connection のどちらかになった。

送り直すかは transport.go の shouldRetryRequest が決める。新しい接続で切られた場合は送り直さない。ソースのコメントは、サーバが要求を嫌って切っているだけなら、送り直すと接続を作っては切られるのを繰り返すと理由を書いている (we could loop forever creating new connections and retrying if the server is just hanging up on us because it doesn't like our request)。再利用した接続で、要求を送り出した後に切られた場合は、Request.isReplayable が true の要求だけを送り直す。

net/http/request.go
func (r *Request) isReplayable() bool {
	if r.Body == nil || r.Body == NoBody || r.GetBody != nil {
		switch valueOrDefault(r.Method, "GET") {
		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
}

本文があるときは GetBody も要る。Idempotency-Key を付けても、本文を io.MultiReader で渡すと送り直されない (上の 本文(io.Reader) の行。次の節)。

HTTP/2 では、応答を待っている間に接続を RST されると、GET でも送り直さずにエラーを返した。HTTP/1.1 のテストサーバでは再現しないのに、HTTP/2 を話す本番の相手でだけ、まれに read: connection reset by peer が出る形になる。冪等な要求だけ、errors.Is(err, syscall.ECONNRESET) のときにアプリ側で送り直す。

  • PITFALL: 応答の本文を読み切らずに Close すると、その接続は再利用されない。次の要求は新しい接続になり、HTTP/1.1 の GET でも送り直されない (上の最後の行)
  • PITFALL: アプリ側の再試行をテストするときは、逆に新しい接続で切る。再利用した接続で切ると、Transport の送り直しが先に効いて、アプリの再試行を確かめられない。サブテストごとにサーバを立てる

本文を送り直せるのは GetBody があるときだけ

GetBody が付くのは strings.Reader / bytes.Reader / bytes.Buffer だけ。無いと 307 は転送されずに返り、読み切った要求の 2 回目は空の本文で届く

http.NewRequestWithContext (http.NewRequest) のドキュメントは次のとおり書いている。

If body is of type [*bytes.Buffer], [*bytes.Reader], or
[*strings.Reader], the returned request's ContentLength is set to its
exact value (instead of -1), GetBody is populated (so 307 and 308
redirects can replay the body), and Body is set to [NoBody] if the
ContentLength is 0.

同じ 8 バイトでも、io.MultiReader で包むだけで GetBody が付かず、ContentLength も 0 (長さ不明) になる。

$ go run ./getbody
# 本文の型ごとの GetBody と ContentLength
*strings.Reader  GetBody=true  ContentLength=8
*bytes.Reader    GetBody=true  ContentLength=8
*bytes.Buffer    GetBody=true  ContentLength=8
io.MultiReader   GetBody=false ContentLength=0

GetBody が無いと、307 / 308 の転送は起きない。Client はエラーを返さず、307 の応答をそのまま返す (client.go のコメント: So just return this response to the user instead of an error)。

# POST /old (307 で /new へ転送)
*strings.Reader  status=200 url=/new got=POST body="{\"id\":1}"
io.MultiReader   status=307 url=/old got=

アプリ側の再試行で、同じ *http.Request をもう一度 Do した。GetConn は、Transport が接続を取りに行った回数 (net/http/httptrace で数えた)。

# 同じ *http.Request をもう一度 Do する (アプリ側の再試行)
*strings.Reader  そのまま                           GetConn=2 2 回目: POST body="{\"id\":1}"
*strings.Reader  GetBody を nil にする              GetConn=1 Post "http://127.0.0.1:40703/new": http: ContentLength=8 with Body length 0
*strings.Reader  Body を GetBody() で差し替える        GetConn=1 2 回目: POST body="{\"id\":1}"
io.MultiReader   そのまま                           GetConn=1 2 回目: POST body=""

*strings.Reader をそのまま使うと、1 回目で読み切った本文は長さが ContentLength (8) と合わず、何も書き出す前に失敗する。Transport は、使い回した接続でのこの失敗を、GetBody で本文を作り直して送り直す (GetConn=2)。transport.go の shouldRetryRequest に、その分岐がある。

net/http/transport.go
	if _, ok := err.(nothingWrittenError); ok {
		// 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
	}

GetBody を nil にすると、その失敗 http: ContentLength=8 with Body length 0 が返る。io.MultiReader は ContentLength が 0 (不明) なので食い違いが起きず、2 回目は空の本文がエラーなしに届いた。

送り直す前に req.Body, _ = req.GetBody() で本文を差し替え、GetBody が nil なら送り直さずにエラーにする。本文は bytes.NewReader などで渡し、GetBody が付く型にしておく。

  • PITFALL: io.MultiReader などの汎用の io.Reader を渡すと、1 回目で本文が読み切られ、再試行は空の本文をエラーなしに送る

HTTP/2 の接続は MaxIdleConnsPerHost で閉じられず、MaxConnsPerHost を超えて増える

取り置き 2 本の設定で、HTTP/1.1 は 24 要求で 20 回握手し、HTTP/2 は 8 本を残して使い回す。MaxConnsPerHost=1 でも HTTP/2 は 4 本以上張る

HTTP/2 の実装 (h2_bundle.go) は、HTTP/1.1 の Transport のフィールドを一部だけ引き継ぐ。接続の数の 2 つのフィールドは、そのファイルに一度も出てこない。MaxConnsPerHost を読むのは transport.go の接続を張る処理だけ。

$ cd "$(go env GOROOT)/src/net/http"
$ grep -c 'MaxIdleConnsPerHost\|MaxConnsPerHost' h2_bundle.go
0
$ grep -o 't\.t1\.[A-Za-z]*' h2_bundle.go | sort -u
t.t1.DisableCompression
t.t1.DisableKeepAlives
t.t1.ExpectContinueTimeout
t.t1.IdleConnTimeout
t.t1.MaxResponseHeaderBytes
t.t1.ResponseHeaderTimeout
$ grep -n 't\.MaxConnsPerHost' transport.go
351:		MaxConnsPerHost:        t.MaxConnsPerHost,
1600:	if t.MaxConnsPerHost <= 0 {
1605:	if n := t.connsPerHost[w.key]; n < t.MaxConnsPerHost {
1664:	if t.MaxConnsPerHost <= 0 {

接続の数を数えた。サーバは 1 要求に 20ms かけて応答し、ConnState が StateNew になった回数を握手 (TCP + TLS) の回数とした。同時 の数だけ goroutine で同時に送り、全部の応答を待つまでを 1 波とした。idle は MaxIdleConnsPerHost、conns は MaxConnsPerHost (0 は無制限)、streams はサーバの HTTP2Config.MaxConcurrentStreams (0 は既定)。warm=true は、波の前に 1 要求だけ送り、接続を 1 本作っておく。

h2conns/main.go (抜粋)
srv.Config.ConnState = func(_ net.Conn, s http.ConnState) {
	if s == http.StateNew {
		conns.Add(1) // TCP + TLS の握手を 1 回した
	}
}
if c.serverStreams > 0 {
	srv.Config.HTTP2 = &http.HTTP2Config{MaxConcurrentStreams: c.serverStreams}
}
srv.EnableHTTP2 = c.h2
srv.StartTLS()

tr := srv.Client().Transport.(*http.Transport).Clone()
tr.MaxIdleConnsPerHost = c.maxIdle
tr.MaxConnsPerHost = c.maxConns
client := &http.Client{Transport: tr}
$ go run ./h2conns
# MaxIdleConnsPerHost=2 (idle=2)
HTTP/1.1 idle=2 conns=0 streams=0   warm=false 同時=8   x 3波 => 握手 計20  波ごと[8 6 6]
HTTP/2.0 idle=2 conns=0 streams=0   warm=false 同時=8   x 3波 => 握手 計8   波ごと[8 0 0]
HTTP/2.0 idle=2 conns=0 streams=0   warm=true  同時=160 x 3波 => 握手 計1   波ごと[0 0 0]
# MaxConnsPerHost=1 (conns=1)。HTTP/2 はサーバの同時ストリームを 2 にして接続を増やさせる
HTTP/1.1 idle=1 conns=1 streams=0   warm=false 同時=8   x 3波 => 握手 計1   波ごと[1 0 0]
HTTP/2.0 idle=1 conns=1 streams=2   warm=true  同時=8   x 3波 => 握手 計4   波ごと[3 0 0]
HTTP/2.0 idle=1 conns=0 streams=2   warm=true  同時=8   x 3波 => 握手 計11  波ごと[10 0 0]
# 接続が 1 本も無いところへ同時に送る (warm=false)
HTTP/2.0 idle=2 conns=0 streams=0   warm=false 同時=160 x 1波 => 握手 計160 波ごと[160]
HTTP/2.0 idle=2 conns=1 streams=0   warm=false 同時=160 x 1波 => 握手 計1   波ごと[1]
  • HTTP/1.1 は取り置きが 2 本なので、波のたびに 6 本を閉じて張り直し、24 要求で 20 回握手した
  • HTTP/2 は、最初の波で張った 8 本を取り置きの 2 本に関係なく残し、2 波目からは握手しなかった。接続が 1 本あれば、同時 160 でもその 1 本の上で流した
  • MaxConnsPerHost=1 は、HTTP/1.1 では接続を 1 本に絞る。HTTP/2 では、サーバの同時ストリームが 2 だと同時 8 要求に 4 本張り (3 回の実行で 4-6 本)、上限にならなかった。外すと 11 本になるので、張りに行く数は変わる
  • 接続が 1 本も無いところへ同時に 160 要求を送ると、HTTP/2 でも 148-160 回握手した (3 回の実行)。相手が HTTP/2 かは TLS の握手 (ALPN) が済むまで分からないので、どの要求も接続を張りに行く。MaxConnsPerHost=1 なら 1-2 回になった

HTTP/2 は 1 本の TCP 接続の上で要求を多重化するので、同時に流せる数は接続の本数ではなく、ストリームの数 (SETTINGS_MAX_CONCURRENT_STREAMS) で決まる。「並列にしたのに速くならない」「接続プールが枯れているのでは」と疑ったら、まず相手が HTTP/2 かを res.Proto か openssl s_client -alpn h2 -connect host:443 で確かめる。HTTP/2 なら、MaxIdleConnsPerHost をいくら変えても接続の数は変わらない。

  • PITFALL: HTTP/1.1 では設定どおりに効くので、手元の HTTP/1.1 のサーバで対照実験すると「効いている」と見誤る。設定が意味を持つかは、コードではなく接続先が決める

接続リセットも接続拒否も net.Error を満たす

接続リセット・接続拒否・Client.Timeout はどれも net.Error を満たす。Timeout() が true なのは Client.Timeout だけ

通信の失敗を、HTTP の状態コードへ写す関数を 2 つ用意した。

neterror/main.go (抜粋)
// 誤り: net.Error を満たせばタイムアウトとみなす
func statusWrong(err error) int {
	var ne net.Error
	if errors.As(err, &ne) {
		return http.StatusRequestTimeout
	}
	return http.StatusInternalServerError
}

// 正: Timeout() が true のときだけタイムアウトとみなす
func statusRight(err error) int {
	var ne net.Error
	if errors.As(err, &ne) && ne.Timeout() {
		return http.StatusRequestTimeout
	}
	return http.StatusInternalServerError
}

接続リセット (要求を読んだ後に RST で切るサーバ)、接続拒否 (閉じたポート)、Client.Timeout (100ms の期限で、応答に 1 秒かかるサーバへ送る) の 3 つで比べた。

$ go run ./neterror
接続リセット         net.Error=true  Timeout()=false ECONNRESET=true  wrong=408 right=500
               Get "http://127.0.0.1:33749": read tcp 127.0.0.1:35392->127.0.0.1:33749: read: connection reset by peer
接続拒否           net.Error=true  Timeout()=false ECONNRESET=false wrong=408 right=500
               Get "http://127.0.0.1:35831": dial tcp 127.0.0.1:35831: connect: connection refused
Client.Timeout net.Error=true  Timeout()=true  ECONNRESET=false wrong=408 right=408
               Get "http://127.0.0.1:46471": context deadline exceeded (Client.Timeout exceeded while awaiting headers)

net.Error は *net.OpError など、ネットワーク層のエラーの全般が実装するインターフェースで、タイムアウトかどうかは Timeout() で区別する設計になっている。接続リセットだけを取り出すなら errors.Is(err, syscall.ECONNRESET) を使う。

  • PITFALL: errors.As だけで判定すると、接続リセットや接続拒否まで 408 になる。呼び出し側は「待てば通る」と読み、同じ再試行を続ける。呼び出し元の取り消し (context.Canceled) をどの状態コードに写すかも、別に決めておく

失敗の *url.Error は、クエリ込みの URL を Error() に持つ

Do の失敗は *url.Error に包まれ、Error() にクエリ込みの URL が入る。%w で包んでも、ログの error 属性に token が出る

Client.Do は、接続の失敗やタイムアウトを *url.Error に包んで返す。その Error() は <Op> "<URL>": <原因> の形で、URL はクエリまで含む。*url.Error は「どの要求が失敗したか」を伝える型なので、URL を識別の情報として持っている。伏せるのは呼び出し側の役目になる。

閉じたポートへ、クエリに token を載せた要求を送り、失敗を %w で包んで slog の JSON ログに出した。

urlerror/main.go (抜粋)
func fetch(client doer, rawURL string, hideQuery bool) error {
	req, _ := http.NewRequest("GET", rawURL, nil)
	res, err := client.Do(req)
	if err != nil {
		var uerr *url.Error
		if hideQuery && errors.As(err, &uerr) {
			uerr.URL = req.URL.Path // クエリを落としパスだけ残す
		}
		return fmt.Errorf("fetch status: %w", err)
	}
	res.Body.Close()
	return nil
}
// ...
err := fetch(http.DefaultClient, "http://"+addr+"/v1/status?token=secret-123", false)
log.Error("request failed", "path", "/v1/status", "error", err)
$ go run ./urlerror
# そのまま %w で包む
{"level":"ERROR","msg":"request failed","path":"/v1/status","error":"fetch status: Get \"http://127.0.0.1:38211/v1/status?token=secret-123\": dial tcp 127.0.0.1:38211: connect: connection refused"}
# url.Error の URL をパスに差し替えてから %w で包む
{"level":"ERROR","msg":"request failed","path":"/v1/status","error":"fetch status: Get \"/v1/status\": dial tcp 127.0.0.1:38211: connect: connection refused"}
errors.Is(err, syscall.ECONNREFUSED) = true
errors.As(err, &uerr) = true

path の属性をパスだけにしても、隣の error の属性にクエリ込みの URL が出る。エラーを作る所で uerr.URL を差し替えると消える。*url.Error は Unwrap を持つので、差し替えた後も errors.Is / errors.As の連鎖は壊れない。ログを出す条件 (障害のとき) と漏れる条件が同じなので、差し替えないかぎり必ず表に出る。

テストの fake は、差し込む層によって結果が変わる。

# fake の doer (Client ごと差し替え) が errors.New を返すとき
fetch status: connection refused
token を含む: false
# fake の Transport (RoundTripper) が errors.New を返すとき
fetch status: Get "http://127.0.0.1:38211/v1/status?token=secret-123": connection refused
token を含む: true
  • PITFALL: Client ごと差し替える fake が errors.New を返すと、エラーに URL が入らず、漏れのあるコードでもテストが通る。Transport の fake が返したエラーは Client.Do が *url.Error で包むので、URL が入る。漏れを確かめるなら、fake を Transport の層に差し込む
  • PITFALL: 上位の層で、自前のメッセージの部分だけをパスまでにしても意味がない。同じ行の %w が、下の層の URL をそのまま通す

まとめ

場面起きること対策
301 / 302 / 303 の転送DELETE・PUT・POST が本文なしの GET になり、転送先の 200 で返る書き込みに使う Client は CheckRedirect で http.ErrUseLastResponse を返す
307 / 308 の転送メソッドと本文を保つ。GetBody が無ければ転送せずに 307 / 308 を返す本文は bytes.NewReader などで渡す
再利用した接続の切断 (HTTP/1.1)GET などと Idempotency-Key 付きだけを送り直す。本文を読み切らないと接続が再利用されず、送り直さない応答の本文は読み切ってから閉じる
再利用した接続の切断 (HTTP/2)GET でも送り直さず connection reset by peer を返す冪等な要求だけ、errors.Is(err, syscall.ECONNRESET) でアプリ側が送り直す
アプリ側の再試行汎用の io.Reader は 2 回目に空の本文がエラーなしに届く送る前に req.Body, _ = req.GetBody()。GetBody が nil なら送り直さない
HTTP/2 の接続の数MaxIdleConnsPerHost より多く残り、MaxConnsPerHost を超えて増えるres.Proto で相手のプロトコルを見る。同時に流せる数はストリームの数で決まる
net.Error の判定接続リセットも接続拒否も net.Error を満たすTimeout() が true のときだけタイムアウトにする
*url.ErrorError() にクエリ込みの URL が入る包む前に uerr.URL をパスに差し替える。fake は Transport の層に差し込む

参考