Day 069 — 2026-10-09 | ミドルウェア設計パターン: error を返すハンドラ(Echo・Caddy 型)を net/http に橋渡しする Adapt——error→ステータスの表、送信後の error、外側の中間件との順序を 13 本のテストと 14 通りの変異で決める

2026-10-09 🟡 上級者 / Phase 3 実装 ミドルウェア設計パターン
フェーズ: 上級者 (Phase 3) | 問題タイプ: 実装 | テーマ: ミドルウェア設計パターン
Day 068 の次回予告どおり「実装」。Day 068 で比べた 3 流儀(デコレータ・添字カーソル・Koa 型)の外に残した 4 つ目、「失敗を error で返すハンドラ」(Echo func(c) error、Caddy v2 ServeHTTP(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、それ以外は 500modules/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 本をすべて通してください。約束は次の表です(括弧はそれを見張るテスト)。

#約束
R1error が 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)
R5context.Canceled は、相手が切った(r.Context() が終わっている)なら何も書かずログに 499。中で cancel しただけなら 500(A7)
R62xx を送り始めた後の 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.go 1225〜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 EOFhttp: superfluous response.WriteHeader call
素朴(http.Error(w, …, 500))**200 "row1\ninternal error\n"、エラーなし**200 "row1\n"、unexpected EOFsuperfluous…
今日の Adapt(ErrAbortHandler)200 "row1\n"、unexpected EOF200 "row1\n"、unexpected EOFなし
  • 意外だった所: 素朴型で Content-Length を宣言した列。internal error が本文に足されると考えていたが、net/http は宣言を超える書き込みを http.ErrContentLength(server.go 57・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 は常に 499A7
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 を返す → HTTPErrorHandlerCommitted なら何もしない(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 倍、など)

🎯 自己評価

自分の回答(最初に通った本数 / 自分の実装で検出できた変異)

D1・D2 の答え

気づき・メモ