Day 060 — 2026-09-29 | ミドルウェア設計パターン: 4 本のミドルウェアが「単体では正しい」のに、積むと壊れる——gzip の defer が panic を 200 にし、Flush が溜めに止まる 6 つのバグをテストで直す

2026-09-29(バックフィル) 🟡 上級者 / Phase 3 デバッグ・コードレビュー ミドルウェア設計パターン

フェーズ: 上級者 (Phase 3) | 問題タイプ: デバッグ・コードレビュー | テーマ: ミドルウェア設計パターン > 9/29 のバックフィル分(実際の作成日は 2026-10-02。番号は state の「次の番号」を使った)。Day 056 の「5 つのバグ」とは別の題材です(Day 056 は順序・return 抜け・本文の消費。今日は ResponseWriter を包む側の約束事 と gzip という「書き換える」ミドルウェア が中心)。

📚 背景知識

アクセスログ・panic 回復・gzip。どれも 20 行で書けて、単体で試すと動きます。壊れるのは「積んだとき」と「実サーバーに載せたとき」です。今日は次の 4 つの約束事を、バグ付きのコードに出会って覚えます。

  1. defer f(x) の x は defer を書いた時点で評価される。ミドルウェアでは「終わった後の状態」を見たいので、ほぼ必ず defer func() { ... }() にする。
  2. WriteHeader は最初の 1 回だけが効く(net/http は 2 回目を superfluous response.WriteHeader call とログに出して捨てる。go1.27.1 net/http/server.go:1210)。包む側が「最後の値」を覚えると、クライアントが見たものとログがずれる。
  3. panic(http.ErrAbortHandler) は「接続を切れ」という合図。go doc net/http.ErrAbortHandler(Go 1.27.1): 「any panic from ServeHTTP aborts the response to the client, panicking with ErrAbortHandler also suppresses logging of a stack trace」。サーバー側は server.go:1936 で err != ErrAbortHandler のときだけスタックを出す。回復ミドルウェアがこれを握りつぶすと、切るべき接続が 500 つきで正常終了する。
  4. 書き換えるミドルウェア(gzip)は、下流が付けるヘッダと、Flush / Unwrap という「能力」に責任を持つ。http.ResponseController.Flush は FlushError() error → http.Flusher → Unwrap() の順に探す(responsecontroller.go:47-62)。包んだ側がどれも持たなければ ErrNotSupported。

世界の実物との位置づけ(参照日 2026-10-02。star 数は gh api repos/<repo> の実測)

実物star今日との関係
felixge/httpsnoop1,174ResponseWriter を「壊さずに」包む専用ライブラリ。Flusher/Hijacker/ReaderFrom などの組み合わせを壊さず通すのが売り(組み合わせの網羅方法は手元知識。README は未確認)
klauspost/compress(gzhttp)5,654本番向け gzip ミドルウェア。最小サイズ・Content-Type 判定・Content-Length の扱い・プールを持つ
go-chi/chi22,914middleware.Recoverer は rvr == http.ErrAbortHandler なら回復しない(recoverer.go:26)、middleware.Compress は Del("Content-Length")(compress.go:334)。どちらも今日の B3・B5 と同じ処理(2026-10-02 に GitHub の main を grep して確認)

今日の自作は、この 3 つの「中核概念」のうち、約束事 4 つ(状態の記録・Abort の通過・Content-Length・Flush)だけを扱う。圧縮レベル・最小サイズ・Content-Type による除外・sync.Pool・Hijacker・ReaderFrom は扱わない(「扱わなかった範囲」参照)。

📝 問題

課題

下の 4 ファイルを置くと、go test が 6 本落ちます(実測。バグ付きの mw.go で 7 本中 6 本が FAIL、1 本は守る側のテストで PASS)。mw.go だけを直して、7 本すべて・-race でも通してください。main.go(積む順 AccessLog → Recover → Gzip)と main_test.go は変えません。

#症状(実サーバーで観測)落ちるテスト
B1ログの status がいつも 0TestAccessLogRecordsStatus
B2本文を書いた後に http.Error を呼ぶ道で、ログは 500、クライアントは 200TestFirstWriteHeaderWins
B3panic(http.ErrAbortHandler) が 200 の空応答になる(切れない)TestAbortHandlerIsNotRecovered
B4普通の panic("boom") が 500 でなく 200・本文なし になるTestPanicStillBecomes500
B5Content-Length を自分で付けるハンドラの応答が unexpected EOFTestGzipWithContentLength
B6SSE の 1 件目が届かない(ヘッダも来ない)TestGzipFlushReachesClient

(TestPlainClientKeepsContentLength は「gzip を要求しないクライアントには従来どおり」を守るテスト。直しすぎの検出用で、最初から PASS)

先に予想を書く(自己評価欄へ)

  • 問A: B3 と B4 は、直す場所が同じか。別か。
  • 問B: B1 を直すと、TestFirstWriteHeaderWins はそのまま通るか。
  • 問C: go vet は B1〜B6 のうちいくつ見つけるか。

準備

mkdir day060 && cd day060
# go.mod・main.go・mw.go・main_test.go を置く
go test -count=1 ./...        # 6 本落ちる
go test -race -count=1 ./...  # 直した後は -race でも通ること

go.mod:

module day060

go 1.24
package main

import (
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"strings"
)

// newHandler は 4 本の「本体」と、守りの積み方を決める。
// 積む順(外→内)は AccessLog → Recover → Gzip。mw.go の中身を直しても、この行は変えない。
// release は /events が「次のイベントを送る」のを待つための栓(テストが閉じる)。
func newHandler(l *slog.Logger, release <-chan struct{}) http.Handler {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /created", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusCreated)
		fmt.Fprint(w, "made")
	})
	// 先に 200 で本文を書いたあと、失敗に気づいて http.Error を呼ぶ(実務でよくある順序)。
	mux.HandleFunc("GET /late", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "ok")
		http.Error(w, "late failure", http.StatusInternalServerError)
	})
	// Content-Length を自分で付ける(静的ファイルや事前計算した JSON と同じ形)。
	mux.HandleFunc("GET /big", func(w http.ResponseWriter, r *http.Request) {
		body := strings.Repeat("a", 2000)
		w.Header().Set("Content-Length", fmt.Sprint(len(body)))
		fmt.Fprint(w, body)
	})
	// SSE。1 件送って Flush し、release が閉じるまで待ってから 2 件目を送る。
	mux.HandleFunc("GET /events", func(w http.ResponseWriter, r *http.Request) {
		rc := http.NewResponseController(w)
		w.Header().Set("Content-Type", "text/event-stream")
		fmt.Fprint(w, "data: 1\n\n")
		rc.Flush()
		select {
		case <-release:
		case <-r.Context().Done():
			return
		}
		fmt.Fprint(w, "data: 2\n\n")
	})
	mux.HandleFunc("GET /boom", func(w http.ResponseWriter, r *http.Request) { panic("boom") })
	mux.HandleFunc("GET /abort", func(w http.ResponseWriter, r *http.Request) { panic(http.ErrAbortHandler) })

	return Chain(mux, AccessLog(l), Recover(l), Gzip)
}

func main() {
	l := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	l.Info("listening", "addr", ":8080")
	if err := http.ListenAndServe(":8080", newHandler(l, make(chan struct{}))); err != nil {
		l.Error("serve", "err", err)
		os.Exit(1)
	}
}
package main

import (
	"compress/gzip"
	"log/slog"
	"net/http"
	"strings"
	"time"
)

type Middleware func(http.Handler) http.Handler

// Chain は先に書いたものが外側になるように積む。
func Chain(h http.Handler, mws ...Middleware) http.Handler {
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

// ---- 状態コードを覚える ResponseWriter ----

type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (s *statusRecorder) WriteHeader(code int) {
	s.status = code
	s.ResponseWriter.WriteHeader(code)
}

func (s *statusRecorder) Write(b []byte) (int, error) {
	if s.status == 0 {
		s.status = http.StatusOK
	}
	return s.ResponseWriter.Write(b)
}

func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }

// ---- アクセスログ ----

func AccessLog(l *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			rec := &statusRecorder{ResponseWriter: w}
			start := time.Now()
			defer l.Info("request", "method", r.Method, "path", r.URL.Path,
				"status", rec.status, "dur_ms", time.Since(start).Milliseconds())
			next.ServeHTTP(rec, r)
		})
	}
}

// ---- panic を 500 にする ----

func Recover(l *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if v := recover(); v != nil {
					l.Error("panic", "value", v, "path", r.URL.Path)
					http.Error(w, "internal server error", http.StatusInternalServerError)
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}

// ---- gzip ----

type gzipWriter struct {
	http.ResponseWriter
	gz *gzip.Writer
}

func (g *gzipWriter) Write(b []byte) (int, error) { return g.gz.Write(b) }

func Gzip(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Add("Vary", "Accept-Encoding")
		if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
			next.ServeHTTP(w, r)
			return
		}
		w.Header().Set("Content-Encoding", "gzip")
		gz := gzip.NewWriter(w)
		defer gz.Close()
		next.ServeHTTP(&gzipWriter{ResponseWriter: w, gz: gz}, r)
	})
}
package main

import (
	"bufio"
	"bytes"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"
)

type syncBuf struct {
	mu sync.Mutex
	b  bytes.Buffer
}

func (s *syncBuf) Write(p []byte) (int, error) { s.mu.Lock(); defer s.mu.Unlock(); return s.b.Write(p) }
func (s *syncBuf) String() string              { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }

func setup(t *testing.T) (*httptest.Server, *syncBuf, chan struct{}) {
	t.Helper()
	buf := &syncBuf{}
	release := make(chan struct{})
	srv := httptest.NewServer(newHandler(slog.New(slog.NewJSONHandler(buf, nil)), release))
	t.Cleanup(srv.Close)
	return srv, buf, release
}

// logStatus は msg=="request" の行のうち path が一致するものの status を返す。
func logStatus(t *testing.T, buf *syncBuf, path string) int {
	t.Helper()
	deadline := time.Now().Add(2 * time.Second) // ログはレスポンス送出後に書かれるので少し待つ
	for time.Now().Before(deadline) {
		for _, line := range strings.Split(buf.String(), "\n") {
			var m map[string]any
			if json.Unmarshal([]byte(line), &m) == nil && m["msg"] == "request" && m["path"] == path {
				return int(m["status"].(float64))
			}
		}
		time.Sleep(10 * time.Millisecond)
	}
	t.Fatalf("path=%s のアクセスログが出ていない:\n%s", path, buf.String())
	return 0
}

// B1: defer の引数は defer を書いた時点で評価される
func TestAccessLogRecordsStatus(t *testing.T) {
	srv, buf, _ := setup(t)
	resp, err := http.Get(srv.URL + "/created")
	if err != nil {
		t.Fatal(err)
	}
	io.Copy(io.Discard, resp.Body)
	resp.Body.Close()
	if got := logStatus(t, buf, "/created"); got != 201 {
		t.Fatalf("ログの status = %d, want 201", got)
	}
}

// B2: 本文を書いた後の http.Error は無視される。ログも最初の状態コードのまま
func TestFirstWriteHeaderWins(t *testing.T) {
	srv, buf, _ := setup(t)
	resp, err := http.Get(srv.URL + "/late")
	if err != nil {
		t.Fatal(err)
	}
	io.Copy(io.Discard, resp.Body)
	resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Fatalf("クライアントが見た status = %d, want 200", resp.StatusCode)
	}
	if got := logStatus(t, buf, "/late"); got != 200 {
		t.Fatalf("ログの status = %d, want 200(クライアントが見た値と一致させる)", got)
	}
}

// B3: ErrAbortHandler は「接続を切れ」という合図。500 に変えてはいけない
func TestAbortHandlerIsNotRecovered(t *testing.T) {
	srv, _, _ := setup(t)
	resp, err := http.Get(srv.URL + "/abort")
	if err == nil {
		resp.Body.Close()
		t.Fatalf("接続が切れるはずが status=%d で応答した", resp.StatusCode)
	}
}

// 守る側: 普通の panic は今までどおり 500
func TestPanicStillBecomes500(t *testing.T) {
	srv, _, _ := setup(t)
	resp, err := http.Get(srv.URL + "/boom")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 500 {
		t.Fatalf("status = %d, want 500", resp.StatusCode)
	}
}

// B4: 自分で付けた Content-Length は gzip 後の長さと合わない
func TestGzipWithContentLength(t *testing.T) {
	srv, _, _ := setup(t)
	resp, err := http.Get(srv.URL + "/big") // Go の Transport は Accept-Encoding: gzip を自動で付けて自動で伸長する
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		t.Fatalf("本文の読み取りに失敗: %v", err)
	}
	if len(body) != 2000 {
		t.Fatalf("len(body) = %d, want 2000", len(body))
	}
}

// 守る側: gzip を受け付けないクライアントには、そのまま・Content-Length 付きで返す
func TestPlainClientKeepsContentLength(t *testing.T) {
	srv, _, _ := setup(t)
	c := &http.Client{Transport: &http.Transport{DisableCompression: true}}
	resp, err := c.Get(srv.URL + "/big")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if len(body) != 2000 || resp.Header.Get("Content-Encoding") != "" || resp.ContentLength != 2000 {
		t.Fatalf("len=%d enc=%q CL=%d", len(body), resp.Header.Get("Content-Encoding"), resp.ContentLength)
	}
	if !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") {
		t.Fatalf("Vary に Accept-Encoding が無い: %q", resp.Header.Get("Vary"))
	}
}

// B5: gzip の内側の Flush は、gzip のバッファも吐き出して初めてクライアントに届く
func TestGzipFlushReachesClient(t *testing.T) {
	srv, _, release := setup(t)
	defer close(release) // 失敗しても /events のハンドラを解放して srv.Close が詰まらないようにする

	type result struct {
		line string
		err  error
	}
	got := make(chan result, 1)
	go func() {
		resp, err := http.Get(srv.URL + "/events") // ヘッダ自体が Flush されないとここで止まる
		if err != nil {
			got <- result{err: err}
			return
		}
		defer resp.Body.Close()
		line, err := bufio.NewReader(resp.Body).ReadString('\n')
		got <- result{line, err}
	}()
	select {
	case r := <-got:
		if r.err != nil || r.line != "data: 1\n" {
			t.Fatalf("1 件目 = %q, err = %v", r.line, r.err)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("Flush したのに 2 秒たっても 1 件目が届かない")
	}
}

💡 ヒント

ヒント1(方向性)

6 つの症状は 6 つの原因ではありません。B3 と B4 は同じ場所(gzip の末尾の書き方)が一部の原因で、B1 は B2 の原因を隠しています。1 つ直したら必ず全テストを回し直して、症状が「入れ替わる」のを見てください。

ヒント2(アプローチ)
  • B1: defer の後ろを関数リテラルに包む
  • B2: 状態コードは「まだ 0 のときだけ」覚える。1xx は何度でも呼べるので code >= 200 を条件に
  • B3: recover() の値が http.ErrAbortHandler なら panic(v) で返す
  • B4: defer gz.Close() は panic のときも走る。gzip の末尾が書かれた瞬間に 200 が確定する。Close は next.ServeHTTP が正常に戻った後に置く
  • B5: Content-Length を消す場所は、Gzip の入口ではない(ハンドラが後から付ける)。最初の WriteHeader / Write の瞬間に消す
  • B6: Flush の持ち主は gzipWriter。FlushError() error を実装し、gz.Flush() → 下流の Flush の順
ヒント3(コード骨格)
type gzipWriter struct {
	http.ResponseWriter
	gz      *gzip.Writer
	started bool
}
func (g *gzipWriter) start()                        { /* 1 回だけ: Content-Length を消し Content-Encoding を付ける */ }
func (g *gzipWriter) WriteHeader(code int)          { g.start(); g.ResponseWriter.WriteHeader(code) }
func (g *gzipWriter) Write(b []byte) (int, error)   { g.start(); return g.gz.Write(b) }
func (g *gzipWriter) FlushError() error             { /* start → gz.Flush → NewResponseController(下流).Flush */ }
func (g *gzipWriter) Unwrap() http.ResponseWriter   { return g.ResponseWriter }

✅ 模範解答

模範解答を表示する(まず問A〜Cの予想を書いてから)

mw.go の差分は 4 か所です(AccessLog の defer・statusRecorder.WriteHeader・Recover の再 panic・gzipWriter 一式)。完全版:

package main

import (
	"compress/gzip"
	"log/slog"
	"net/http"
	"strings"
	"time"
)

type Middleware func(http.Handler) http.Handler

// Chain は先に書いたものが外側になるように積む。
func Chain(h http.Handler, mws ...Middleware) http.Handler {
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

// ---- 状態コードを覚える ResponseWriter ----

type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (s *statusRecorder) WriteHeader(code int) {
	if s.status == 0 && code >= 200 { // 最初の最終ステータスだけ覚える(1xx は何度でも呼べる)
		s.status = code
	}
	s.ResponseWriter.WriteHeader(code)
}

func (s *statusRecorder) Write(b []byte) (int, error) {
	if s.status == 0 {
		s.status = http.StatusOK
	}
	return s.ResponseWriter.Write(b)
}

func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }

// ---- アクセスログ ----

func AccessLog(l *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			rec := &statusRecorder{ResponseWriter: w}
			start := time.Now()
			defer func() { // 引数は「いま」ではなく、閉じるときに評価したい
				l.Info("request", "method", r.Method, "path", r.URL.Path,
					"status", rec.status, "dur_ms", time.Since(start).Milliseconds())
			}()
			next.ServeHTTP(rec, r)
		})
	}
}

// ---- panic を 500 にする ----

func Recover(l *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if v := recover(); v != nil {
					if v == http.ErrAbortHandler {
						panic(v) // 「接続を切れ」の合図は net/http に返す
					}
					l.Error("panic", "value", v, "path", r.URL.Path)
					http.Error(w, "internal server error", http.StatusInternalServerError)
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}

// ---- gzip ----

type gzipWriter struct {
	http.ResponseWriter
	gz      *gzip.Writer
	started bool
}

// start は「最初に WriteHeader か Write が来た時」に1回だけ、gzip にするとヘッダに書く。
// ハンドラが付けた Content-Length は圧縮後の長さと合わないので、このとき消す。
func (g *gzipWriter) start() {
	if g.started {
		return
	}
	g.started = true
	h := g.Header()
	h.Del("Content-Length")
	h.Set("Content-Encoding", "gzip")
}

func (g *gzipWriter) WriteHeader(code int) { g.start(); g.ResponseWriter.WriteHeader(code) }

func (g *gzipWriter) Write(b []byte) (int, error) { g.start(); return g.gz.Write(b) }

// FlushError は ResponseController.Flush が最初に探すメソッド。gzip の溜めを吐いてから下流を Flush する。
func (g *gzipWriter) FlushError() error {
	g.start()
	if err := g.gz.Flush(); err != nil {
		return err
	}
	return http.NewResponseController(g.ResponseWriter).Flush()
}

func (g *gzipWriter) Unwrap() http.ResponseWriter { return g.ResponseWriter }

func Gzip(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Add("Vary", "Accept-Encoding")
		if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
			next.ServeHTTP(w, r)
			return
		}
		gw := &gzipWriter{ResponseWriter: w, gz: gzip.NewWriter(w)}
		next.ServeHTTP(gw, r)
		// defer にしない: panic の最中に Close すると、gzip の末尾が 200 として確定してしまい、
		// 外側の Recover が 500 を返せなくなる。何も書かなかった応答には空の gzip も付けない。
		if gw.started {
			gw.gz.Close()
		}
	})
}

実行結果(Go 1.27.1 darwin/arm64、go test -race -count=3 ./...): ok day060。

🪜 Step-by-Step 解説

Step 1: B1 — defer の引数は「今」評価される

defer l.Info(..., "status", rec.status, ...) は、この行を通った瞬間の rec.status(=0)をコピーして積みます。time.Since(start) も同じ理由で 0 ms になります。go vet が出すのは後者だけ(call to time.Since is not deferred。実測)で、rec.status の方は黙ります。ツールが見つけてくれるのは時間の計算だけで、「状態を後で読む」という意図は人が書く必要があります。

Step 2: B2 — 最後ではなく最初の WriteHeader

/late は Write("ok")(暗黙の 200)の後に http.Error(..., 500) を呼びます。net/http は 2 回目を捨てて superfluous をログに出すだけで、クライアントは 200 を見ます。包む側が s.status = code と上書きすると、ログだけ 500 になり監視のグラフとクライアントの体験が食い違います。B1 が残っている間は status が 0 のままなので、このバグは見えません(テスト結果の「0」の裏に隠れている)。

Step 3: B4 — gzip の defer gz.Close() が panic を 200 にする

最も気づきにくいバグです。panic("boom") が起きると、スタックを巻き戻す途中で Gzip の defer gz.Close() が走り、gzip のヘッダ 10 バイトと末尾 8 バイトを下流に書きます。これが暗黙の WriteHeader(200) になります。その後で外側の Recover が http.Error(500) を呼んでも superfluous。クライアントには 200 と壊れた gzip が届きます(実測: curl が http=200 size=0 exit=23)。

直し方は「panic のときは Close しない」。defer をやめて、next.ServeHTTP の後ろに置きます(正常に戻ったときだけ実行される)。何も書かなかった応答に空の gzip ストリームを付けないよう、started も見ます。

Step 4: B3 — ErrAbortHandler は握りつぶさない

Step 3 を直しても B3 はまだ落ちます。Recover が ErrAbortHandler を 500 に変えるためです。v == http.ErrAbortHandler なら panic(v) で net/http に返す。net/http はこれを受けて接続を切り、スタックのログも出しません(server.go:1936)。「回復」は panic の種類を選ぶ、というのが chi の Recoverer も同じ処理を持つ理由です。

Step 5: B5 — Content-Length は圧縮後の長さではない

ハンドラは 2000 バイトに Content-Length: 2000 を付けます。gzip 後は 36 バイト。ヘッダが 2000 のままだとクライアントは 36 バイトで EOF を受け取り unexpected EOF。直しは Del("Content-Length")。タイミングが肝で、入口で消してもハンドラが後から付け直すので、最初の WriteHeader/Write で(start())。同じ start() で Content-Encoding: gzip も付けます。入口で付けると、Step 3 の 500(平文)に Content-Encoding: gzip が付いて、クライアントが伸長に失敗します。

Step 6: B6 — Flush は 3 段ある

/events の rc.Flush() は gzipWriter に当たります。(1) FlushError/Flusher が無く Unwrap も無いので ErrNotSupported(ハンドラは戻り値を捨てている)→ ヘッダすら出ない。(2) Unwrap だけ足しても、データは gzip の内部バッファに残っているので届かない。gz.Flush() で圧縮ブロックを確定してから下流を Flush する、の 2 段が要ります(変異 M6・M7 で、どちらを欠いてもこのテストが落ちることを確認)。

🧭 設計思想・なぜこう書くのか

  • 標準ライブラリは「約束」で動いている: ResponseWriter はインターフェース 3 メソッドだけで、Flusher や Hijacker は「実体が持っていれば使える」任意の能力。包むとその能力が消える、という設計上の代償を、Go 1.20 の ResponseController(Unwrap を辿る)が埋めた。包む側は Unwrap を書く、が現在の作法。
  • 小さなインターフェースの副作用: http.Handler が ServeHTTP 1 本なので、ミドルウェアは何でも包める。その自由さと引き換えに、包んだ側が何を落としたかを型は教えてくれない。だからテストが唯一の網。
  • 明示的エラー処理の延長としての panic: Go は例外で制御しない。しかし ErrAbortHandler だけは「panic を制御フローに使う」公式の例外。回復ミドルウェアはこれを知っている必要がある。

⚖️ 迷った他案と採らなかった理由

論点採った案採らなかった案と理由前提が変わったら
ResponseWriter を包む自作(3 メソッド + Unwrap)httpsnoop(1,174★): Hijacker・ReaderFrom まで壊さず包めるが、今日の論点(何が起きるか)が見えなくなるWebSocket・io.Copy の最適化(ReaderFrom)を通す API なら httpsnoop を使う
gzip の実装自作klauspost/compress/gzhttp(5,654★): 本番ならこちら。最小サイズ・Content-Type 判定・プールを持つ。今日は「壊れ方」を学ぶのが目的本番に載せるなら即置換。契約テストはそのまま使える(newHandler の積み替えだけで同じテストを当てる)
Content-Length最初の書き込みで削除全体をバッファして圧縮後の長さを付ける: SSE・大きな応答で壊れる/メモリを食う小さな固定サイズの応答だけを扱う内部 API ならバッファでよい
panic 中の gzipClose しない(defer をやめる)常に defer Close して Recover を gzip の内側に置く: 積む順の知識を main.go に強いる。ミドルウェア単体で安全な方が良い積む順を 1 か所で強制する仕組み(宣言表)があるなら成立する
ErrAbortHandler再 panic回復しない・ログだけ出す: 通常 panic の 500 化まで失う—
状態コード最初の 200 以上常に上書き(バグ版)/ wroteHeader フラグで全て無視: 1xx(Early Hints)の扱いを間違えるHijack(101)を通すなら別扱い

🌐 他言語との比較

※ 下表は手元知識で、一次資料は未確認。

言語・FW同じ問題の扱い
Java ServletHttpServletResponseWrapper で包む。isCommitted() で「ヘッダ確定済み」を問える(Go の ResponseWriter には無い)。gzip フィルタで Content-Length を消すのは定番の作法
Express (Node)compression ミドルウェアは res.write を差し替え、Content-Length を removeHeader する。res.flush() を自前で足す必要があり、Go の Flush 問題と同じ
Python (WSGI)レスポンスがイテレータなので、gzip ミドルウェアは close() の呼び出しに責任を持つ。panic の代わりに例外

🛠️ 実務での使いどころ

  • 本番の「500 率」ダッシュボードが、gzip を足した日から 0 になる(panic が 200 で記録される)。B4 は監視が静かになる種類の障害で、気づくのは遅れる。
  • SSE・長いレスポンスの前段に CDN/gzip を入れた途端、「イベントが最後にまとめて届く」。B6。
  • 静的ファイル・事前計算 JSON に Content-Length を付ける実装に、後から gzip を足すと一部のクライアントだけ unexpected EOF(B5)。
  • 公開されている同じ作法(確認済み): chi の Recoverer(recoverer.go:26)と Compress(compress.go:334)、標準の httputil.ReverseProxy は転送中の失敗で panic(http.ErrAbortHandler) を使う(go1.27.1 net/http/httputil/reverseproxy.go:600、Issue 23643 への言及つき)。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「ミドルウェアは単体テストが通れば安全」各自は正しい。破綻は積み方と実サーバーの挙動httptest.NewServer の実接続で、積んだ形を試す
「httptest.NewRecorder で十分」速いので使いたくなるRecorder は接続を切れない・Flush を実際には送らない。B3・B5・B6 は実サーバーでしか見えない
「go vet が通れば defer は安全」vet は time.Since の即評価だけ指摘するdefer f(x) の x 全般は人が見る
「recover は全部 500 にすれば親切」例外処理の習慣ErrAbortHandler は意図的な切断
「Content-Encoding は先に付けてよい」入口で付けるのが自然に見える下流が 500 を平文で返す道がある。付けるのは圧縮を始める瞬間

🚧 扱わなかった範囲

  • 圧縮レベル・最小サイズ・Content-Type による除外・sync.Pool(gzhttp は持つ)。性能は測っていない
  • Hijacker・ReaderFrom・Pusher(httpsnoop は網羅)。Unwrap で ResponseController 経由の Hijack は届くが、テストしていない
  • HEAD・204・304・Range・既に Content-Encoding が付いた応答(二重圧縮)
  • WriteHeader(101)・103 Early Hints の細部。1xx は code >= 200 で無視するだけ
  • HTTP/2 での挙動(テストは HTTP/1.1 の httptest.NewServer)
  • gzip bomb 等のセキュリティ(今日は応答側のみ)

🚀 次のステップ

  • 発展: Gzip を klauspost/compress/gzhttp に差し替えて、同じ main_test.go を当てる(契約テスト。どれが落ちるかを見る)。AccessLog を httpsnoop 版に差し替えて 3 ファイルの差を測る
  • 次回予告: 同テーマ(ミドルウェア設計パターン)の続き。state の記載に従う

🎯 自己評価(解いた後に記入)

問A〜C の予想と結果

自分の回答

気づき・メモ