Day 061 — 2026-09-30 | ミドルウェア設計パターン: タイムアウトをどの層に置くか——TimeoutHandler・ctx の期限・Server.WriteTimeout が「何を止め、何を止めないか」を8つの実験で測り、ルートごとに仕分ける

2026-09-30(バックフィル) 🟡 上級者 / Phase 3 設計・アーキテクチャ ミドルウェア設計パターン

フェーズ: 上級者 (Phase 3) | 問題タイプ: 設計・アーキテクチャ | テーマ: ミドルウェア設計パターン 9/30 のバックフィル分(実際の作成日は 2026-10-03。番号は state の「次の番号」を使った)。Day 057 の設計(ルートごとにミドルウェアを変える)、Day 059 のレート制限(入口で断る)とは別の軸で、「遅いリクエストを誰がいつ切るか」 を扱う。

📚 背景知識

「タイムアウトを入れる」と言っても、Go の net/http には止める対象の違う 4 つの道具があります。名前が似ているので混ぜて使うと、「入れたつもりで効いていない」状態になります。

道具 何を切るか 切ったときクライアントが見るもの ハンドラは止まるか
Server.ReadHeaderTimeout ヘッダを読み終えるまで 接続が閉じる(応答なし) そもそも始まっていない
Server.WriteTimeout 応答書き込みの期限(接続の期限) 接続が閉じる(EOF、ステータスなし) 止まらない(書こうとして初めてエラー)
http.TimeoutHandler ハンドラの実行時間 503 と指定した本文 止まらない(ctx は切れるが、見ないハンドラは走り続ける)
context.WithTimeout(自作ミドルウェア) ctx の期限だけ 自分で書かない限り何も起きない ctx を見るハンドラだけ止まる

go doc net/http.TimeoutHandler(Go 1.27.1、server.go 約 4000 行目): 「TimeoutHandler supports the Pusher interface but does not support the Hijacker or Flusher interfaces」。つまり TimeoutHandler で包むと SSE(Flush が要る応答)が壊れる。一方で TimeoutHandler は本文をバッファし、期限が来たら 503 を確実に返せる。ctx 方式はバッファしないので Flush は通るが、ハンドラが ctx を見なければ期限を過ぎても待たされる。ここに本物のトレードオフがあります。

Go の設計思想(明示的・小さい部品)からすると、「全部入りのタイムアウト」は標準ライブラリに無く、層ごとに道具を選んで組むのが前提です。

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

実物 star 今日との関係
Go 標準 net/http(golang/go) 139,123 TimeoutHandler・ResponseController・Server.*Timeout の一次情報。本文の行番号は手元の Go 1.27.1 の server.go
go-chi/chi 22,915 middleware.Timeout は ctx に期限を付け、期限切れなら無条件で WriteHeader(504)(middleware/timeout.go。2026-10-03 に master を取得して読んだ。コメントにも「ctx.Done() を select しないと timeout は無視される」とある)。これは今日の R8(Flush 済みの応答に 504 を重ねる)と同じ形。chi で実際にログが出るかは実行していない(読んだだけ)
gin-contrib/timeout 236 gin 向けのタイムアウト(中身は未読。名前と star のみ)

今日の自作が扱う中核概念は、タイムアウトの 4 層すべての「効き方の違い」と、ctx 方式の落とし穴 1 つ(Flush による暗黙の 200 確定)。扱わないもの: 上流(クライアント・LB)側の timeout との整合、http.Client 側の timeout、ctx の期限を DB・gRPC に渡す部分、リトライ予算(「扱わなかった範囲」参照)。

📝 問題

課題

次の要件表を満たすように、budget.go の 2 関数(WithBudget と NoWriteDeadline)を実装してください。main.go(ルートごとの層の割り当て)と main_test.go(8 本)は変えません。スタブ(素通しの 2 関数)のまま go test を回すと、2 本(R2・R6)が落ちます(実測)。残りの 6 本のうち 5 本は「標準ライブラリの挙動を測る実験」でスタブでも通り、R8 は素朴な実装(chi 型)を落とす罠です。

ルート 事情 割り当てた層(main.go)
GET /api/report 新しいハンドラ。ctx を見る。1 秒かかる仕事を 200ms で諦めたい WithBudget(200ms)(自作・バッファなし)
GET /api/legacy 古いハンドラ。ctx を見ない。クライアントだけでも 200ms で解放したい http.TimeoutHandler(200ms)
GET /stream 150ms ごとに Flush する 3 回の応答。Server の WriteTimeout(300ms)より長い NoWriteDeadline
全体 ヘッダだけ送って居座る接続 Server.ReadHeaderTimeout = 300ms
全体 書き込みが滞る接続 Server.WriteTimeout = 300ms

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

  • 問A: /api/legacy が 503 を返した後、1 秒の仕事は止まるか。走り続けるなら、リクエストが積み重なると何が起きるか。
  • 問B: WithBudget(200ms) を ctx を見ない 600 ms のハンドラに付けたら、何 ms 待たされるか。
  • 問C: WithBudget が、Flush だけ呼んで本文を書かないハンドラを包み、その後に期限が切れた。クライアントが見るのは何か。
  • 問D: Server.WriteTimeout だけを設定した場合、遅いハンドラのクライアントは 503 と EOF のどちらを見るか。

準備

mkdir day061 && cd day061
go mod init day061            # モジュールを作る(go 1.27 を指定)
# main.go・budget.go(まずスタブ)・main_test.go を置く
go test -count=1 -timeout 30s ./...        # スタブでは 2 本落ちる
go test -race -count=1 -timeout 30s ./...  # 実装した後は -race でも通ること

ファイル 1: main.go(変えない)

package main

import (
	"fmt"
	"net/http"
	"sync/atomic"
	"time"
)

// work は d だけかかる仕事。aware なら ctx が切れた時点で諦める。done は「仕事が最後まで走ったか」を記録する。
func work(aware bool, d time.Duration, done *atomic.Int32) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if aware {
			select {
			case <-time.After(d):
			case <-r.Context().Done():
				done.Store(2) // 2=ctxで中断
				return
			}
		} else {
			time.Sleep(d)
		}
		done.Store(1) // 1=最後まで走った
		fmt.Fprint(w, "ok")
	})
}

func NewMux(done *atomic.Int32) http.Handler {
	mux := http.NewServeMux()
	// ctx を見る新しいハンドラ: 予算ミドルウェア(バッファなし)
	mux.Handle("GET /api/report", WithBudget(200*time.Millisecond)(work(true, time.Second, done)))
	// ctx を見ない既存ハンドラ: クライアントを待たせないことだけを優先して TimeoutHandler
	mux.Handle("GET /api/legacy", http.TimeoutHandler(work(false, time.Second, done), 200*time.Millisecond, "legacy timeout\n"))
	// 長い応答: WriteTimeout を外す
	mux.Handle("GET /stream", NoWriteDeadline(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		rc := http.NewResponseController(w)
		for i := 0; i < 3; i++ {
			fmt.Fprintf(w, "tick %d\n", i)
			if err := rc.Flush(); err != nil {
				return
			}
			time.Sleep(150 * time.Millisecond)
		}
	})))
	return mux
}

func NewServer(h http.Handler) *http.Server {
	return &http.Server{
		Handler:           h,
		ReadHeaderTimeout: 300 * time.Millisecond,
		WriteTimeout:      300 * time.Millisecond,
	}
}

func main() {
	var done atomic.Int32
	srv := NewServer(NewMux(&done))
	srv.Addr = "127.0.0.1:8099"
	fmt.Println("listening on", srv.Addr)
	_ = srv.ListenAndServe()
}

ファイル 2: budget.go(スタブ。ここを実装する)

package main

import (
	"net/http"
	"time"
)

// TODO: 期限を ctx に付け、期限切れで未送信なら 504。バッファしない。Flush・Unwrap を壊さない。
func WithBudget(d time.Duration) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler { return next }
}

// TODO: このリクエストだけ Server.WriteTimeout を外す。
func NoWriteDeadline(next http.Handler) http.Handler { return next }

ファイル 3: main_test.go(変えない)

package main

import (
	"bufio"
	"errors"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"sync/atomic"
	"testing"
	"time"
)

func start(t *testing.T, h http.Handler, wt time.Duration) *httptest.Server {
	t.Helper()
	ts := httptest.NewUnstartedServer(h)
	ts.Config.ReadHeaderTimeout = 300 * time.Millisecond
	ts.Config.WriteTimeout = wt
	ts.Start()
	t.Cleanup(ts.Close)
	return ts
}

func get(t *testing.T, url string) (int, string, time.Duration, error) {
	t.Helper()
	t0 := time.Now()
	resp, err := http.Get(url)
	if err != nil {
		return 0, "", time.Since(t0), err
	}
	defer resp.Body.Close()
	b, err := io.ReadAll(resp.Body)
	return resp.StatusCode, string(b), time.Since(t0), err
}

// R1: ctx を見ないハンドラでも、クライアントは予算(200ms)+余裕で 503 を受け取る。ただしハンドラは走り続ける。
func TestLegacyTimeoutHandlerReturns503ButWorkContinues(t *testing.T) {
	var done atomic.Int32
	ts := start(t, NewMux(&done), 5*time.Second)
	code, body, el, err := get(t, ts.URL+"/api/legacy")
	if err != nil || code != 503 || body != "legacy timeout\n" {
		t.Fatalf("code=%d body=%q err=%v", code, body, err)
	}
	if el > 500*time.Millisecond {
		t.Fatalf("遅すぎる: %v", el)
	}
	if done.Load() != 0 {
		t.Fatalf("503 を返した時点では仕事は終わっていないはず: %d", done.Load())
	}
	time.Sleep(1100 * time.Millisecond)
	if done.Load() != 1 {
		t.Fatalf("ハンドラは最後まで走るはず(止まらない): %d", done.Load())
	}
	t.Logf("503 まで %v、その後も仕事は完走(done=1)", el)
}

// R2: ctx を見るハンドラは、予算で本当に止まり 504 になる。
func TestBudgetStopsAwareHandler(t *testing.T) {
	var done atomic.Int32
	ts := start(t, NewMux(&done), 5*time.Second)
	code, body, el, err := get(t, ts.URL+"/api/report")
	if err != nil || code != 504 {
		t.Fatalf("code=%d body=%q err=%v", code, body, err)
	}
	if el > 500*time.Millisecond {
		t.Fatalf("遅すぎる: %v", el)
	}
	if done.Load() != 2 {
		t.Fatalf("ctx で中断されたはず: %d", done.Load())
	}
	t.Logf("504 まで %v、仕事は ctx で中断(done=2)", el)
}

// R3: 予算ミドルウェアだけでは、ctx を見ないハンドラは止まらず、クライアントも 1 秒待たされる。
func TestBudgetAloneCannotStopUnawareHandler(t *testing.T) {
	var done atomic.Int32
	h := WithBudget(200 * time.Millisecond)(work(false, 600*time.Millisecond, &done))
	ts := start(t, h, 5*time.Second)
	code, _, el, err := get(t, ts.URL)
	if err != nil {
		t.Fatal(err)
	}
	if el < 600*time.Millisecond {
		t.Fatalf("待たされるはず: %v", el)
	}
	t.Logf("予算 200ms でも %v 待たされた(status=%d)", el, code)
}

// R4: TimeoutHandler の内側では Flush できない。予算ミドルウェアの内側ではできる。
func TestFlushThroughWrappers(t *testing.T) {
	var flushErr atomic.Value
	probe := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		err := http.NewResponseController(w).Flush()
		flushErr.Store(errors.Is(err, http.ErrNotSupported))
		io.WriteString(w, "x")
	})
	ts1 := start(t, http.TimeoutHandler(probe, time.Second, "x"), 5*time.Second)
	get(t, ts1.URL)
	if flushErr.Load() != true {
		t.Fatalf("TimeoutHandler 内の Flush: ErrNotSupported のはず")
	}
	ts2 := start(t, WithBudget(time.Second)(probe), 5*time.Second)
	get(t, ts2.URL)
	if flushErr.Load() != false {
		t.Fatalf("WithBudget 内の Flush: 成功のはず")
	}
}

// R8: Flush 済み(200 確定済み)の応答の途中で期限が切れても、後ろに 504 の本文を継ぎ足さない。
func TestBudgetAfterFlushDoesNotAppend504(t *testing.T) {
	h := WithBudget(100 * time.Millisecond)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.NewResponseController(w).Flush() // 本文なしの Flush だけで 200 が確定する
		select {                              // 期限が付かない実装でもテストが固まらないよう上限を置く
		case <-r.Context().Done():
		case <-time.After(time.Second):
		}
	}))
	ts := start(t, h, 5*time.Second)
	code, body, _, err := get(t, ts.URL)
	if err != nil || code != 200 || body != "" {
		t.Fatalf("code=%d body=%q err=%v", code, body, err)
	}
}

// R5: Server.WriteTimeout は 504/503 ではなく「何も返さず切る」。
func TestServerWriteTimeoutDropsConnection(t *testing.T) {
	var done atomic.Int32
	ts := start(t, work(false, 600*time.Millisecond, &done), 300*time.Millisecond)
	_, _, el, err := get(t, ts.URL)
	if err == nil {
		t.Fatalf("接続が切られるはず")
	}
	t.Logf("%v 後にクライアントはエラー: %v", el, err)
}

// R6: /stream は WriteTimeout(300ms) を越えて 3 回 Flush できる(NoWriteDeadline)。
func TestStreamSurvivesWriteTimeout(t *testing.T) {
	var done atomic.Int32
	ts := start(t, NewMux(&done), 300*time.Millisecond)
	t0 := time.Now()
	resp, err := http.Get(ts.URL + "/stream")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	b, err := io.ReadAll(resp.Body)
	if err != nil || string(b) != "tick 0\ntick 1\ntick 2\n" {
		t.Fatalf("body=%q err=%v", b, err)
	}
	if time.Since(t0) < 400*time.Millisecond {
		t.Fatalf("WriteTimeout を越えて続いたはず")
	}
}

// R7: ヘッダをだらだら送る接続は ReadHeaderTimeout(300ms) で閉じられる。
func TestReadHeaderTimeoutClosesSlowClient(t *testing.T) {
	var done atomic.Int32
	ts := start(t, NewMux(&done), 5*time.Second)
	c, err := net.Dial("tcp", ts.Listener.Addr().String())
	if err != nil {
		t.Fatal(err)
	}
	defer c.Close()
	t0 := time.Now()
	c.Write([]byte("GET /api/report HTTP/1.1\r\nHost: x\r\n")) // 空行を送らない
	c.SetReadDeadline(time.Now().Add(2 * time.Second))
	_, err = bufio.NewReader(c).ReadString('\n')
	el := time.Since(t0)
	var ne net.Error
	if errors.As(err, &ne) && ne.Timeout() {
		t.Fatalf("サーバーが閉じなかった(クライアント側の待ちが切れた)")
	}
	if el > 800*time.Millisecond {
		t.Fatalf("遅い: %v", el)
	}
	t.Logf("%v で閉じられた(err=%v)", el, err)
}

💡 ヒント

ヒント1(方向性)

「期限を付ける」ことと「期限が来たら応答を返す」ことは別の仕事です。前者は ctx、後者はミドルウェアが自分で書く必要があります。そして書いてよいのは、まだ何も送っていないときだけです。

ヒント2(アプローチ)

context.WithTimeout、errors.Is(ctx.Err(), context.DeadlineExceeded)、http.ResponseController(SetWriteDeadline・Flush)。ResponseWriter を包むなら「送信済みか」を覚える。どの呼び出しが送信済みにするかを全部数える(WriteHeader・Write、そしてあと 1 つ)。

ヒント3(コード骨格)
tw := &budgetWriter{ResponseWriter: w} // wrote bool を持つ
// WriteHeader / Write で wrote = true
// FlushError() error { ... }  // Flush は 200 を暗黙に確定させる
// Unwrap() http.ResponseWriter

✅ 模範解答

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

budget.go(50 行、gofmt 済み)。main.go・main_test.go は上のまま。

package main

import (
	"context"
	"errors"
	"net/http"
	"time"
)

// WithBudget は「ctx に期限を付け、期限切れでまだ何も書いていなければ 504 を返す」ミドルウェア。
// バッファしないので Flush が通る。代わりに、ctx を見ないハンドラは止められない。
func WithBudget(d time.Duration) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx, cancel := context.WithTimeout(r.Context(), d)
			defer cancel()
			tw := &budgetWriter{ResponseWriter: w}
			next.ServeHTTP(tw, r.WithContext(ctx))
			if errors.Is(ctx.Err(), context.DeadlineExceeded) && !tw.wrote {
				http.Error(w, "budget exceeded", http.StatusGatewayTimeout)
			}
		})
	}
}

type budgetWriter struct {
	http.ResponseWriter
	wrote bool
}

func (b *budgetWriter) WriteHeader(code int) { b.wrote = true; b.ResponseWriter.WriteHeader(code) }
func (b *budgetWriter) Write(p []byte) (int, error) {
	b.wrote = true
	return b.ResponseWriter.Write(p)
}

// FlushError: Flush は暗黙に 200 を確定させるので、「書いた」扱いにする。
func (b *budgetWriter) FlushError() error {
	b.wrote = true
	return http.NewResponseController(b.ResponseWriter).Flush()
}
func (b *budgetWriter) Unwrap() http.ResponseWriter { return b.ResponseWriter }

// NoWriteDeadline は Server.WriteTimeout をこのリクエストだけ外す(SSE・大きなダウンロード用)。
func NoWriteDeadline(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_ = http.NewResponseController(w).SetWriteDeadline(time.Time{})
		next.ServeHTTP(w, r)
	})
}

🪜 Step-by-Step 解説

Step 1: 道具ごとの「止める対象」を実測する(R1・R3・R5・R7)

実験 結果(実測。Go 1.27.1 darwin/arm64、-race)
R1 /api/legacy(TimeoutHandler、1 秒の仕事) クライアントは 206 ms で 503+legacy timeout。その時点で仕事は未完了、1.1 秒後に 完走(done=1)
R3 WithBudget(200ms) を ctx を見ない 600 ms のハンドラに クライアントは 602 ms 待たされ 200 を受け取る(予算は何も変えない)
R5 WriteTimeout=300ms、600 ms のハンドラ 610 ms 後に Get ...: EOF。503 は届かない
R7 ヘッダを途中までしか送らない接続 311 ms で閉じられた(ReadHeaderTimeout=300ms)

「クライアントを待たせない」ことと「サーバーの仕事を止める」ことは別です。R1 は前者だけ満たす。ハンドラが走り続けるので、遅い依存先があると goroutine と DB 接続が積み上がる(積み上がる規模は測っていない)。後者を満たすのは ctx を見るハンドラだけです。R5 に注意: WriteTimeout は 600 ms 経った後に切れており、ハンドラの側は最後まで走る。

Step 2: ctx を見るハンドラは予算で本当に止まる(R2)

/api/report は 209 ms で 504、仕事は ctx.Done() で中断(done=2)。実サーバーの curl でも 504 0.277s(最初の接続を含む)。/api/legacy は curl で 503 0.205s、本文 legacy timeout。

Step 3: 「期限が来たら 504」は、送信済みでないときだけ(R8)

素朴な実装は「期限切れなら WriteHeader(504)」。chi の middleware.Timeout と同じ形です。しかしハンドラが Flush した後は 200 が確定済みで、後から 504 を書いても 2 回目の WriteHeader として捨てられ、ログに superfluous response.WriteHeader call が出る(実測: 最初の版のテストで実際に出た)。http.Error を使えば、本文だけ 200 の応答の後ろに継ぎ足される。

罠は「Flush は本文を書かなくても 200 を確定させる」こと。WriteHeader・Write だけを見張る wrapper は、Flush 経由の確定を見落とす。→ FlushError() を実装して wrote = true にする。ResponseController.Flush は FlushError() error → http.Flusher → Unwrap() の順に探す。この罠は自分の最初の版でも踏み、R8 を足して直した。

Step 4: TimeoutHandler の内側では Flush できない(R4)

ResponseController.Flush() は TimeoutHandler の中で ErrNotSupported、WithBudget の中では成功(テストで検証)。SSE・ストリーミングのルートに TimeoutHandler を付けてはいけない。

Step 5: ストリームだけ WriteTimeout を外す(R6)

Server.WriteTimeout は接続単位の固定期限なので、SSE のような長い応答はサーバー全体の値を伸ばさず、ハンドラ側で SetWriteDeadline(time.Time{}) として外す。/stream は 460 ms(WriteTimeout 300 ms を超えて)3 回 Flush し切った。外さないと 300 ms で切られる(変異 M4 で確認)。

Step 6: テストが本当に効いているかを変異で確かめる

実装を 6 通りに壊して go test を回した(実測)。6 通りすべて検出。

変異 落ちたテスト
M1 FlushError で wrote を立てない R8
M2 wrote を無視して常に 504 R8
M3 ctx に期限を付けない R2
M4 SetWriteDeadline を外さない R6
M5 /api/legacy を TimeoutHandler で包まない R1
M6 FlushError メソッドを消す R8

注意: R7(ReadHeaderTimeout)・R5・R4 は Server 設定と標準ライブラリを測るテストで、自作コードの変異では落ちない(main.go の設定を消せば落ちるはずだが、その変異は試していない)。

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

  • 標準ライブラリは「全部入りのタイムアウト」を持たない。 4 つの道具が別々の層にあり、組み合わせは設計者が決める。Go の「小さい部品を明示的に組む」流儀そのもの。
  • ctx は協調的キャンセル。 goroutine を外から殺す手段が無いので、「止める」は常にハンドラの協力が前提。だから新しいハンドラは ctx を見る契約にし、見ない古いハンドラだけ TimeoutHandler で「クライアントの解放」を担保する二段構えになる。
  • バッファの有無が機能を決める。 TimeoutHandler は本文を溜めるので 503 に差し替えられるが Flush できない。ctx 方式は素通しなので Flush できるが、差し替えられない。「確実な 503」と「ストリーミング」は同時には取れない。

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

他案 採らなかった理由 前提が変わったら
全ルートを TimeoutHandler で包む /stream が壊れる(R4)。遅い仕事は走り続ける(R1) ストリームが無く全ハンドラが ctx を見ないなら、これが最小で最も確実
Server.WriteTimeout だけで済ませる 503 でなく EOF(R5)で、クライアントは原因が分からない。ハンドラも止まらない LB 側に再試行があり、切断で十分なら可
chi の middleware.Timeout をそのまま使う 期限切れで無条件に WriteHeader(504)。Flush 済みの応答に重なる(読んだ範囲。実行未確認) Flush を使わないルートだけなら十分
504 を本文なしの WriteHeader だけにする 運用者が原因を読めない(chi はこの形) ログ・メトリクスに期限切れを別途出すなら可
ハンドラごとに time.AfterFunc で自前に切る ルートごとに書くことになり、書き忘れる —

🌐 他言語との比較

言語/FW 同等の機能 Go との違い
Java(Spring MVC) spring.mvc.async.request-timeout、Tomcat の connectionTimeout スレッド割り込みで止められる場面がある一方、後始末が難しい
Node.js(Express) server.timeout、AbortController 単一スレッドなので「止める」は非同期処理の中止に依存。Go の ctx に近い
Python(asyncio) asyncio.timeout() await 点でしかキャンセルされない点が Go の協調性と同じ

🛠️ 実務での使いどころ

  • 外向き API の入口: ReadHeaderTimeout は必ず設定する(未設定だと ReadTimeout も 0 なら無期限で待つ。slowloris 対策。静的解析の gosec に専用ルールがあるはずだが ID は未確認)。
  • ダウンストリーム呼び出しの予算: ミドルウェアで ctx に期限を付けて DB・gRPC・HTTP クライアントへ渡せば、予算が全体に伝わる(今日は扱わない)。
  • レガシー移行: ctx を見ない古いコードに TimeoutHandler を被せて「クライアントだけ解放」し、ハンドラを順に ctx 対応させる。
  • SSE/ダウンロード: ストリームのルートだけ SetWriteDeadline を外す/伸ばす。

⚠️ よくある誤解・ミス

誤解・ミス なぜ起こるか 正しい理解
「TimeoutHandler を付けたから仕事も止まる」 503 が返るので止まったように見える 返るのはクライアント向けの応答だけ。ハンドラは走り続ける(R1)
「ctx に期限を付ければ自動で切れる」 context.WithTimeout の名前から 見ないハンドラは止まらない。待たされる時間も変わらない(R3)
「WriteTimeout を長くすれば SSE も動く」 サーバー全体の値を触りがち 全ルートの上限が緩む。ストリームだけ SetWriteDeadline で外す(R6)
「期限切れで WriteHeader(504) を足せば十分」 chi にも同じ形がある Flush 済みなら無効。送信済みかを見る(R8)
「Flush は本文があるときだけ 200 を確定する」 Flush という名前から 本文なしでも確定する。wrapper は FlushError も見張る

🚧 扱わなかった範囲

  • クライアント側(http.Client.Timeout、Transport の各 timeout)、LB・プロキシの idle timeout との整合。
  • ReadTimeout・IdleTimeout・MaxHeaderBytes の検証、HTTP/2 での挙動(今日のテストは HTTP/1.1 のみ)。
  • ctx の期限を DB・gRPC・外部 API に伝播する部分、リトライ予算。
  • 負荷時の goroutine 数の推移(R1 で「走り続ける」ことは確認したが、積み上がる規模は測っていない)。
  • chi の Timeout との比較は読解のみ。実行して挙動を再現していない。
  • R3 の予算ミドルウェア単体の待ち時間は 1 回の計測(再現回数は 1 回)。

🚀 次のステップ

  • 発展: ctx の期限を database/sql(QueryContext)に渡し、予算が DB の実行まで届くことを実測する。
  • 次回予告: state.md の予告(同テーマ「デバッグ」)に従う。

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

問A〜D の予想と結果

自分の回答

気づき・メモ