フェーズ: 上級者 (Phase 3) | 問題タイプ: 実装 | テーマ: ミドルウェア設計パターン
Day 068 の次回予告どおり「実装」。Day 068 で比べた 3 流儀(デコレータ・添字カーソル・Koa 型)の外に残した 4 つ目、「失敗を error で返すハンドラ」(Echofunc(c) error、Caddy v2ServeHTTP(w, r, next) error)を、標準のhttp.Handlerの世界へつなぐ部品Adaptを書く。
📚 背景知識(読んでから問題へ)
http.Handler の ServeHTTP には戻り値がありません(Day 068 の D1)。そのため素の net/http では、失敗した場所でそれぞれ http.Error(w, …, 500) を書くことになり、次の 3 つが散らばります。
1. どの失敗を何番にするか(見つからない→404、期限切れ→503、想定外→500) 2. 本文に何を出すか(DB のエラー文をそのまま出すと内部情報が漏れる。CWE-209。Day 064 B5) 3. 失敗をどこで 1 回だけ記録するか
Echo と Caddy は、ハンドラに error を返させ、1 か所(エラーハンドラ)でまとめて応答に直すことでこれを解きました。
ただし「まとめて直す」には落とし穴が 1 つあります。ハンドラが本文を書き始めた後に error を返した場合です。HTTP のステータス行は本文より先に送られるので、もう 500 には変えられません。世界の実物はここで扱いが割れています(2026-10-09 に GitHub の master を取得して確認)。
| 実物 | 送信後に error が来たとき | 一次資料 |
|---|---|---|
Echo v5(github.com/labstack/echo/v5、master) | DefaultHTTPErrorHandler の先頭で if r, _ := UnwrapResponse(c.response); r != nil && r.Committed { return }。何もしない | echo.go 463〜466 行 |
| Echo の状態判定 | ResolveResponseStatus: 「once committed (sent to the client), the wire status is fixed; err cannot change it.」 | response.go 71〜81 行 |
| Caddy v2(master) | エラー経路が無ければ w.WriteHeader(errStatus)。送信済みかは見ない(net/http が "superfluous response.WriteHeader call" を出して無視する) | modules/caddyhttp/server.go 737・790 行 |
| Caddy の番号の決め方 | errLogValues: errors.AsType[HandlerError](err) なら StatusCode、それ以外は 500 | modules/caddyhttp/logging.go 206〜231 行 |
| net/http(Go 1.27.1 手元) | ErrAbortHandler で panic すると「aborts the response to the client」かつスタックを出さない | server.go 1905〜1909 行 |
どちらの実物も「送信後は番号を変えられない」までは同じで、クライアントに何が届くかは手を打っていません。今日の「実測」で、これが chunked 応答だと途中で切れた本文が完全な 200 として届くことを確かめます(Day 064 B3 と同じ現象)。
迷った他案と採らなかった理由(今日の Adapt の作り方)
| 論点 | 採った案 | 迷った他案と採らなかった理由 | 前提が変わったら |
|---|---|---|---|
| ステータスの持たせ方 | *StatusError{Code, Msg, Err} を errors.As で取り出す+番兵 ErrNotFound を errors.Is | **インターフェース StatusCode() int**(Echo の HTTPStatusCoder): 柔軟だが、今日の規模では型 1 つで足りる。**型アサーション err.(*StatusError)**: fmt.Errorf("…: %w") で包んだ瞬間に外れる(M2 で実測) | 外部パッケージの error(gRPC の status 等)も番号に直したいなら、インターフェースにする |
| 期限切れの番号 | 503(http.TimeoutHandler と同じ。Day 061) | 504(chi middleware.Timeout が使う): 504 は「上流のゲートウェイが待ちきれなかった」意味で、自分自身の予算切れには 503 の方が近いと判断 | 前段に LB があり、LB の 504 と区別したい運用なら 504 でもよい(どちらでも、テスト A6 の期待値を 1 か所変えるだけ) |
context.Canceled | **相手が切った(r.Context().Err() != nil)ときだけ 499 扱いで何も書かない**。中で cancel しただけなら 500 | 常に 499(M5): errgroup の兄弟の失敗などで中が cancel した場合まで「クライアントのせい」として集計から消える | — |
| 本文の文言 | 4xx の Msg は出す。5xx は常に固定文言 | Msg をそのまま出す(M12): 502 の Msg に上流の IP を入れる人がいる(A5 の例) | 社内専用 API で詳細を返したいなら、Echo の exposeError のような明示スイッチにする |
| 送信後の error(2xx・3xx を送った後) | **panic(http.ErrAbortHandler) で接続を切る** | Echo 型の「何もしない」(M6)・**Caddy 型の WriteHeader(500)(M9): どちらも chunked だと完全な 200** が届く(実測)。行の途中で切れた CSV や JSON を、クライアントは成功として保存する | 本文が Content-Length 付きで送られる設計に統一しているなら、宣言より短い本文で net/http が切るので(実測)、どれでも結果は同じ |
| 送信後の error(4xx・5xx を送った後) | 何もしない(ログだけ) | 常に切る(M7): Echo の RequestLogger(HandleError: true)のように中の中間件が先に失敗の応答を書き、それでも error を上へ返す形を壊す(A9) | 「先に書いた」ことを番号でなく印(Handled 型)で示す方式も考えた。印は全員が守らないと効かないので、番号で判断した |
| 「送信済み」の線引き | 101 以外の 1xx は送信済みにしない | 何か WriteHeader したら送信済み(M13): 103 Early Hints の後の 404 が書けなくなる(A13)。net/http 自体の線引き(server.go 1227 行 code >= 100 && code <= 199 && code != StatusSwitchingProtocols)に合わせた | — |
| w を包むこと | 包む(送信済みを知る方法が他に無い)。**Unwrap() を必ず付ける** | 包まない: 送信済みが分からない。Unwrap なし(M1): http.NewResponseController(w).Flush() が ErrNotSupported になり SSE が壊れる(A11) | — |
世界の実物との位置づけ(数えた範囲)
Echo の DefaultHTTPErrorHandler が扱う論点は、①送信済みなら何もしない ②HTTPStatusCoder から番号 ③json.Marshaler ならそのまま本文 ④*HTTPError の Message ⑤exposeError で詳細を出すか ⑥HEAD なら本文なし ⑦応答の書き込み失敗のログ、の 7 つ。今日の Adapt はこのうち ①(ただし扱いを変えた)②④⑤(5xx は出さない固定方針として)⑥(http.Error と net/http に任せる。テストはしていない)の 5 つに触れ、③(JSON 本文)と ⑦は扱っていない。代わりに Echo に無い 3 つ(送信後の切断・Canceled の発生源・1xx の線引き)を足した。
📝 問題
課題
adapt.go の 3 つの関数 StatusOf・publicMsg・Adapt を実装して、go test -race -count=1 ./... の 13 本をすべて通してください。約束は次の表です(括弧はそれを見張るテスト)。
| # | 約束 |
|---|---|
| R1 | error が nil なら、ハンドラが書いたものに触らない。ログも出さない(A1) |
| R2 | 包まれた *StatusError から番号と公開文言を取る。原因 Err はログにだけ出す。*StatusError は番兵より優先(A2・A3) |
| R3 | 失敗として使えない番号(400〜599 の外。0・200・302・1000)は 500。WriteHeader(0) で panic させない(A4) |
| R4 | 想定外の error・5xx は本文を internal error に固定(A5)。番兵 ErrNotFound→404(A3)、context.DeadlineExceeded→503(A6) |
| R5 | context.Canceled は、相手が切った(r.Context() が終わっている)なら何も書かずログに 499。中で cancel しただけなら 500(A7) |
| R6 | 2xx を送り始めた後の error では、クライアントに完全な応答を渡さない(接続を切る)。WriteHeader なしの Write も「200 を送った」に数える(A8) |
| R7 | 中の中間件がすでに 4xx/5xx を書いてから error を返したら、2 度書かず、切らず、ログは 1 回(A9)。103 などの 1xx は送信済みに数えない(A13) |
| R8 | 外側の func(http.Handler) http.Handler(集計)は Adapt が決めた番号を見る(A10)。包んでも Flush が効く(A11)。同時に 200 本呼んでも混ざらない(A12) |
さらに短く答えてください(自己評価欄へ)。
- D1: R6 で Echo のように「何もしない」を採らなかった理由を、クライアントが受け取るものを根拠に 2 文で。逆に「何もしない」で十分になる条件は何か
- D2: R5 で
errors.Is(err, context.Canceled)だけで 499 と決めると、どんな障害が見えなくなるか。具体例を 1 つ
準備
mkdir d69 && cd d69 # 作業ディレクトリを作って入る
printf 'module d69\n\ngo 1.22\n' > go.mod # モジュールを作る(標準ライブラリだけ)
# 下の types.go・adapt.go(スタブ)・adapt_test.go を保存する
go test -count=1 ./... 2>&1 | grep -c '^--- FAIL' # スタブで落ちるテスト数(11。A1・A11 は何もしなくても通る)
go test -race -count=1 -v ./... # 実装後: 13 本すべて PASS、A8 の t.Logf に unexpected EOF が出る
ファイル 1: types.go(変えない)
package main
import (
"errors"
"net/http"
)
// HandlerE は Echo(func(c) error)・Caddy(ServeHTTP(w, r, next) error)と同じく
// 「失敗を error で返す」ハンドラ。書けなかった応答を、呼び出し側が代わりに書く。
type HandlerE func(w http.ResponseWriter, r *http.Request) error
// MiddlewareE は Echo の MiddlewareFunc と同じ形。下流の error を見て、足す・変える・返す。
type MiddlewareE func(HandlerE) HandlerE
// ChainE は ms[0] が最も外側になるように h を包む。
func ChainE(h HandlerE, ms ...MiddlewareE) HandlerE {
for i := len(ms) - 1; i >= 0; i-- {
h = ms[i](h)
}
return h
}
// StatusError は「クライアントに見せてよい」ステータスと文言を持つ error。
// Msg は応答本文に出る。Err は内部の原因で、ログにだけ出る。
type StatusError struct {
Code int
Msg string
Err error
}
func (e *StatusError) Error() string {
if e.Err != nil {
return e.Msg + ": " + e.Err.Error()
}
return e.Msg
}
func (e *StatusError) Unwrap() error { return e.Err }
// ErrNotFound は「対象が無い」を表す番兵。リポジトリ層が返し、HTTP 層が 404 にする。
var ErrNotFound = errors.New("not found")
// StatusClientClosed は nginx の 499 と同じ意味(クライアントが先に切った)。
// 実際には送らない。ログと集計にだけ使う。
const StatusClientClosed = 499
// LogFunc は Adapt が error 1 件につき 1 回だけ呼ぶ。status は「書いた(書くはずだった)」値。
type LogFunc func(r *http.Request, status int, err error)
ファイル 2: adapt.go(スタブ。ここを実装する)
package main
import "net/http"
// StatusOf は error を HTTP ステータスに直す。(ここを実装する)
func StatusOf(r *http.Request, err error) int { return 0 }
// publicMsg は本文に出す文言を決める。(ここを実装する)
func publicMsg(err error, status int) string { return "" }
// Adapt は HandlerE を net/http の http.Handler にする。(ここを実装する)
func Adapt(h HandlerE, log LogFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = h(w, r)
})
}
ファイル 3: adapt_test.go(変えない。A1〜A13)
adapt_test.go 全体(355 行)
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// logRec は LogFunc の呼び出しを全部覚える。
type logRec struct {
mu sync.Mutex
recs []string
}
func (l *logRec) fn(r *http.Request, status int, err error) {
l.mu.Lock()
defer l.mu.Unlock()
l.recs = append(l.recs, fmt.Sprintf("%d %v", status, err))
}
func (l *logRec) all() []string {
l.mu.Lock()
defer l.mu.Unlock()
return append([]string(nil), l.recs...)
}
// do は Adapt を httptest.Recorder で 1 回呼ぶ。
func do(t *testing.T, h HandlerE, r *http.Request) (*httptest.ResponseRecorder, []string) {
t.Helper()
l := &logRec{}
if r == nil {
r = httptest.NewRequest("GET", "/x", nil)
}
rec := httptest.NewRecorder()
Adapt(h, l.fn).ServeHTTP(rec, r)
return rec, l.all()
}
func want(t *testing.T, rec *httptest.ResponseRecorder, logs []string, code int, body string, nlog int) {
t.Helper()
if rec.Code != code || rec.Body.String() != body || len(logs) != nlog {
t.Fatalf("got %d %q logs=%q; want %d %q logs=%d", rec.Code, rec.Body.String(), logs, code, body, nlog)
}
}
// A1: error が nil なら、ハンドラが書いたものに一切触らない。ログも出さない。
func TestA1_NilPassesThrough(t *testing.T) {
rec, logs := do(t, func(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(201)
io.WriteString(w, "ok")
return nil
}, nil)
want(t, rec, logs, 201, "ok", 0)
}
// A2: 包まれた *StatusError からステータスと公開文言を取り出す。原因はログにだけ出る。
func TestA2_StatusErrorWrapped(t *testing.T) {
rec, logs := do(t, func(w http.ResponseWriter, r *http.Request) error {
return fmt.Errorf("handler: %w", &StatusError{Code: 400, Msg: "bad id", Err: errors.New("strconv: \"x\"")})
}, nil)
want(t, rec, logs, 400, "bad id\n", 1)
if !strings.Contains(logs[0], "strconv") {
t.Fatalf("原因がログに無い: %q", logs)
}
}
// A3: 番兵 ErrNotFound(包まれていても)は 404。ただし *StatusError が先に当たる。
func TestA3_SentinelAndPrecedence(t *testing.T) {
rec, logs := do(t, func(w http.ResponseWriter, r *http.Request) error {
return fmt.Errorf("repo.Get(42): %w", ErrNotFound)
}, nil)
want(t, rec, logs, 404, "Not Found\n", 1)
rec, logs = do(t, func(w http.ResponseWriter, r *http.Request) error {
return &StatusError{Code: 410, Err: ErrNotFound}
}, nil)
want(t, rec, logs, 410, "Gone\n", 1)
}
// A4: 失敗として使えないコード(0・200・1000)は 500。WriteHeader(0) の panic にしない。
func TestA4_InvalidCodes(t *testing.T) {
for _, c := range []int{0, 200, 302, 1000} {
rec, logs := do(t, func(w http.ResponseWriter, r *http.Request) error {
return &StatusError{Code: c, Msg: "x"}
}, nil)
if rec.Code != 500 || len(logs) != 1 {
t.Fatalf("Code=%d: got %d logs=%q; want 500", c, rec.Code, logs)
}
}
}
// A5: 想定外の error は 500。本文に error の中身を出さない(CWE-209)。ログには出す。
func TestA5_NoLeak(t *testing.T) {
secret := "pq: password authentication failed for user \"app\" host=10.0.3.7"
rec, logs := do(t, func(w http.ResponseWriter, r *http.Request) error {
return errors.New(secret)
}, nil)
want(t, rec, logs, 500, "internal error\n", 1)
if !strings.Contains(logs[0], "10.0.3.7") {
t.Fatalf("ログに原因が無い: %q", logs)
}
// 5xx の StatusError も、Msg が内部情報なら出さない
rec, logs = do(t, func(w http.ResponseWriter, r *http.Request) error {
return &StatusError{Code: 502, Msg: "upstream 10.0.3.7:5432 refused"}
}, nil)
want(t, rec, logs, 502, "internal error\n", 1)
}
// A6: 期限切れは 503(http.TimeoutHandler と同じ番号)。
func TestA6_Deadline(t *testing.T) {
rec, logs := do(t, func(w http.ResponseWriter, r *http.Request) error {
return fmt.Errorf("db: %w", context.DeadlineExceeded)
}, nil)
want(t, rec, logs, 503, "internal error\n", 1)
}
// countWriter は WriteHeader と Write が呼ばれたかを数える(Recorder は「呼ばれなかった」を区別できない)。
type countWriter struct {
h http.Header
headers, wrs int
}
func (c *countWriter) Header() http.Header { return c.h }
func (c *countWriter) WriteHeader(int) { c.headers++ }
func (c *countWriter) Write(b []byte) (int, error) { c.wrs++; return len(b), nil }
// A7: context.Canceled は「誰が切ったか」で分ける。
// 相手が切った(r.Context() が終わっている)→ 何も書かず、ログに 499。
// 中で誰かが cancel しただけ(r.Context() は生きている)→ 500。
func TestA7_CanceledWhoCut(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
r := httptest.NewRequest("GET", "/x", nil).WithContext(ctx)
cw := &countWriter{h: http.Header{}}
l := &logRec{}
Adapt(func(w http.ResponseWriter, r *http.Request) error {
return fmt.Errorf("query: %w", r.Context().Err())
}, l.fn).ServeHTTP(cw, r)
if cw.headers != 0 || cw.wrs != 0 || len(l.all()) != 1 || !strings.HasPrefix(l.all()[0], "499 ") {
t.Fatalf("相手が切った: headers=%d writes=%d logs=%q; want 0 0 [499 ...]", cw.headers, cw.wrs, l.all())
}
rec, logs := do(t, func(w http.ResponseWriter, r *http.Request) error {
inner, stop := context.WithCancel(r.Context())
stop() // 中で打ち切った(例: errgroup の兄弟が失敗)
return fmt.Errorf("fan-out: %w", inner.Err())
}, nil)
want(t, rec, logs, 500, "internal error\n", 1)
}
// serve は実サーバーに 1 回投げ、ステータスと本文と読み取りエラーを返す。
func serve(t *testing.T, h http.Handler) (int, string, error) {
t.Helper()
srv := httptest.NewServer(h)
defer srv.Close()
res, err := http.Get(srv.URL)
if err != nil {
return 0, "", err
}
defer res.Body.Close()
b, err := io.ReadAll(res.Body)
return res.StatusCode, string(b), err
}
// A8: 200 で本文を書き始めた後の error。クライアントに「完全な 200」を渡さない(接続を切る)。
// WriteHeader を呼ばずに Write だけした場合も同じ(暗黙の 200)。
func TestA8_ErrorAfterSuccessCommit(t *testing.T) {
for _, explicit := range []bool{true, false} {
l := &logRec{}
h := Adapt(func(w http.ResponseWriter, r *http.Request) error {
if explicit {
w.WriteHeader(200)
}
io.WriteString(w, "row1\n")
http.NewResponseController(w).Flush() // ここで送信済み(chunked)
return errors.New("row2: scan failed")
}, l.fn)
code, body, err := serve(t, h)
t.Logf("A8 explicit=%v: status=%d body=%q readErr=%v logs=%q", explicit, code, body, err, l.all())
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("explicit=%v: 読み取りが %v。途中の本文が完全な応答として届いた", explicit, err)
}
if logs := l.all(); len(logs) != 1 || !strings.HasPrefix(logs[0], "200 ") {
t.Fatalf("logs=%q; want [200 ...] 1 件", logs)
}
}
}
// A9: Echo の RequestLogger(HandleError: true) のように、中の中間件が失敗の応答を書いてから
// error を返す。Adapt は 2 度書かず、接続も切らず、ログは 1 回。
func TestA9_AlreadyHandledByMiddleware(t *testing.T) {
handled := func(next HandlerE) HandlerE {
return func(w http.ResponseWriter, r *http.Request) error {
err := next(w, r)
if err != nil {
s := StatusOf(r, err)
http.Error(w, publicMsg(err, s), s) // 先に書く
}
return err // それでも上へ返す(Echo と同じ)
}
}
l := &logRec{}
h := Adapt(ChainE(func(w http.ResponseWriter, r *http.Request) error {
return fmt.Errorf("get: %w", ErrNotFound)
}, handled), l.fn)
code, body, err := serve(t, h)
if err != nil || code != 404 || body != "Not Found\n" || len(l.all()) != 1 {
t.Fatalf("got %d %q err=%v logs=%q; want 404 \"Not Found\\n\" nil 1 件", code, body, err, l.all())
}
}
// Observe は Day063 と同じ A 型の集計中間件(ステータスだけ覚える)。
type obsWriter struct {
http.ResponseWriter
status int
}
func (o *obsWriter) WriteHeader(c int) { o.status = c; o.ResponseWriter.WriteHeader(c) }
func (o *obsWriter) Write(b []byte) (int, error) {
if o.status == 0 {
o.status = 200
}
return o.ResponseWriter.Write(b)
}
func (o *obsWriter) Unwrap() http.ResponseWriter { return o.ResponseWriter }
func Observe(got *int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ow := &obsWriter{ResponseWriter: w}
next.ServeHTTP(ow, r)
*got = ow.status
})
}
}
// A10: 外側の A 型中間件(集計)は、Adapt が error から決めたステータスを見る。
func TestA10_OuterObserveSeesMappedStatus(t *testing.T) {
cases := []struct {
err error
want int
}{
{nil, 204},
{ErrNotFound, 404},
{&StatusError{Code: 429, Msg: "slow down"}, 429},
{context.DeadlineExceeded, 503},
{errors.New("boom"), 500},
}
for _, c := range cases {
var got int
h := Observe(&got)(Adapt(func(w http.ResponseWriter, r *http.Request) error {
if c.err == nil {
w.WriteHeader(204)
}
return c.err
}, func(*http.Request, int, error) {}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/x", nil))
if got != c.want {
t.Fatalf("err=%v: Observe が見たのは %d; want %d", c.err, got, c.want)
}
}
}
// A11: Adapt が w を包んでも、http.ResponseController で Flush できる(SSE・大きな応答)。
func TestA11_FlushThroughWrapper(t *testing.T) {
var ferr error
rec, _ := do(t, func(w http.ResponseWriter, r *http.Request) error {
io.WriteString(w, "data: 1\n\n")
ferr = http.NewResponseController(w).Flush()
return nil
}, nil)
if ferr != nil || !rec.Flushed {
t.Fatalf("Flush err=%v flushed=%v; want nil true", ferr, rec.Flushed)
}
}
// A12: 同じ http.Handler を 200 本同時に呼ぶ。リクエストごとの状態が混ざらない(-race で)。
func TestA12_Concurrent(t *testing.T) {
errs := []error{nil, ErrNotFound, &StatusError{Code: 409, Msg: "conflict"}, errors.New("x")}
codes := []int{200, 404, 409, 500}
h := Adapt(func(w http.ResponseWriter, r *http.Request) error {
i := int(r.URL.Query().Get("i")[0] - '0')
if errs[i] == nil {
io.WriteString(w, "ok")
}
return errs[i]
}, func(*http.Request, int, error) {})
var wg sync.WaitGroup
bad := make(chan string, 200)
for n := 0; n < 200; n++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
i := n % 4
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", fmt.Sprintf("/x?i=%d", i), nil))
if rec.Code != codes[i] {
bad <- fmt.Sprintf("i=%d got %d", i, rec.Code)
}
}(n)
}
wg.Wait()
close(bad)
if n := len(bad); n > 0 {
t.Fatalf("%d 本が違うステータス(例: %s)", n, <-bad)
}
}
func BenchmarkPlain(b *testing.B) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
r := httptest.NewRequest("GET", "/x", nil)
b.ReportAllocs()
for i := 0; i < b.N; i++ {
h.ServeHTTP(httptest.NewRecorder(), r)
}
}
func BenchmarkAdaptNil(b *testing.B) {
h := Adapt(func(w http.ResponseWriter, r *http.Request) error { io.WriteString(w, "ok"); return nil },
func(*http.Request, int, error) {})
r := httptest.NewRequest("GET", "/x", nil)
b.ReportAllocs()
for i := 0; i < b.N; i++ {
h.ServeHTTP(httptest.NewRecorder(), r)
}
}
func BenchmarkAdaptNotFound(b *testing.B) {
h := Adapt(func(w http.ResponseWriter, r *http.Request) error { return ErrNotFound },
func(*http.Request, int, error) {})
r := httptest.NewRequest("GET", "/x", nil)
b.ReportAllocs()
for i := 0; i < b.N; i++ {
h.ServeHTTP(httptest.NewRecorder(), r)
}
}
// A13: 103 Early Hints(1xx)は「送信済み」ではない。その後の error は普通に 404 になる。
func TestA13_InformationalIsNotCommit(t *testing.T) {
l := &logRec{}
h := Adapt(func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Link", "</app.css>; rel=preload; as=style")
w.WriteHeader(http.StatusEarlyHints)
return fmt.Errorf("get: %w", ErrNotFound)
}, l.fn)
code, body, err := serve(t, h)
if err != nil || code != 404 || body != "Not Found\n" || len(l.all()) != 1 {
t.Fatalf("got %d %q err=%v logs=%q; want 404 \"Not Found\\n\"", code, body, err, l.all())
}
}
💡 ヒント
ヒント1(方向性)
Adapt の仕事は 3 つの場合分けだけです。「error が無い」「error があるが、まだ何も送っていない」「error があり、もう送り始めた」。3 つ目を判定するには、ハンドラに渡す w を自分で包んで、WriteHeader と Write を見張るしかありません。
ヒント2(アプローチ)
StatusOfはswitch { case errors.As(…): case errors.Is(…): … }で上から順に。順番そのものが仕様です(A3 は&StatusError{Code: 410, Err: ErrNotFound}を 410 にしたい)- 「相手が切った」は、error の中身ではなく
r.Context().Err()で見ます - 送信後に接続を切るには
panic(http.ErrAbortHandler)。net/http が回収し、スタックも出しません - 包んだ w には
Unwrap() http.ResponseWriterを付けます(http.ResponseControllerがこれをたどる)
ヒント3(コード骨格)
type commitWriter struct {
http.ResponseWriter
status int // 0 = まだ送っていない
}
// WriteHeader: 1xx(101 を除く)なら status を変えない
// Write: status が 0 なら 200 にする
// Unwrap: 中の ResponseWriter を返す
func Adapt(h HandlerE, log LogFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cw := &commitWriter{ResponseWriter: w} // リクエストごとに作る
err := h(cw, r)
// nil → return
// cw.status != 0 → log(1 回) → 2xx/3xx なら panic(http.ErrAbortHandler)、4xx/5xx なら return
// それ以外 → status := StatusOf(r, err) → log(1 回) → 499 なら return → http.Error(cw, publicMsg(...), status)
})
}
✅ 模範解答
adapt.go(86 行)
package main
import (
"context"
"errors"
"net/http"
)
// StatusOf は error を HTTP ステータスに直す。上から順に最初に当たったもの。
func StatusOf(r *http.Request, err error) int {
var se *StatusError
switch {
case errors.As(err, &se):
if se.Code < 400 || se.Code > 599 {
return http.StatusInternalServerError // 0・200・1000 は「失敗」として扱えない
}
return se.Code
case errors.Is(err, context.Canceled) && r.Context().Err() != nil:
return StatusClientClosed // 切ったのは相手。中の誰かが cancel しただけなら下の 500
case errors.Is(err, context.DeadlineExceeded):
return http.StatusServiceUnavailable
case errors.Is(err, ErrNotFound):
return http.StatusNotFound
}
return http.StatusInternalServerError
}
// publicMsg は本文に出す文言。5xx と番兵は固定文言で、err の中身は出さない(CWE-209)。
func publicMsg(err error, status int) string {
var se *StatusError
if errors.As(err, &se) && se.Msg != "" && status < 500 {
return se.Msg
}
if status >= 500 {
return "internal error"
}
return http.StatusText(status)
}
// commitWriter は「もう送り始めたか」と「何番を送ったか」だけを覚える。リクエストごとに作る。
type commitWriter struct {
http.ResponseWriter
status int // 0 = まだ
}
func (w *commitWriter) WriteHeader(code int) {
if w.status == 0 && (code >= 200 || code == http.StatusSwitchingProtocols) { // 101 以外の 1xx は確定ではない(server.go 1227 行と同じ線引き)
w.status = code
}
w.ResponseWriter.WriteHeader(code)
}
func (w *commitWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK // WriteHeader なしの Write は暗黙の 200
}
return w.ResponseWriter.Write(b)
}
// Unwrap があると http.ResponseController が中の Flusher などに届く(Go 1.20+)。
func (w *commitWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
// Adapt は HandlerE を net/http の http.Handler にする。
func Adapt(h HandlerE, log LogFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cw := &commitWriter{ResponseWriter: w}
err := h(cw, r)
if err == nil {
return
}
if cw.status != 0 { // 送信後の error。ステータスはもう変えられない
log(r, cw.status, err)
if cw.status < 400 {
// 成功を名乗った途中の本文を「完全な応答」にしない。接続を切る
panic(http.ErrAbortHandler)
}
return // 失敗の応答はもう書かれている(Echo の Committed と同じ扱い)
}
status := StatusOf(r, err)
log(r, status, err)
if status == StatusClientClosed {
return // 相手はいない。書かない
}
http.Error(cw, publicMsg(err, status), status)
})
}
実行結果(Go 1.27.1、2026-10-09、M1 MacBook Pro 8GB)
$ go test -race -count=1 -v ./... 2>&1 | grep -E '^(---|ok)|A8'
--- PASS: TestA1_NilPassesThrough (0.00s)
--- PASS: TestA2_StatusErrorWrapped (0.00s)
--- PASS: TestA3_SentinelAndPrecedence (0.00s)
--- PASS: TestA4_InvalidCodes (0.00s)
--- PASS: TestA5_NoLeak (0.00s)
--- PASS: TestA6_Deadline (0.00s)
--- PASS: TestA7_CanceledWhoCut (0.00s)
adapt_test.go:180: A8 explicit=true: status=200 body="row1\n" readErr=unexpected EOF logs=["200 row2: scan failed"]
adapt_test.go:180: A8 explicit=false: status=200 body="row1\n" readErr=unexpected EOF logs=["200 row2: scan failed"]
--- PASS: TestA8_ErrorAfterSuccessCommit (0.00s)
--- PASS: TestA9_AlreadyHandledByMiddleware (0.00s)
--- PASS: TestA10_OuterObserveSeesMappedStatus (0.00s)
--- PASS: TestA11_FlushThroughWrapper (0.00s)
--- PASS: TestA12_Concurrent (0.01s)
--- PASS: TestA13_InformationalIsNotCommit (0.00s)
ok d69 1.565s
🪜 Step-by-Step 解説
Step 1: 型を「失敗を返す」側に寄せる理由
HandlerE は http.HandlerFunc に error を 1 つ足しただけです。足したことで、ハンドラの中は if err != nil { return fmt.Errorf("repo.Get(%d): %w", id, err) } という普通の Go のエラー処理になり、HTTP の番号を考えるのは Adapt 1 か所になります。リポジトリ層は ErrNotFound を返すだけで、HTTP を知りません。
Step 2: StatusOf の順番が仕様
case errors.As(err, &se): // 1. 明示された番号が最優先
case errors.Is(err, context.Canceled) && r.Context().Err() != nil: // 2. 相手が切った
case errors.Is(err, context.DeadlineExceeded): // 3. 期限切れ
case errors.Is(err, ErrNotFound): // 4. 番兵
default: 500
1 を最初に置くのは、ハンドラが「これは 404 ではなく 410 だ」とわざわざ書いた意思を番兵より優先するためです(A3。M10 で順番を入れ替えると 404 になって落ちる)。errors.As でなく型アサーションにすると、%w で 1 段包んだだけで 500 に落ちます(M2 → A2)。
範囲外の番号を 500 にするのは 2 つの理由です。WriteHeader(0) や WriteHeader(1000) は net/http が panic します(server.go 1177 行 if code < 100 || code > 999 { panic(...) })。そして 200・302 は「失敗」の番号ではありません(M3 → A4 は panic で落ちる)。
Step 3: 「誰が cancel したか」を error の外で見る
context.Canceled は、クライアントが切断したときにも、**ハンドラ内で cancel() した**ときにも同じ値です。前者は相手がもういないので書いても無駄(nginx の慣行では 499 として記録)、後者は自分の失敗なので 500 で記録すべきです。区別は error の中身ではできず、r.Context().Err() != nil(リクエストの ctx が終わっているか)で判断します(M5 → A7)。
Step 4: 送信済みの見張り役 commitWriter
WriteHeader(code): 1xx は「情報」で、その後に本当の番号を 1 回送れます。net/http も 101 以外の 1xx は確定扱いしません(server.go1225〜1227 行)。ここを「何か書いたら確定」にすると、103 Early Hints の後の 404 が書けなくなります(M13 → A13。最初の 12 本ではこの変異が生き残り、A13 を足して検出)Write(b):WriteHeaderを呼ばずに書くと暗黙の 200。ここを見落とすと、io.WriteString(w, …)だけのハンドラで送信後の error に気づかず、http.Errorが本文の後ろにinternal errorを足します(M8 → A8)Unwrap():http.ResponseController(Go 1.20+)はUnwrapをたどってFlusherを探します。無いとFlush()がhttp.ErrNotSupportedを返し、SSE やストリーミングが黙って溜まるようになります(M1 → A11)
commitWriter はリクエストごとに &commitWriter{…} で作ります。Adapt の外(合成時)に 1 つだけ作って使い回すと、同時リクエストで status が混ざります(A12 が -race で見張る)。
Step 5: 送信後の error を番号で 2 つに分ける
| 送ってしまった番号 | クライアントの理解 | Adapt の動き |
|---|---|---|
| 2xx・3xx | 「成功した」 | panic(http.ErrAbortHandler)。chunked の終端を送らずに切るので、クライアントは unexpected EOF を受け取る |
| 4xx・5xx | 「失敗した」(中の中間件が書いた) | ログだけ。2 度目の WriteHeader も本文も書かない |
「常に切る」(M7)だと A9 の 404 が unexpected EOF に化け、「常に何もしない」(M6)だと A8 で途中の本文が完全な 200 になります。
Step 6: ログはちょうど 1 回
log を呼ぶ場所は 2 か所(送信後の分岐と、通常の分岐)で、どちらを通っても 1 回です。M11(2 回呼ぶ)は 7 本のテストで落ちます。障害時にエラー件数が 2 倍に見えると、アラートの閾値がずれます。
📏 実測(予想と外れた所を含む)
1. 送信後の error を 4 つの流儀で扱うと、クライアントに何が届くか
ハンドラが row1\n を書いて Flush した後に error を返す。4 流儀は今日の模型(Echo 型・Caddy 型は実物の該当行の動きだけを写した模型で、Echo・Caddy 本体は動かしていない)。httptest.NewServer(実 TCP)+ http.Get。
| 流儀 | Content-Length なし(chunked) | Content-Length: 10 を宣言 | サーバーログ |
|---|---|---|---|
| Echo 型(送信済みなら return) | **200 "row1\n"、読み取りエラーなし** | 200 "row1\n"、unexpected EOF | なし |
Caddy 型(WriteHeader(500)) | **200 "row1\n"、読み取りエラーなし** | 200 "row1\n"、unexpected EOF | http: superfluous response.WriteHeader call |
素朴(http.Error(w, …, 500)) | **200 "row1\ninternal error\n"、エラーなし** | 200 "row1\n"、unexpected EOF | superfluous… |
| 今日の Adapt(ErrAbortHandler) | 200 "row1\n"、unexpected EOF | 200 "row1\n"、unexpected EOF | なし |
- 意外だった所: 素朴型で Content-Length を宣言した列。
internal errorが本文に足されると考えていたが、net/http は宣言を超える書き込みをhttp.ErrContentLength(server.go57・1685 行)で拒み、本文はrow1\nのまま、宣言 10 バイトに届かずに接続が切れた - Caddy 型は、送信済みでも
WriteHeader(500)を呼ぶぶん net/http の警告が 1 行増えるだけで、クライアントに届くものは Echo 型と同じだった - 結論: 差が出るのは chunked の列だけ。ストリーミング・
Flushを使う応答(CSV エクスポート、SSE、大きな JSON 配列)は chunked になるので、そこでは切断が唯一「失敗」をクライアントに伝える手段
2. 変異 14 通り(全部検出)
| 変異 | 検出したテスト |
|---|---|
| M1 Unwrap を消す | A8,A11 |
| M2 errors.As を型アサーションに | A2 |
| M3 コード範囲検査なし | A4 |
| M4 5xx でも err を本文に | A5,A6,A7 |
| M5 Canceled は常に 499 | A7 |
| M6 送信後 2xx でも切らない(Echo の Committed→return 相当) | A8 |
| M7 送信後は常に切る | A9 |
| M8 Write で送信済みと覚えない | A8 |
| M9 送信済み検査なし(Caddy 型 | そのまま WriteHeader): A8,A9 |
| M10 StatusError より番兵を先に | A3 |
| M11 ログを 2 回 | A2,A3,A4,A5,A6,A7,A13 |
| M12 Msg を 5xx でも出す | A5 |
| M13 1xx で確定とみなす | A13 |
| M14 Deadline を 500 に | A6,A10 |
スタブ(何もしない Adapt)では 13 本中 11 本が落ちる。A1(nil 素通し)と A11(Flush)は「何もしない」でも通る。A11 は M1(Unwrap を消す)を検出するので意味があるが、A1 はどの変異も検出していない(error=nil の分岐を壊す変異を今日は作っていない。A1 は「余計なことをしない」の回帰防止としてだけ置いている)。
3. 割り当て(決定的)と時間(参考)
BenchmarkPlain-8 1016 B/op 10 allocs/op (素の HandlerFunc+httptest.NewRecorder)
BenchmarkAdaptNil-8 1040 B/op 11 allocs/op (+24 B・+1 alloc = commitWriter 1 個)
BenchmarkAdaptNotFound-8 1096 B/op 14 allocs/op (http.Error のヘッダ設定ぶん +3)
時間は 5 回で Plain 1,335〜1,905 ns、AdaptNil 1,602〜2,181 ns、AdaptNotFound 1,669〜2,676 ns。計測中のロードアベレージが 19.8〜21.7 と高く(M1 8GB で他の処理が並走)、差は揺れの中に埋もれているので比較には使わない。割り当ての差(+1 alloc / 24 B)は負荷に左右されないので、こちらを根拠にする。
🧭 設計思想・なぜこう書くのか
- Go は「戻り値の error」で失敗を運ぶ言語です。
http.Handlerだけがその例外で、Echo・Caddy・多くの社内フレームワークがerrorを返す形に戻しているのは、言語の流儀に揃えるためです。Adaptはその「戻した形」を標準のhttp.Handlerに再びつなぐので、chi・http.ServeMux・Day 063〜067 の A 型中間件をそのまま使えます(A10) - 小さいインターフェースを壊さない:
HandlerEは新しいインターフェースを作らず関数型 1 つ。http.Handlerを置き換えるのでなく、端で変換する(アダプタ)。フレームワークを持ち込まずに Echo の利点だけを取る - 明示性: どの error が何番になるかは
StatusOfの 1 つのswitchに全部書いてあり、順番がそのまま仕様。Echo のHTTPStatusCoder・Caddy のHandlerErrorも同じ考え方です - 失敗の伝え方はプロトコルに従う: HTTP/1.1 では送ったステータスは取り消せない。取り消せない以上、「途中で切る」が chunked で失敗を伝える唯一の方法で、net/http はそのために
ErrAbortHandlerを用意しています
🌐 他言語との比較
| 言語・FW | 失敗の運び方 | 送信後の失敗 | 今日の Adapt との違い |
|---|---|---|---|
| Echo v5(Go) | ハンドラが error を返す → HTTPErrorHandler | Committed なら何もしない(echo.go 465 行) | 送信後 2xx で切らない。exposeError で詳細を出せる |
| Caddy v2(Go) | ServeHTTP(w, r, next) error、caddyhttp.Error(code, err) | w.WriteHeader(errStatus)(server.go 790 行)。エラー経路(handle_errors)を設定できる | 送信済みを見ない。エラー ID とスタックをログに付ける |
| Express(Node) | next(err) → 4 引数のエラーハンドラ (err, req, res, next) | 公式ガイドが「res.headersSent なら既定のハンドラへ委ねよ(接続を閉じる)」と案内(今回ソース未確認、記憶) | Express は「閉じる」側。今日の Adapt と同じ向き |
| Spring MVC(Java) | 例外を投げる → @ExceptionHandler/@ControllerAdvice | コミット済みなら例外ハンドラは応答を変えられない(未確認) | 型で番号を決める点(@ResponseStatus)は StatusError と同じ発想 |
| FastAPI(Python) | raise HTTPException(status_code=…) | ストリーミング応答の途中の例外は接続切断になる(未確認) | 例外で運ぶか、値で運ぶかの違い |
Go の特徴は、例外の「投げる」がないので、返り値の error を誰かが番号に直す 1 か所が必ず要ることです。Java・Python は例外が勝手に上がるので、その 1 か所はフレームワークが最初から持っています。
🏭 実務での使いどころ
- **ハンドラを 1 行の
return errにする**:user, err := repo.Get(ctx, id); if err != nil { return fmt.Errorf("get user %d: %w", id, err) }。404 か 500 かはリポジトリの番兵とStatusOfが決める - CSV・JSON Lines のエクスポート: 10 万行を
Flushしながら書く途中で DB が切れた場合、Adapt なら利用者のダウンロードが「失敗」になる(unexpected EOF)。素の net/http だと途中までのファイルが正常終了として保存され、後で件数が合わないと問い合わせが来る - SLO の集計: 499(相手が切った)を 5xx から外し、中の cancel(500)は外さない。モバイル回線の切断でエラー率が跳ねるのを防ぎつつ、errgroup の失敗は隠さない
- chi・ServeMux との共存:
mux.Handle("GET /users/{id}", Adapt(getUser, logErr))。Observe・Recover・Auth などの A 型中間件は外側にそのまま積める(A10)。Recover(Day 064)はhttp.ErrAbortHandlerを握りつぶさず再 panic すること
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
送信後の error でも http.Error を書けば 500 になる | ステータスは本文より先に送られることを忘れる | 200 のまま、本文の後ろに internal error が足される(実測 1 の素朴型・chunked) |
| Echo・Caddy のように「何もしない」でクライアントは失敗に気づく | Content-Length 付きの応答で試すと切れて見える | chunked だと完全な 200として届く(実測 1) |
err.(*StatusError) で番号を取る | %w で包む習慣と型アサーションが噛み合わない | errors.As を使う(M2) |
context.Canceled はすべてクライアント切断 | 同じ値が 2 つの原因で返る | r.Context().Err() で発生源を見る(M5) |
WriteHeader(0) は 500 くらいになる | net/http が範囲を検査すると知らない | panic する(server.go 1177 行)。番号は範囲を確かめてから渡す(M3) |
| w を包んだら Flush は自動で効く | 埋め込み(embedding)でメソッドが引き継がれると思う | http.ResponseWriter を埋め込んでも Flush は出てこない。Unwrap を付けて ResponseController に任せる(M1) |
5xx の Msg は開発者が書いたものだから出してよい | 「公開用」と「内部用」の区別が型に無い | 5xx は固定文言。詳細はログへ(M12。CWE-209) |
🚧 扱わなかった範囲と理由
| 範囲 | 理由 |
|---|---|
| Echo・Caddy 本体を動かした比較 | 手元のモジュールキャッシュに無く、取得しなかった。該当行を GitHub master から読んで写した模型で測った |
| JSON のエラー本文(RFC 9457 Problem Details) | 番号と送信後の扱いに絞った。本文の形式は publicMsg を差し替えるだけで足せる |
| HTTP/2 での切断の見え方 | HTTP/2 では ErrAbortHandler が RST_STREAM になる(記憶。今回未測定)。クライアントの見え方は実装依存 |
| 101 Switching Protocols の分岐 | net/http に合わせて「送信済み」に数えるコードは入れたが、テストしていない(WebSocket は通常 Hijack を使い、WriteHeader(101) を経由しないため) |
| HEAD リクエスト | http.Error の本文は net/http が HEAD で捨てる。テストはしていない |
| Express・Spring・FastAPI の挙動 | 表の「未確認・記憶」と書いた行は今回ソースも実行も確認していない |
| 時間の比較 | ロードアベレージ 20 前後で揺れが差より大きい。割り当てだけを根拠にした |
➡️ 次のステップ
- 発展:
StatusOfをinterface{ StatusCode() int }に一般化し、gRPC のstatus.FromErrorも番号に直す。publicMsgを RFC 9457(application/problem+json)にする - 次回予告: 同テーマで「デバッグ」——今日の Adapt を Day 064 の Recover・Day 063 の Observe と積んだときに起きるバグ(Recover が
ErrAbortHandlerを 500 にする、Observe が 499 を 200 と数える、Adapt を二重に掛けてログが 2 倍、など)