Goで本番だけ出るconnection resetの直し方

Goから外部APIを呼ぶと、本番だけまれにread: connection reset by peerが出る。原因はHTTP/2では切られた接続の要求をGoが送り直さないこと。GETだけを再試行し、タイムアウトと接続断を分けた判断と、そのテストの落とし穴を実行結果つきで解説する。

Goで本番だけ出るconnection resetの直し方

本記事はプロモーションを含みます。

はじめに:手元では出ない connection reset by peer が本番だけ出る

再利用した接続を切られたとき、HTTP/1.1 の GET は Go が新しい接続で送り直して成功するが、HTTP/2 ではエラーがそのまま返る

Go で外部 API を呼ぶサーバを動かしていると、エラー監視にまれに次のエラーが上がることがある。

read tcp ...: read: connection reset by peer

筆者の場合は検証環境で 30 日に 1 件で、業務への影響は無かった。ただ、手元のテストでは一度も出ない。調べると、呼んでいた外部 API は HTTP/2 で応答していた。

結論から書くと、原因と直し方は次のとおり。

  • Go の net/http は、再利用した接続を応答前に切られたとき、HTTP/1.1 なら GET を自動で送り直すが、HTTP/2 では送り直さずにエラーを返す
  • アプリ側で、冪等な要求 (GET) だけを、接続を切られた (ECONNRESET) ときに送り直す。Idempotency-Key の無い POST は送り直さない
  • 通信の失敗をまとめて 408 にしない。本当のタイムアウトだけ 408、接続断などは 500 にする

この記事では、どう再現したか、何を再試行の対象にしたか、テストで踏んだ落とし穴を順に書く。コードはすべて Go 1.26.4 で実行した。

なぜ本番だけ出るのか:HTTP/2 では Go が送り直さない

2 回目の要求で接続を切ったときの結果。HTTP/1.1 の GET と Idempotency-Key 付き POST は送り直されて成功し、POST と HTTP/2 の GET は失敗する

手元で再現するには、2 回目の要求 (1 回目と同じ接続を再利用したもの) で、サーバが応答を返さずに TCP を RST で切ればよい。SetLinger(0) してから Close すると、FIN ではなく RST で切れる。

package main

import (
	"context"
	"crypto/tls"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"sync/atomic"
)

type connKey struct{}

// 応答を返さずに TCP を RST で切る
func rst(c net.Conn) {
	if tc, ok := c.(*tls.Conn); ok {
		c = tc.NetConn()
	}
	c.(*net.TCPConn).SetLinger(0)
	c.Close()
}

func run(h2 bool, method string) {
	var calls atomic.Int32
	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")
	}))
	// HTTP/2 は Hijack できないので、接続を ctx に載せてハンドラから触る
	srv.Config.ConnContext = func(ctx context.Context, c net.Conn) context.Context {
		return context.WithValue(ctx, connKey{}, c)
	}
	srv.EnableHTTP2 = h2
	srv.StartTLS()
	defer srv.Close()

	var err error
	for range 2 {
		req, _ := http.NewRequest(method, srv.URL, nil)
		res, e := srv.Client().Do(req)
		if err = e; err == nil {
			io.Copy(io.Discard, res.Body) // 読み切らないと接続が再利用されない
			res.Body.Close()
		}
	}
	fmt.Printf("h2=%-5v %-4s calls=%d err=%v\n", h2, method, calls.Load(), err != nil)
}

func main() {
	run(false, "GET")  // h2=false GET  calls=3 err=false
	run(false, "POST") // h2=false POST calls=2 err=true
	run(true, "GET")   // h2=true  GET  calls=2 err=true
}

POST に Idempotency-Key ヘッダを付けた場合も足して、結果をまとめると次のようになる。

プロトコルメソッドサーバが呼ばれた回数結果
HTTP/1.1GET3成功 (新しい接続で自動で送り直し)
HTTP/1.1POST2connection reset by peer
HTTP/1.1POST + Idempotency-Key3成功
HTTP/2GET2connection reset by peer

HTTP/1.1 のテストサーバで試している限り、GET は Go が裏で送り直すので失敗が表に出ない。相手が HTTP/2 で話す本番だけで出る理由はこれだった。

Go が自動で送り直す条件

Go の HTTP/1 の Transport が送り直すのは、再利用した接続で失敗し、かつ GET などの冪等なメソッドか Idempotency-Key 付きの要求のときだけ

送り直すかどうかは、net/http の transport.go にある shouldRetryRequest が決めている。読むと条件は次の 2 つだ。

  • 失敗したのが再利用した接続であること。新しい接続で切られたのなら、サーバがその要求を嫌っている可能性があり、送り直すと接続と再送を繰り返しかねない、とソースのコメントにある
  • 要求が送り直してよいものであること (Request.isReplayable)。body が無い (または GetBody で巻き戻せる) うえで、メソッドが GET / HEAD / OPTIONS / TRACE か、Idempotency-Key か X-Idempotency-Key ヘッダが付いているとき

Idempotency-Key について、ソースには「標準ではないが、POST などが冪等であることを示すのに広く使われている」とコメントがあり、golang/go#19943 の議論を指している。

HTTP/2 で応答を待っている最中に接続を切られた場合は、上の表のとおり送り直されずにエラーが返った。

何を再試行するか:GET だけにした

失敗した要求を全部送り直す案は、POST が二重に処理されうるので見送り、ECONNRESET の GET だけを最大 3 回送り直す案を選んだ

直し方として、失敗した要求を全部送り直す形も考えられる。しかし POST は、切られる前にサーバ側で処理が終わっていたかどうかをクライアントから知る手段が無い。送り直すと同じ登録が 2 回行われうる。

そこで、Go の Transport が HTTP/1.1 でやっていることをアプリ側でなぞる形にした。

  • 送り直すのは GET だけ
  • 送り直すのは、エラーが ECONNRESET (接続を切られた) のときだけ。タイムアウトや接続拒否は送り直さない
  • 最大 3 回まで。エラー監視への通知は、3 回とも失敗したときだけ出す

Idempotency-Key の無い POST は送り直さない。この「GET はやり直し、POST はやり直さない」をテストで固定した。

再試行のコード

GET を送り、ECONNRESET なら送り直す。3 回失敗したらエラーを返し、そこで初めてエラー監視に通知する

判定は errors.Is(err, syscall.ECONNRESET) で書ける。http.Client.Do のエラーは *url.Error に包まれているが、errors.Is は内側まで辿る。

// GET だけ、接続を切られたときに最大 3 回まで送る
func getWithRetry(ctx context.Context, c *http.Client, url string) (*http.Response, error) {
	var err error
	for range 3 {
		req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
		res, e := c.Do(req)
		if e == nil {
			return res, nil
		}
		err = e
		if !errors.Is(err, syscall.ECONNRESET) {
			return nil, err // タイムアウトや接続拒否は送り直さない
		}
	}
	return nil, err
}

上の再現用サーバ (HTTP/2) に対して 2 回呼ぶと、2 回目の要求で切られた分をこの関数が送り直し、2 回とも 200 HTTP/2.0 で返った。サーバが呼ばれた回数は 3 回だった。

筆者の実装では、既にあった再試行の共通処理にこの条件を足している。待ち時間を空けるか、どのくらい空けるかは呼ぶ API に合わせて決める。

再試行で考えることは、すべてを再試行しないこと以外にも、待ち時間の伸ばし方 (Exponential backoff) や Retry-After ヘッダの扱いがある。『実用 Go言語 第2版』は HTTP クライアントの章に「リトライ時に考慮するべき点」の節があり、RoundTripper でリトライを組む方法まで扱っている。

実用 Go言語 第2版 (渋川よしき 他著、オライリー・ジャパン) 画像: 楽天市場

実用 Go言語 第2版 (渋川よしき 他著、オライリー・ジャパン)

タイムアウトと接続断を同じエラーにしない

接続リセットもタイムアウトも errors.As で net.Error に当たる。Timeout() が true のときだけ 408、それ以外は 500 にする

もう 1 つ直したのが、外部 API の通信失敗を自分の API のステータスに写す箇所だ。直す前は、通信の失敗をすべて 408 (Request Timeout) にしていた。

Go では、接続リセットも net.Error インターフェースを満たす。errors.As(err, &ne) が true になるだけでは、タイムアウトかどうかは分からない。

失敗の種類errors.As(err, &ne)ne.Timeout()
ECONNRESET (接続を切られた)truefalse
http.Client.Timeout による失敗truetrue

errors.As だけで判定すると、接続リセットや接続拒否まで 408 になる。呼び出し側は「待てば通る」と受け取り、同じ再試行を続けてしまう。

func status(err error) int {
	var ne net.Error
	if errors.As(err, &ne) && ne.Timeout() {
		return http.StatusRequestTimeout // 本当のタイムアウトだけ
	}
	return http.StatusInternalServerError
}

手元で確かめると、閉じたポートへの接続 (connect: connection refused) は 500、Client.Timeout を 50 ミリ秒にして遅い応答を待たせた場合 (Client.Timeout exceeded while awaiting headers) は 408 になった。呼び出し元が要求を取り消した場合 (context.Canceled) をどのステータスにするかは、別に決めておく。

再試行をテストするときの落とし穴

再試行の処理をテストで固定するときに、気付きにくい点が 3 つある。

body を読み切らないと、接続が再利用されない

応答の body を読み切らずに Close すると接続は再利用されず、次の要求は新しい接続になる。新しい接続で切られると HTTP/1.1 の GET も送り直されない

最初の再現コードから io.Copy(io.Discard, res.Body) を外すと、HTTP/1.1 の GET も失敗するようになる。

body の扱いサーバが呼ばれた回数2 回目の結果
読み切ってから Close3成功
読まずに Close2失敗

読み切らずに閉じた接続は再利用されないので、2 回目の要求は新しい接続で送られる。Transport は「新しい接続で切られたら送り直さない」ので、GET でも失敗が返る。再現を作るときに body の扱いを変えるだけで、結果が逆になる。

Transport の送り直しが先に効くと、アプリの再試行を確かめられない

再利用した接続で切ると、HTTP/1.1 では Transport が先に送り直してしまう。アプリの再試行を確かめるには、サブテストごとにサーバを立てて新しい接続で切る

前の節の逆で、アプリ側の再試行をテストしたいときは、Transport の自動の送り直しが邪魔になる。HTTP/1.1 のテストサーバで「再利用した接続の上で切る」と、アプリの再試行より先に Transport が送り直して成功してしまい、アプリの処理を通らない。

筆者はサブテストごとにサーバを立て、新しい接続の上で切るようにした。これなら Transport は送り直さないので、アプリの再試行だけが働く。

ハンドラの中で require (t.FailNow) を使わない

テストサーバのハンドラは別の goroutine で動く。そこで t.FailNow を呼んでも止まるのはハンドラの goroutine だけなので、t.Errorf で記録して return する

テストサーバのハンドラの中で、接続の取り出しに失敗したら testify の require.NoError で止めたくなる。しかし go doc testing.T.FailNow には、FailNow はテスト関数を実行している goroutine から呼ばなければならず、テスト中に作られた別の goroutine から呼んではいけない、とある。

httptest.NewServer のハンドラは、サーバ側の goroutine で動く。ここで FailNow を呼んでも止まるのはその goroutine だけで、テスト関数は続く。ハンドラの中では t.Errorf で記録して return する。

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	c, _, err := w.(http.Hijacker).Hijack()
	if err != nil {
		t.Errorf("hijack: %v", err) // require.NoError(t, err) にしない
		return
	}
	c.Close()
}))

全部成功している間はこの違いは表に出ない。失敗したときだけ、テストが止まらずに別の箇所で落ち、原因を追いにくくなる。

まとめ

  • Go の net/http は、再利用した接続を切られたとき、HTTP/1.1 なら GET などを自動で送り直すが、HTTP/2 では送り直さない。HTTP/1.1 のテストサーバでは再現しない
  • アプリ側で、GET だけを errors.Is(err, syscall.ECONNRESET) のときに送り直す。Idempotency-Key の無い POST は送り直さない
  • エラー監視への通知は、再試行を使い切ったときだけにする
  • net.Error に当たるだけではタイムアウトとは限らない。Timeout() が true のときだけ 408 にする
  • 再試行のテストでは、body を読み切るか、どの接続の上で切るか、ハンドラで FailNow を呼ばないか、の 3 点で結果が変わる

本番だけで出るエラーは、手元との違い (プロトコル、接続の再利用) を 1 つずつ揃えると再現できることが多い。今回は「相手が HTTP/2 で話している」の 1 点だった。

参考