フェーズ: 上級者 (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 の予告(同テーマ「デバッグ」)に従う。