フェーズ: 上級者 (Phase 3) | 問題タイプ: デバッグ・コードレビュー | テーマ: ミドルウェア設計パターン
Day 069 の次回予告どおり「デバッグ」。Day 069 で作ったAdapt(error を返すハンドラ →http.Handler)は単体では 13 本のテストを通った。今日はそれを Day 064 の Recover・Day 063 の Observe・「誰のリクエストか」を添える中間件と本番と同じ順に積んだときに出るバグを探す。Adapt自体は正しいまま渡す(触らない)。
📚 背景知識(読んでから問題へ)
Day 069 の Adapt は、ハンドラが 200 を送り始めた後に error を返すと panic(http.ErrAbortHandler) で接続を切りました。途中で切れた CSV を、クライアントに「完全な 200」として保存させないためです。
この panic は意図した切断です。ところが外側には「panic を見たら 500 にする」Recover や、「最後に何番だったかを数える」Observe がいます。外側の部品は、この panic が事故なのか意図なのかを知りません。部品をそれぞれ単体でテストしても、積んだときの約束(誰が何を 1 回だけやるか) は誰のテストにも入っていません。今日のバグは全部そこにあります。
積み方は本番と同じです(外から順に)。
Observe(集計)→ Recover(panic → 500)→ Adapt(error → 番号、Day 069)→ AnnotateE(user を添える)→ ハンドラ
世界の実物はどうしているか(一次資料・実測)
| 実物 | panic(http.ErrAbortHandler) を見たら | 送信後の本物の panic | 確認方法 |
|---|---|---|---|
net/http(Go 1.27.1 手元 src/net/http/server.go) | conn.serve の recover() が err != ErrAbortHandler のときだけスタックを出す(1936 行)。ErrAbortHandler は「aborts the response to the client」(1905〜1909 行) | スタックをログに出して接続を切る | ソースを読んだ |
chi v5.3.2(middleware/recoverer.go、手元のモジュールキャッシュ) | if rvr == http.ErrAbortHandler { panic(rvr) }(26〜29 行)。投げ直す。コメントは "we don't recover http.ErrAbortHandler so the response to the client is aborted, this should not be logged" | w.WriteHeader(http.StatusInternalServerError)(40 行)。送信済みかを見ない | ソースを読み、実物を動かして測った(下の実測 3) |
| Echo の Recover 中間件 | ErrAbortHandler を投げ直す、と記憶している | — | 今回ソース未取得・記憶のみ |
| 「error は 1 回だけ扱う」 | Dave Cheney「Don't just check errors, handle them gracefully」(2016、https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully )が「ログに出してから返す」を二重処理として戒めている | — | 今回 URL を開いていない・記憶のみ |
chi は ErrAbortHandler の投げ直しはしていますが、送信後の本物の panic では WriteHeader(500) を呼ぶだけです。これが何を起こすかは、今日の実測 3 で chi 本体を動かして確かめます。
📝 問題
状況
CSV エクスポート(/export)を Day 069 の Adapt に載せ替えた翌週、3 件の報告が来ました。
- 営業部:「ダウンロードした CSV の最後の行が
internal errorになっていることがある。ブラウザはエラーを出さなかった」 - SRE:「可用性ダッシュボードは 99.0%。でも問い合わせの件数と合わない。モバイルで途中で閉じた人も成功に入っている気がする」
- SRE:「エラーログの件数が、ダッシュボードの 5xx 件数のちょうど 2 倍ある」
mw.go に 5 つのバグがあります(types.go・adapt.go は Day 069 のまま正しい)。
課題
go test -race -count=1 ./...の 8 本(T1〜T8)がすべて通るように、mw.goだけを直してください(Stackの積み順は変えない)- 5 つのバグそれぞれについて「どの報告の原因か/どのテストが捕まえるか」を 1 行で
- 短く答えてください(自己評価欄へ)
・D1: Observe の「切断を数えない」バグ(B4)だけを直しても T5 は通りません。なぜか。何を一緒に直すと通るか
・D2: chi v5.3.2 のRecovererは B1 は踏んでいません。では B2 はどうか。クライアントに何が届くと予想するか(実測 3 で答え合わせ)
準備
mkdir d70 && cd d70 # 作業ディレクトリを作って入る
printf 'module d70\n\ngo 1.22\n' > go.mod # モジュールを作る(標準ライブラリだけ)
# 下の types.go・adapt.go・mw.go(バグ入り)・mw_test.go を保存する
go test -count=1 ./... 2>&1 | grep '^--- FAIL' # バグ入りで落ちるテスト(5 本: T1 T2 T4 T5 T6)
go test -race -count=1 -v ./... # 修正後: 8 本すべて PASS
ファイル 1: types.go(Day 069 と同じ。変えない)
types.go(47 行)
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(Day 069 の模範解答そのまま。変えない)
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)
})
}
ファイル 3: mw.go(バグ入り。ここを直す)
package main
import (
"fmt"
"net/http"
)
// Record は Observe が 1 リクエストにつき 1 回報告する集計の 1 行。
// Status は「クライアントに送った(送るはずだった)」番号。499 は相手が先に切った。
// Aborted は接続を途中で切った(クライアントは応答を受け取り損ねた)。
type Record struct {
Status int
Aborted bool
}
// Sinks は記録の出口。どれも 1 件につき 1 回だけ呼ばれる約束。
type Sinks struct {
Err LogFunc // ハンドラが返した error
Panic func(r *http.Request, p any) // 回収した panic
Rec func(Record) // 集計(SLO・ダッシュボード)
}
// Stack は本番と同じ積み方。外から Observe → Recover → Adapt → AnnotateE → h。
func Stack(h HandlerE, s Sinks) http.Handler {
return Observe(s.Rec)(Recover(s.Panic)(Adapt(ChainE(h, AnnotateE(s.Err)), s.Err)))
}
// obsWriter は最初に送った番号だけを覚える。
type obsWriter struct {
http.ResponseWriter
status int
}
func (o *obsWriter) WriteHeader(c int) {
if o.status == 0 && c >= 200 {
o.status = c
}
o.ResponseWriter.WriteHeader(c)
}
func (o *obsWriter) Write(b []byte) (int, error) {
if o.status == 0 {
o.status = http.StatusOK
}
return o.ResponseWriter.Write(b)
}
func (o *obsWriter) Unwrap() http.ResponseWriter { return o.ResponseWriter }
// Observe は 1 リクエストにつき 1 回 Record を報告する。panic が通り抜けても数える(defer)。
func Observe(sink func(Record)) 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}
defer func() {
st := ow.status
if st == 0 {
st = http.StatusOK // 何も書かなければ net/http が 200 を送る
}
sink(Record{Status: st})
}()
next.ServeHTTP(ow, r)
})
}
}
// Recover は panic を回収して 500 を返し、記録する。
func Recover(logPanic func(*http.Request, any)) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if p := recover(); p != nil {
logPanic(r, p)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
// AnnotateE は失敗に「誰のリクエストか」を添えて記録する(問い合わせ対応のため)。
func AnnotateE(log LogFunc) MiddlewareE {
return func(next HandlerE) HandlerE {
return func(w http.ResponseWriter, r *http.Request) error {
err := next(w, r)
if err != nil {
log(r, StatusOf(r, err), fmt.Errorf("user=%s: %w", r.Header.Get("X-User"), err))
}
return err
}
}
}
ファイル 4: mw_test.go(変えない。T1〜T8 とベンチマーク)
mw_test.go 全体(242 行)
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// sinks は 3 つの出口に来たものを全部覚える(実サーバーのハンドラ goroutine から呼ばれるので Mutex)。
type sinks struct {
mu sync.Mutex
errs []string
panics []string
recs []Record
}
func (s *sinks) S() Sinks {
return Sinks{
Err: func(r *http.Request, st int, err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.errs = append(s.errs, fmt.Sprintf("%d %v", st, err))
},
Panic: func(r *http.Request, p any) {
s.mu.Lock()
defer s.mu.Unlock()
s.panics = append(s.panics, fmt.Sprint(p))
},
Rec: func(rc Record) {
s.mu.Lock()
defer s.mu.Unlock()
s.recs = append(s.recs, rc)
},
}
}
func (s *sinks) snap() ([]string, []string, []Record) {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.errs...), append([]string(nil), s.panics...), append([]Record(nil), s.recs...)
}
// serve は実サーバー(実 TCP)に 1 回投げ、ステータス・本文・読み取りエラーを返す。
// srv.Close はハンドラの終了を待つので、戻った時点で sinks は出そろっている。
func serve(t *testing.T, h http.Handler, user string) (int, string, error) {
t.Helper()
srv := httptest.NewServer(h)
defer srv.Close()
req, _ := http.NewRequest("GET", srv.URL, nil)
req.Header.Set("X-User", user)
res, err := http.DefaultClient.Do(req)
if err != nil {
return 0, "", err
}
defer res.Body.Close()
b, err := io.ReadAll(res.Body)
return res.StatusCode, string(b), err
}
// streamThenFail は CSV エクスポートの形: 1 行書いて送り出した後に DB が失敗する。
func streamThenFail(w http.ResponseWriter, r *http.Request) error {
io.WriteString(w, "id,name\n1,alice\n")
http.NewResponseController(w).Flush() // ここでステータス 200 と 1 行目が相手に届く(chunked)
return errors.New("rows.Next: driver: bad connection")
}
// T1: 送信後の error。クライアントは「完全な 200」を受け取ってはいけない(接続が切れる)。
// Adapt の panic(http.ErrAbortHandler) は意図した切断なので、panic としては記録しない。
func TestT1_StreamErrorReachesClientAsFailure(t *testing.T) {
s := &sinks{}
code, body, err := serve(t, Stack(streamThenFail, s.S()), "alice")
_, panics, _ := s.snap()
t.Logf("T1: status=%d body=%q readErr=%v panics=%q", code, body, err, panics)
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("読み取りが %v(本文 %q)。途中の CSV が成功として保存される", err, body)
}
if len(panics) != 0 {
t.Fatalf("意図した切断が panic として記録された: %q", panics)
}
}
// T2: 送信後の本物の panic(nil map 等)。これもクライアントに完全な 200 を渡さない。panic の記録は 1 回。
func TestT2_PanicAfterCommit(t *testing.T) {
s := &sinks{}
h := Stack(func(w http.ResponseWriter, r *http.Request) error {
io.WriteString(w, "id,name\n1,alice\n")
http.NewResponseController(w).Flush()
var m map[string]int
m["x"]++ // panic: assignment to entry in nil map
return nil
}, s.S())
code, body, err := serve(t, h, "alice")
_, panics, _ := s.snap()
t.Logf("T2: status=%d body=%q readErr=%v panics=%d", code, body, err, len(panics))
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("読み取りが %v(本文 %q)。panic した応答が成功として届いた", err, body)
}
if len(panics) != 1 || !strings.Contains(panics[0], "nil map") {
t.Fatalf("panic の記録 %q; want 1 件", panics)
}
}
// T3: 何も送る前の panic は 500 と固定文言。集計も 500。(基準線。最初から通る)
func TestT3_PanicBeforeCommit(t *testing.T) {
s := &sinks{}
h := Stack(func(w http.ResponseWriter, r *http.Request) error {
panic("boom: secret=hunter2")
}, s.S())
code, body, err := serve(t, h, "alice")
_, panics, recs := s.snap()
if err != nil || code != 500 || body != "internal error\n" || len(panics) != 1 ||
len(recs) != 1 || recs[0] != (Record{Status: 500}) {
t.Fatalf("got %d %q err=%v panics=%d recs=%v; want 500 \"internal error\\n\" 1 [{500 false}]", code, body, err, len(panics), recs)
}
}
// countWriter は WriteHeader と Write が呼ばれたかを数える。
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 }
// T4: 相手が先に切った(r.Context() が終わっている)。Adapt は何も書かない。集計は 499 で、200 ではない。
func TestT4_ClientGoneIsNot200(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
r := httptest.NewRequest("GET", "/report", nil).WithContext(ctx)
cw := &countWriter{h: http.Header{}}
s := &sinks{}
Stack(func(w http.ResponseWriter, r *http.Request) error {
return fmt.Errorf("query: %w", r.Context().Err())
}, s.S()).ServeHTTP(cw, r)
_, _, recs := s.snap()
if cw.headers != 0 || cw.wrs != 0 || len(recs) != 1 || recs[0] != (Record{Status: StatusClientClosed}) {
t.Fatalf("headers=%d writes=%d recs=%v; want 0 0 [{499 false}]", cw.headers, cw.wrs, recs)
}
}
// T5: 送信後の error で接続を切った応答は、集計で「成功」に数えない(Aborted=true)。
func TestT5_AbortedIsCountedAsAborted(t *testing.T) {
s := &sinks{}
serve(t, Stack(streamThenFail, s.S()), "alice")
_, _, recs := s.snap()
if len(recs) != 1 || recs[0] != (Record{Status: 200, Aborted: true}) {
t.Fatalf("recs=%v; want [{200 true}]", recs)
}
}
// T6: error の記録は 1 件につき 1 回。誰のリクエストかはその 1 回に入っている。番号は 404 のまま。
func TestT6_ErrorLoggedOnceWithUser(t *testing.T) {
for _, c := range []struct {
err error
code int
}{
{fmt.Errorf("repo.Get(42): %w", ErrNotFound), 404},
{errors.New("pq: connection refused"), 500},
} {
s := &sinks{}
code, _, err := serve(t, Stack(func(w http.ResponseWriter, r *http.Request) error { return c.err }, s.S()), "alice")
errs, _, recs := s.snap()
if err != nil || code != c.code || len(errs) != 1 || !strings.Contains(errs[0], "user=alice") ||
len(recs) != 1 || recs[0].Status != c.code {
t.Fatalf("err=%v: got %d errs=%q recs=%v; want %d、記録 1 件(user=alice 入り)", c.err, code, errs, recs, c.code)
}
}
}
// T7: 成功は素通し。記録なし、集計は 200。(基準線)
func TestT7_SuccessUntouched(t *testing.T) {
s := &sinks{}
code, body, err := serve(t, Stack(func(w http.ResponseWriter, r *http.Request) error {
io.WriteString(w, "ok")
return nil
}, s.S()), "alice")
errs, panics, recs := s.snap()
if err != nil || code != 200 || body != "ok" || len(errs)+len(panics) != 0 || len(recs) != 1 || recs[0] != (Record{Status: 200}) {
t.Fatalf("got %d %q err=%v errs=%q panics=%q recs=%v", code, body, err, errs, panics, recs)
}
}
// T8: 同じハンドラを 200 本同時に。リクエストごとの状態が混ざらない(-race で)。
func TestT8_Concurrent(t *testing.T) {
s := &sinks{}
errs := []error{nil, ErrNotFound, &StatusError{Code: 409, Msg: "conflict"}, errors.New("x")}
want := []int{200, 404, 409, 500}
h := Stack(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]
}, s.S())
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()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", fmt.Sprintf("/x?i=%d", n%4), nil))
if rec.Code != want[n%4] {
bad <- fmt.Sprintf("i=%d got %d", n%4, rec.Code)
}
}(n)
}
wg.Wait()
close(bad)
if n := len(bad); n > 0 {
t.Fatalf("%d 本が違うステータス(例: %s)", n, <-bad)
}
if _, _, recs := s.snap(); len(recs) != 200 {
t.Fatalf("集計 %d 件; want 200", len(recs))
}
}
func benchServe(b *testing.B, h http.Handler) {
r := httptest.NewRequest("GET", "/x", nil)
b.ReportAllocs()
for i := 0; i < b.N; i++ {
h.ServeHTTP(httptest.NewRecorder(), r)
}
}
func okHandler(w http.ResponseWriter, r *http.Request) error { io.WriteString(w, "ok"); return nil }
var nop = Sinks{Err: func(*http.Request, int, error) {}, Panic: func(*http.Request, any) {}, Rec: func(Record) {}}
func BenchmarkAdaptOnly(b *testing.B) { benchServe(b, Adapt(okHandler, nop.Err)) }
func BenchmarkStackOK(b *testing.B) { benchServe(b, Stack(okHandler, nop)) }
func BenchmarkStackNotFound(b *testing.B) {
benchServe(b, Stack(func(w http.ResponseWriter, r *http.Request) error { return ErrNotFound }, nop))
}
バグ入りのまま実行したときの出力(Go 1.27.1、2026-10-10 実測)
--- FAIL: TestT1_StreamErrorReachesClientAsFailure
T1: status=200 body="id,name\n1,alice\ninternal error\n" readErr=<nil> panics=["net/http: abort Handler"]
--- FAIL: TestT2_PanicAfterCommit
T2: status=200 body="id,name\n1,alice\ninternal error\n" readErr=<nil> panics=1
--- FAIL: TestT4_ClientGoneIsNot200
headers=0 writes=0 recs=[{200 false}]; want 0 0 [{499 false}]
--- FAIL: TestT5_AbortedIsCountedAsAborted
recs=[{200 false}]; want [{200 true}]
--- FAIL: TestT6_ErrorLoggedOnceWithUser
got 404 errs=["404 user=alice: repo.Get(42): not found" "404 repo.Get(42): not found"] recs=[{404 false}]
http: superfluous response.WriteHeader call from d70.(*obsWriter).WriteHeader (mw.go:38)
💡 ヒント
ヒント1(方向性)
3 つの部品に 1 つずつ問いを立ててください。Recover:「この panic は事故か、Adapt がわざと投げたものか」「もう 200 を送ったか」。Observe:「何も書かれていないのは、200 だからか、相手がもういないからか」「panic が自分を通り抜けたか」。AnnotateE:「この error を記録するのは誰の仕事か」。
ヒント2(アプローチ)
- Recover は
p == http.ErrAbortHandlerなら記録せずpanic(p)で投げ直す(chi と同じ) - Recover が「送信済み」を知るには、Adapt と同じく w を包む必要がある。
adapt.goのcommitWriterがそのまま使える(同じパッケージ) - 送信後の panic には 500 を書けない。Adapt と同じく
panic(http.ErrAbortHandler)に変える - Observe の
deferの中でrecover()すると「通り抜けた」が分かる。数えたら必ず投げ直す(投げ直さないと net/http が応答を正常終了させ、完全な 200 が届く) - 相手が切ったかは
r.Context().Err() != nil(Day 069 R5 と同じ判断を、Observe でももう一度する) - AnnotateE は記録せず、
fmt.Errorf("user=%s: %w", …)で包んで返すだけにする。%wでないとStatusOfが番兵を見失う
ヒント3(コード骨格)
// Recover
cw := &commitWriter{ResponseWriter: w} // リクエストごと
defer func() {
p := recover()
// nil → return
// p == http.ErrAbortHandler → panic(p)(記録しない)
// logPanic(r, p)
// cw.status != 0 → panic(http.ErrAbortHandler)
// それ以外 → http.Error(cw, "internal error", 500)
}()
next.ServeHTTP(cw, r)
// Observe の defer
p := recover()
rec := Record{Status: ow.status}
// p != nil → Aborted / Status 0 かつ r.Context().Err() != nil → 499 / Status 0 → 200
sink(rec)
// p != nil → panic(p)
✅ 模範解答
5 つのバグ
| # | 場所 | バグ | 報告 | 捕まえるテスト |
|---|---|---|---|---|
| B1 | Recover | http.ErrAbortHandler も事故として回収し、500 を書く。Adapt の「意図した切断」が打ち消され、internal error が CSV の末尾に足された完全な 200 になる。panic ログにも 1 件入る | 1 | T1 |
| B2 | Recover | 送信済みかを見ずに http.Error。ハンドラ自身が 200 を送った後に panic しても、同じく完全な 200+internal error | 1 | T2 |
| B3 | Observe | 何も書かれていなければ常に 200。相手が切って Adapt が何も書かなかった要求(Day 069 の 499)を成功に数える | 2 | T4 |
| B4 | Observe | defer で数えているが panic を見ない。Adapt が接続を切った応答を {200, Aborted:false}=成功に数える | 2 | T5(B1 か B2 を直した後で) |
| B5 | AnnotateE | 「ログに出してから返す」。Adapt も同じ error を記録するので、error 1 件がログ 2 行になる(片方には user が無い) | 3 | T6 |
修正後の mw.go(109 行。バグ入りとの差分 44 行)
package main
import (
"fmt"
"net/http"
)
// Record は Observe が 1 リクエストにつき 1 回報告する集計の 1 行。
// Status は「クライアントに送った(送るはずだった)」番号。499 は相手が先に切った。
// Aborted は接続を途中で切った(クライアントは応答を受け取り損ねた)。
type Record struct {
Status int
Aborted bool
}
// Sinks は記録の出口。どれも 1 件につき 1 回だけ呼ばれる約束。
type Sinks struct {
Err LogFunc // ハンドラが返した error
Panic func(r *http.Request, p any) // 回収した panic
Rec func(Record) // 集計(SLO・ダッシュボード)
}
// Stack は本番と同じ積み方。外から Observe → Recover → Adapt → AnnotateE → h。
func Stack(h HandlerE, s Sinks) http.Handler {
return Observe(s.Rec)(Recover(s.Panic)(Adapt(ChainE(h, AnnotateE()), s.Err)))
}
// obsWriter は最初に送った番号だけを覚える。
type obsWriter struct {
http.ResponseWriter
status int
}
func (o *obsWriter) WriteHeader(c int) {
if o.status == 0 && c >= 200 {
o.status = c
}
o.ResponseWriter.WriteHeader(c)
}
func (o *obsWriter) Write(b []byte) (int, error) {
if o.status == 0 {
o.status = http.StatusOK
}
return o.ResponseWriter.Write(b)
}
func (o *obsWriter) Unwrap() http.ResponseWriter { return o.ResponseWriter }
// Observe は 1 リクエストにつき 1 回 Record を報告する。panic が通り抜けても数える(defer)。
func Observe(sink func(Record)) 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}
defer func() {
p := recover() // 通り抜ける panic を一度止めて「切れた」と数え、必ず投げ直す
rec := Record{Status: ow.status}
switch {
case p != nil:
rec.Aborted = true // ここまで届く panic は接続の切断(ErrAbortHandler)
case rec.Status == 0 && r.Context().Err() != nil:
rec.Status = StatusClientClosed // 相手が先に切った。何も送っていない
case rec.Status == 0:
rec.Status = http.StatusOK // 何も書かなければ net/http が 200 を送る
}
sink(rec)
if p != nil {
panic(p)
}
}()
next.ServeHTTP(ow, r)
})
}
}
// Recover は panic を回収して 500 を返し、記録する。
func Recover(logPanic func(*http.Request, any)) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cw := &commitWriter{ResponseWriter: w} // Adapt と同じ見張り役。リクエストごとに作る
defer func() {
p := recover()
if p == nil {
return
}
if p == http.ErrAbortHandler {
panic(p) // 意図した切断。記録せずに net/http へ渡す(chi Recoverer と同じ)
}
logPanic(r, p)
if cw.status != 0 {
panic(http.ErrAbortHandler) // もう 200 を送った。500 は書けないので切る
}
http.Error(cw, "internal error", http.StatusInternalServerError)
}()
next.ServeHTTP(cw, r)
})
}
}
// AnnotateE は失敗に「誰のリクエストか」を添えて上へ返す。記録はしない(記録は Adapt の 1 か所)。
func AnnotateE() MiddlewareE {
return func(next HandlerE) HandlerE {
return func(w http.ResponseWriter, r *http.Request) error {
err := next(w, r)
if err != nil {
return fmt.Errorf("user=%s: %w", r.Header.Get("X-User"), err) // %w で包むので StatusOf は変わらない
}
return nil
}
}
}
実行結果(Go 1.27.1、2026-10-10、M1 MacBook Pro 8GB)
$ go test -race -count=1 -v ./... 2>&1 | grep -E '^(---|ok)|T1:|T2:'
mw_test.go:79: T1: status=200 body="id,name\n1,alice\n" readErr=unexpected EOF panics=[]
--- PASS: TestT1_StreamErrorReachesClientAsFailure (0.00s)
mw_test.go:100: T2: status=200 body="id,name\n1,alice\n" readErr=unexpected EOF panics=1
--- PASS: TestT2_PanicAfterCommit (0.00s)
--- PASS: TestT3_PanicBeforeCommit (0.00s)
--- PASS: TestT4_ClientGoneIsNot200 (0.00s)
--- PASS: TestT5_AbortedIsCountedAsAborted (0.00s)
--- PASS: TestT6_ErrorLoggedOnceWithUser (0.00s)
--- PASS: TestT7_SuccessUntouched (0.00s)
--- PASS: TestT8_Concurrent (0.01s)
ok d70 1.525s
修正後は http: superfluous response.WriteHeader call の警告も出なくなる(バグ入りでは T1・T2 で 1 行ずつ出ていた)。
🪜 Step-by-Step 解説
Step 1: B1——Recover が「意図した切断」を事故として扱う
Adapt は 200 を送った後の error で panic(http.ErrAbortHandler) を投げます。バグ入りの Recover はこれを recover() で受け止め、http.Error(w, "internal error", 500) を書いて正常に戻ります。正常に戻ったハンドラの応答を、net/http は chunked の終端まで送って完了させます。結果、クライアントには id,name\n1,alice\ninternal error\n が読み取りエラーなしの 200 として届きます(実測。報告 1 の正体)。
修正は chi と同じく、ErrAbortHandler なら記録せずに投げ直すことです。panic は Recover を素通りして net/http の conn.serve に届き、そこで接続が切られます(ログも出ない。server.go 1936 行)。
Step 2: B2——送信後の本物の panic も同じ穴に落ちる
B1 を直しても、ハンドラ自身の panic(nil map への代入など)が 200 を送った後に起きると、Recover は同じく http.Error を書いて完全な 200 にします(T2)。Recover は送信済みかを知らないからです。
Recover にも Adapt と同じ見張り役 commitWriter を被せ、送信済みなら panic を記録したうえで panic(http.ErrAbortHandler) に変えます。「記録してから切る」順番に注意してください(切ってからでは記録の行に届かない)。
B1 と B2 は別の修正です。B2 だけ直すと、Adapt の ErrAbortHandler も「送信済みの panic」として切断にはなる(クライアントは正しく失敗を受け取る)が、panic ログに net/http: abort Handler が 1 件入り、T1 が落ちます(実測。意図した切断でアラートが鳴る)。
Step 3: B3——「何も書いていない」には 2 つの意味がある
net/http はハンドラが何も書かずに戻ると 200 を送ります。だからバグ入りの Observe の if st == 0 { st = 200 } は、普段は正しいのです。ところが Day 069 の Adapt は、相手が先に切った(r.Context().Err() != nil)ときにわざと何も書きません(499)。Observe から見ると両者は同じ「0」です。
Observe でも r.Context().Err() を見て 499 に分けます。同じ判断を Adapt と Observe の 2 か所でするのは重複に見えますが、Observe は Adapt を知らない外側の部品なので、番号以外の手がかり(ctx)を自分で見るしかありません(別案は下の「迷った他案」)。
Step 4: B4——通り抜けた panic を数え、必ず投げ直す
Observe の defer は panic が通り抜けても動くので「数え漏れ」はありません(Day 064 B4 の修正済み)。ただし ow.status は 200 のままなので、接続を切った応答を成功として数えます。defer の中で recover() して Aborted: true を付け、数えた後に panic(p) で投げ直します。投げ直しを忘れると(変異 M1)、Observe が panic を飲み込んで正常終了し、B1 と同じ「完全な 200」に戻ります。
B4 は B1 に隠されていた: バグ入りの Recover が ErrAbortHandler を飲み込むので、Observe まで panic が届きません。B4 だけ直しても T5 は落ちたままです(実測 1 の表)。
Step 5: B5——ログに出してから返す
AnnotateE は「問い合わせ対応で誰のリクエストか分かるように」user 付きで記録し、error も上へ返していました。上の Adapt も記録するので、error 1 件がログ 2 行になります(報告 3。片方には user が無い)。
修正は「記録は Adapt の 1 か所」と決め、AnnotateE は情報を error に足して返すだけにすることです。%w で包むので StatusOf の errors.Is(err, ErrNotFound) はそのまま効き、Adapt の 1 行に user=alice が入ります。%v で包むと(変異 M5)番兵が見えなくなり 404 が 500 に、相手の切断も 499 でなく 500 になります(T4・T6・T8 が落ちる)。
📏 実測(予想と外れた所を含む)
1. どのバグを直すと、どのテストが通るか(直した組み合わせ別)
| 直したバグ | 落ちるテスト |
|---|---|
| なし | T1 T2 T4 T5 T6 |
| B1 だけ | T2 T4 T5 T6 |
| B2 だけ | T1 T4 T5 T6 |
| B3 だけ | T1 T2 T5 T6 |
| B4 だけ | T1 T2 T4 T5 T6(何も変わらない) |
| B5 だけ | T1 T2 T4 T5 |
| B1+B4 | T2 T4 T6 |
| B2+B4 | T1 T4 T6 |
| B1+B2+B3+B5(B4 だけ残す) | T5 |
| B2+B3+B4+B5(B1 だけ残す) | T1 |
| B1+B3+B4+B5(B2 だけ残す) | T2 |
| すべて | なし(-race -count=3 も ok) |
- 予想と外れた所: B4 を直せば T5 は通ると思っていた。実際は B1 が Adapt の panic を飲み込むので、B4 の修正は何も変えない。B1 か B2 のどちらかを直すと T5 が通る(B2 だけでも、送信済みの panic を ErrAbortHandler に変えて投げるので Observe まで届く)
- 「B1 だけ残す」と T1 だけが落ちる。このときクライアントは正しく
unexpected EOFを受け取るが、panic ログにnet/http: abort Handlerが入る(意図した切断で panic アラートが鳴る)
2. 変異 12 通り(修正後の mw.go に 1 か所ずつ入れる。-race 付き)
| 変異 | 検出したテスト |
|---|---|
| M1 Observe が panic を投げ直さない | T1, T2 |
| M2 Recover が next に(cw でなく)w を渡す | T2 |
| M3 Observe の「0 → 200」を 499 判定より先に | T4 |
| M4 ErrAbortHandler の判定を logPanic の後に | T1 |
M5 AnnotateE が %v で包む | T4, T6, T8 |
| M6 AnnotateE が user を足さずに元の err を返す | T6 |
| M7 obsWriter が最後の WriteHeader を覚える | 生存 |
| M8 obsWriter の Unwrap を消す | T1, T2 |
| M9 Recover の commitWriter を合成時に 1 個だけ作る | T8(-race) |
| M10 Observe が 499 に直さず Status 0 のまま報告 | T4 |
| M11 Recover が送信後でも http.Error を書く(B2 に戻す) | T2 |
| M12 Observe が panic 時も Aborted=false(B4 に戻す) | T5 |
- M7 は生存。修正後の積み方では 2 回目の
WriteHeaderを呼ぶ部品がもう無いので、「最初の番号を覚える」と「最後の番号を覚える」の差が出ない。ただしバグ入り(B1)のときは Recover のhttp.Errorが 2 回目のWriteHeader(500)を呼んでいたので、M7 の Observe ならクライアントは 200 なのにダッシュボードは 500 という逆向きの食い違いになる(ここは今回テストしていない。推論)。今日のテストは「線の上の番号」と「数えた番号」の一致を、2 回目の WriteHeader がある状況では見張っていない - M8(Unwrap なし)は Flush が効かず、
row1が送信されないまま Adapt が切るので、クライアントは本文でなく応答そのものを受け取れない(unexpected EOFではなくGet ...: EOF)。T1 はこれを別の失敗として捕まえる - 最初の採点で M6 が「生存」と出たが、変異の書き方で
fmtが未使用になりビルドが失敗していただけだった。書き直して T6 で検出を確認した
3. chi v5.3.2 の Recoverer を実際に動かす(D2 の答え合わせ)
手元のモジュールキャッシュの github.com/go-chi/chi/v5@v5.3.2 を GOPROXY=off で使い、io.WriteString(w, "id,name\n1,alice\n") の後に panic するハンドラを middleware.Recoverer で包んで実 TCP で取得した。
| panic の値 | Flush してから | 結果(status / 本文 / 読み取りエラー) |
|---|---|---|
| nil map への代入 | する | 200 / "id,name\n1,alice\n" / なし。サーバーに superfluous response.WriteHeader call from ...Recoverer.func1.1 (recoverer.go:40) |
| nil map への代入 | しない | 200 / "id,name\n1,alice\n" / なし(本文が小さく Content-Length 付きで正常に送られた) |
| http.ErrAbortHandler | する | 200 / "id,name\n1,alice\n" / unexpected EOF |
| http.ErrAbortHandler | しない | 応答なし / Get "http://127.0.0.1:…": EOF |
- chi は B1 を踏んでいない(投げ直す)が、B2 と同じ形: 送信後の本物の panic で、クライアントは途中の本文を完全な 200 として受け取る。
http.ErrorでなくWriteHeaderだけなのでinternal errorは足されない分、壊れたことがもっと見えない - 予想と外れた所: 「Flush しない」場合は本文がまだ手元にあるので、chi なら 500 にできると思っていた。実際は
Writeの時点で 200 が確定しており(Day 069 の commitWriter と同じ考え方を net/http もしている)、WriteHeader(500)は無視されて 200 で完了した - 今日の修正版 Recover では、どちらの場合も切断(Flush ありは unexpected EOF、なしは応答なし)になる
4. ダッシュボードの数字がどれだけずれるか(配分は仮定、数え方は実行結果)
1000 リクエストを Stack に流した。配分は仮定(成功 940・相手が切る 30・ストリーム途中の失敗 20・500 が 10)。可用性 = 成功 ÷(全体 − 499)、成功 = 5xx でなく Aborted でもないもの、とした。
| 集計の中身 | エラーログ行数 | 可用性 | |
|---|---|---|---|
| バグ入り | {200}: 990、{500}: 10 | 120 | 99.00%(990/1000) |
| 修正後 | {200}: 940、{200, Aborted}: 20、{499}: 30、{500}: 10 | 60 | 96.91%(940/970) |
- 報告 2 の「99.0%」は、相手が切った 30 件と途中で切れた 20 件を成功に数えた結果だった。報告 3 の「ちょうど 2 倍」は B5(120 = 60 × 2)
- 499 を分母から外すかどうかは SLO の定義次第(下の「迷った他案」)。どちらの定義でも、Aborted を成功に数えないことは変わらない
5. 割り当て(決定的)と時間(参考)
バグ入り 修正後
BenchmarkAdaptOnly 1040 B / 11 allocs 1040 B / 11 allocs (Adapt 単体。比較の基準)
BenchmarkStackOK 1064 B / 12 allocs 1088 B / 13 allocs (+24 B・+1 alloc = Recover の commitWriter)
BenchmarkStackNotFound 1176 B / 18 allocs 1192 B / 18 allocs
時間は修正後 StackOK 1,503〜2,057 ns、AdaptOnly 1,543〜1,916 ns。計測中にロードアベレージが 2.8 から 17〜21 に上がった(M1 8GB で他の処理が並走)ので、時間は比較に使わない。根拠は割り当ての差(成功時 +1 alloc / 24 B)だけ。
⚖️ 迷った他案と採らなかった理由
| 論点 | 採った案 | 迷った他案と採らなかった理由 | 前提が変わったら |
|---|---|---|---|
| Recover が送信済みを知る方法 | Recover も commitWriter で w を包む(+1 alloc) | Adapt の commitWriter を ctx で共有: allocs は増えないが、Recover が Adapt の存在を前提にする(Adapt を外したルートで壊れる)。http.ResponseController で調べる: 標準に「送信済みか」を問う API は無い | すべてのルートが必ず Adapt を通る構成に固定できるなら、ctx 共有で 1 alloc 減らせる |
| 送信後の panic | 記録してから panic(http.ErrAbortHandler) | chi 型の WriteHeader(500): 実測 3 のとおり完全な 200 が届く。何もしない: 同じ | — |
| Observe の 499 の判断 | Observe 自身が r.Context().Err() を見る | Adapt が 499 を ctx 経由で Observe に知らせる: 判断が 1 か所になるが、Observe と Adapt が互いを知る結合になる。Adapt の無いルート(素の http.Handler)で相手が切った場合を数えられない | 観測の部品を 1 つの社内パッケージにまとめ、全ルートが同じ入口を通るなら、ctx 経由の方がよい |
| Observe が panic を見る方法 | defer 内で recover() して数え、投げ直す | Recover の内側に置いて panic を見ない: Recover が 500 を書いた後の番号は見えるが、Recover より外の切断は見えない。順番の議論は Day 065 | — |
| 誰が error を記録するか | Adapt の 1 か所。中間件は %w で情報を足して返す | AnnotateE が記録し、Adapt は記録しない: Adapt を通らない経路(素のハンドラ)で記録が抜ける。slog の属性を ctx に積む: 標準的だが今日の LogFunc の形を変える必要がある | 構造化ログ(slog)に移るなら、user は ctx の属性として 1 回だけ付ける方が自然 |
| 499 を SLO の分母に入れるか | 外す(クライアントの都合) | 入れる: モバイルの切断が多い日に SLO が落ちる。ただし「遅すぎて相手が諦めた」切断も外れる | p99 遅延の SLO を別に持っていないなら、499 の急増を別アラートにする |
🧭 設計思想・なぜこう書くのか
- panic は Go では「通常の失敗の運び方」ではない。だからこそ net/http は「接続を切る」という 1 つの意味だけを
http.ErrAbortHandlerという番兵の panic に割り当て、conn.serveで特別扱いしています。外側の中間件がこの番兵を握りつぶすと、Go の標準ライブラリが用意した唯一の「途中で失敗を伝える手段」が消えます - 小さい部品を組み合わせる Go の流儀には、組み合わせの約束が要る。
func(http.Handler) http.Handlerは型の上では何でも積めますが、「panic を投げ直す」「1 回だけ記録する」「最初の番号を覚える」は型に出ません。今日のバグはどれも単体テストでは見えず、Stackを通した 8 本で初めて見えました - エラーは 1 回だけ扱う: 「記録する」と「返す」はどちらも error の扱いで、両方やると二重になります。Go の
%wは「情報を足して返す」を安く書けるので、記録は端(Adapt)の 1 か所に寄せられます
🌐 他言語との比較
| 言語・FW | 送信後の例外・失敗 | 二重ログの起きやすさ | 今日の Go との違い |
|---|---|---|---|
| chi(Go、実測) | ErrAbortHandler は投げ直す。本物の panic は WriteHeader(500) で完全な 200 | Recoverer 自身は 1 回 | B2 と同形(実測 3) |
| Express(Node) | 既定のエラーハンドラは res.headersSent なら接続を閉じる、と公式ガイドにある(記憶・未確認) | next(err) 前に console.error する書き方が多い | 「閉じる」側で、今日の修正と同じ向き |
| Spring MVC(Java) | 例外は @ExceptionHandler へ。コミット済みなら応答を変えられない(未確認) | フレームワークも例外をログに出すので、自前でも出すと二重になりやすい(記憶) | 例外は自動で上がるので「投げ直し忘れ」(M1)は起きにくい |
| Python(Starlette/FastAPI) | ストリーミング中の例外は接続切断になる(未確認) | — | — |
Go では panic も error も明示的に渡し直さないと止まるので、Java・Python で自動的に起きる「上へ伝わる」を、中間件ごとに panic(p) や return err で書く必要があります。M1(投げ直し忘れ)はこの差から来る Go 特有のバグです。
🏭 実務での使いどころ
- 既存の chi アプリに Adapt を入れるとき:
middleware.Recovererは B1 は大丈夫だが B2 の形(実測 3)。ストリーミング応答(CSV・JSON Lines・SSE)があるなら、今日の Recover に差し替えるか、送信済みならpanic(http.ErrAbortHandler)を投げる薄い包みを足す - ダッシュボードの監査: 「エラーログ件数 ÷ 5xx 件数」が 1 から離れていないかを週次で見る。2 に近ければ二重記録(B5)、1 より小さければ記録漏れ
- SLO 定義: Observe に Aborted と 499 を別のラベルで出す。Aborted は失敗に数え、499 は分母から外すかどうかをチームで決めて文書に残す
- コードレビューの観点:
recover()を書いた差分には「ErrAbortHandler を投げ直しているか」「送信済みを見ているか」「数えた後に投げ直しているか」の 3 点を必ず聞く
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| Recover はすべての panic を 500 にすればよい | panic=事故と思い込む | http.ErrAbortHandler は意図した切断。投げ直す(B1) |
送信後でも http.Error すれば失敗が伝わる | ステータスが先に送られることを忘れる | 本文の末尾に internal error が足された完全な 200 になる(B2・実測) |
| chi の Recoverer を使っていれば安全 | ErrAbortHandler の扱いだけ見て判断する | 送信後の本物の panic は完全な 200(実測 3) |
| 何も書いていなければ 200 | net/http の既定動作だけを見る | 相手が切った要求も「何も書いていない」。ctx で区別する(B3) |
| defer で数えれば panic も正しく数えられる | 「数え漏れ」だけを気にする | 番号は 200 のまま。panic を見て Aborted を付ける(B4) |
recover() したら仕事は終わり | 例外言語の catch の感覚 | 数えるだけの部品は必ず panic(p) で投げ直す(M1) |
| ログに出してから返すと安心 | どこで記録されるか分からない不安 | 二重記録になる。情報は %w で足し、記録は 1 か所(B5) |
🚧 扱わなかった範囲と理由
| 範囲 | 理由 |
|---|---|
| Echo の Recover 中間件 | 手元のモジュールキャッシュに無く、取得しなかった。表の Echo の行は記憶 |
| Dave Cheney の記事本文 | 今回 URL を開いていない。主張は記憶に基づく要約 |
| HTTP/2 での切断の見え方 | Day 069 と同じく未測定(RST_STREAM になると記憶) |
| M7(最後の番号を覚える)を捕まえるテスト | 修正後の積み方では 2 回目の WriteHeader が起きないので、テストを足す対象が無い。バグ入りと組み合わせた挙動は推論のみ |
| slog・OpenTelemetry での記録 | 今日の LogFunc・Record の形に絞った。属性を ctx に積む設計は次回の候補 |
| Recover より外側(RequestID・Auth など)を含めた積み順 | Day 065 で決めた順を前提にし、今日は Observe・Recover・Adapt・AnnotateE の 4 段だけを見た |
| 時間の比較 | 計測中にロードアベレージが 17〜21 に上がった。割り当てだけを根拠にした |
| Express・Spring・FastAPI の挙動 | 「記憶・未確認」と書いた行は、今回ソースも実行も確認していない |
➡️ 次のステップ
- 発展: Recover と Adapt の 2 つの
commitWriterを 1 つにまとめる(ctx で共有)と allocs が 1 減るが、Adapt の無いルートで Recover が壊れないことをどうテストするか - 次回予告: 同テーマで「設計・アーキテクチャ」——error・panic・集計の 3 つの出口(ログ/メトリクス/トレース)を、どの部品が 1 回だけ記録するかの表として決める。slog の属性・otelhttp の span・Record の対応、499 と Aborted を SLO にどう入れるかを含める