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

本記事はプロモーションを含みます。
Go で外部 API を呼ぶサーバを動かしていると、エラー監視にまれに次のエラーが上がることがある。
read tcp ...: read: connection reset by peer
筆者の場合は検証環境で 30 日に 1 件で、業務への影響は無かった。ただ、手元のテストでは一度も出ない。調べると、呼んでいた外部 API は HTTP/2 で応答していた。
結論から書くと、原因と直し方は次のとおり。
net/http は、再利用した接続を応答前に切られたとき、HTTP/1.1 なら GET を自動で送り直すが、HTTP/2 では送り直さずにエラーを返すECONNRESET) ときに送り直す。Idempotency-Key の無い POST は送り直さないこの記事では、どう再現したか、何を再試行の対象にしたか、テストで踏んだ落とし穴を順に書く。コードはすべて Go 1.26.4 で実行した。
手元で再現するには、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.1 | GET | 3 | 成功 (新しい接続で自動で送り直し) |
| HTTP/1.1 | POST | 2 | connection reset by peer |
| HTTP/1.1 | POST + Idempotency-Key | 3 | 成功 |
| HTTP/2 | GET | 2 | connection reset by peer |
HTTP/1.1 のテストサーバで試している限り、GET は Go が裏で送り直すので失敗が表に出ない。相手が HTTP/2 で話す本番だけで出る理由はこれだった。
送り直すかどうかは、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 で応答を待っている最中に接続を切られた場合は、上の表のとおり送り直されずにエラーが返った。
直し方として、失敗した要求を全部送り直す形も考えられる。しかし POST は、切られる前にサーバ側で処理が終わっていたかどうかをクライアントから知る手段が無い。送り直すと同じ登録が 2 回行われうる。
そこで、Go の Transport が HTTP/1.1 でやっていることをアプリ側でなぞる形にした。
ECONNRESET (接続を切られた) のときだけ。タイムアウトや接続拒否は送り直さないIdempotency-Key の無い POST は送り直さない。この「GET はやり直し、POST はやり直さない」をテストで固定した。
判定は 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版 (渋川よしき 他著、オライリー・ジャパン)
もう 1 つ直したのが、外部 API の通信失敗を自分の API のステータスに写す箇所だ。直す前は、通信の失敗をすべて 408 (Request Timeout) にしていた。
Go では、接続リセットも net.Error インターフェースを満たす。errors.As(err, &ne) が true になるだけでは、タイムアウトかどうかは分からない。
| 失敗の種類 | errors.As(err, &ne) | ne.Timeout() |
|---|---|---|
ECONNRESET (接続を切られた) | true | false |
http.Client.Timeout による失敗 | true | true |
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 つある。
最初の再現コードから io.Copy(io.Discard, res.Body) を外すと、HTTP/1.1 の GET も失敗するようになる。
| body の扱い | サーバが呼ばれた回数 | 2 回目の結果 |
|---|---|---|
読み切ってから Close | 3 | 成功 |
読まずに Close | 2 | 失敗 |
読み切らずに閉じた接続は再利用されないので、2 回目の要求は新しい接続で送られる。Transport は「新しい接続で切られたら送り直さない」ので、GET でも失敗が返る。再現を作るときに body の扱いを変えるだけで、結果が逆になる。
前の節の逆で、アプリ側の再試行をテストしたいときは、Transport の自動の送り直しが邪魔になる。HTTP/1.1 のテストサーバで「再利用した接続の上で切る」と、アプリの再試行より先に Transport が送り直して成功してしまい、アプリの処理を通らない。
筆者はサブテストごとにサーバを立て、新しい接続の上で切るようにした。これなら Transport は送り直さないので、アプリの再試行だけが働く。
テストサーバのハンドラの中で、接続の取り出しに失敗したら 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()
}))
全部成功している間はこの違いは表に出ない。失敗したときだけ、テストが止まらずに別の箇所で落ち、原因を追いにくくなる。
net/http は、再利用した接続を切られたとき、HTTP/1.1 なら GET などを自動で送り直すが、HTTP/2 では送り直さない。HTTP/1.1 のテストサーバでは再現しないerrors.Is(err, syscall.ECONNRESET) のときに送り直す。Idempotency-Key の無い POST は送り直さないnet.Error に当たるだけではタイムアウトとは限らない。Timeout() が true のときだけ 408 にするFailNow を呼ばないか、の 3 点で結果が変わる本番だけで出るエラーは、手元との違い (プロトコル、接続の再利用) を 1 つずつ揃えると再現できることが多い。今回は「相手が HTTP/2 で話している」の 1 点だった。
Idempotency-Key で POST を送り直す扱いの議論)