2-5. エラーハンドリング - 「値」としての error、ラップ、panic / recover
所要時間: 40-60分(2セッション分) コミット内容:
~/learn/go/level2/day05/にエラー型設計と recover ミドルウェアの組み合わせ
このレッスンのゴール
-
fmt.Errorf("ctx: %w", err)で意味あるエラーラップが書ける -
errors.Is(値比較)とerrors.As(型取り出し)を使い分けられる - センチネルエラー / カスタムエラー型 / Behavior 3パターンを選定できる
- panic を使ってよい3場面、業務コードでの NG を判断できる
- goroutine 内 panic 対策と「クライアントに内部情報を漏らさない」設計
なぜ学ぶか
「TS の try/catch が無い世界で、どう設計する?」 - Go のエラー設計は 「値で返す」哲学 で、見える・追える・分岐できる代わりに、書くのは冗長。ここを 思想ごと理解 しないと「if err != nil 教」と揶揄される機械的なコードしか書けない。%w ラップ・errors.Is/As・AppError 設計が出来ないと、本番障害解析で詰む。
前章とのつながり
2-4_ミドルウェア の Recover ミドルウェアで panic を捕捉した。本章では そもそも panic を使うべきか、いつ error を使うか の境界設計を学ぶ。1-3_制御構文 の if err != nil { return err } を進化させ、fmt.Errorf("...: %w", err) でコンテキスト付き伝播を会得する。
これができると何が嬉しいか
- 本番障害ログから「どの関数のどの操作で失敗したか」を秒速で特定できる
- クライアントには汎用エラー、ログには詳細 という二層構造が書ける
- goroutine内panicでサーバー落ちる事故を防げる
AppErrorパターン で API エラーが整理される
ストーリー導入: try/catch なき世界の「証跡リレー」
例外がない世界では、エラーは 手紙のリレー で上の階層に伝わる。手紙を渡す時、各階層で「自分のところで何をしようとして、何の手紙を受け取ったか」を追記(fmt.Errorf("loadConfig: open %q: %w", path, err))するのが Go 流。受け取った人は errors.Is で「これは fs.ErrNotExist か?」と判定して分岐する。手紙をちぎって捨てる(%s で潰す) と、源流が分からなくなって障害解析に半日溶ける。
大前提: Go のエラーは「値」である
Java / Python / JavaScript は 例外(exception) でエラーを扱う。throw した瞬間にコールスタックを巻き戻し、catch で受け止める。
Go は そうしない。エラーは普通の 戻り値 として返す:
file, err := os.Open("config.yml")
if err != nil {
return err
}
defer file.Close()設計思想: なぜ Go は例外を採用しなかったか
Go 設計者 (Rob Pike, Ken Thompson, Robert Griesemer) は、例外による「見えない制御フロー」を嫌った。
- どこで例外が投げられるか追えない → 巨大コードベースで把握困難
- try/catch が「キャッチオール」になりやすい → エラーが握り潰される
- コストの隠蔽: try/catch 内で関数呼び出すたびにエラー処理のスタックフレーム確保
Go は「エラーは見えるべき」「処理するか・上に返すか・無視するかを その場で明示するべき」と考えた。これが冗長な
if err != nilの正体。結果: Go のコードは「この関数が失敗し得ること」が一目で分かる。Java の
throws IOExceptionの進化形と言える。
FE出身者へ: TypeScript の Result<T, E> や Rust の Result と発想が近い。「成功か失敗かは値で表現する」哲学。ただし Go の error 型はシンプルすぎる側面もあり、後発の Result 型より粒度が荒いのが弱点。
セッション①: error の基本と「ラップ」(30分)
0. 録画スタート&作業ディレクトリ
mkdir -p ~/log ~/learn/go/level2/day05
cd ~/learn/go/level2/day05
script ~/log/go_level2_day05.log
# Go モジュール初期化
go mod init example.com/level2/day051. error は単なるインターフェース
// 標準ライブラリの定義
type error interface {
Error() string
}たった1メソッドのインターフェース
Error() stringを実装すれば、何でもerror型として扱える。type MyError struct { Code int Msg string } func (e *MyError) Error() string { return fmt.Sprintf("[%d] %s", e.Code, e.Msg) } // これで MyError は error として返せる func doSomething() error { return &MyError{Code: 404, Msg: "not found"} }
2. errors.New と fmt.Errorf
import "errors"
// 静的な文字列のエラー
var err1 = errors.New("file not found")
// 動的に組み立てるエラー
err2 := fmt.Errorf("user %d not found", userID)
// 別のエラーをラップする(Go 1.13+)
err3 := fmt.Errorf("failed to load config: %w", originalErr)
%wと%s/%vの違い
フォーマット 挙動 %s/%vエラー文字列を埋め込むだけ。元エラーは「行方不明」になる %w(wrap)元エラーを 保持しつつ 新しいエラーを作る。 errors.Is/Asで元エラーに辿れるorig := errors.New("connection refused") err := fmt.Errorf("db query failed: %w", orig) // ラップ errStr := fmt.Errorf("db query failed: %s", orig) // ただの文字列結合 errors.Is(err, orig) // true errors.Is(errStr, orig) // false ← 元エラーを辿れない
%wは1つだけ使える(複数ラップは Go 1.20+ のerrors.Joinを使う)。
3. エラーラップの実例
func loadConfig(path string) (*Config, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("loadConfig: open %q: %w", path, err)
}
defer f.Close()
var cfg Config
if err := json.NewDecoder(f).Decode(&cfg); err != nil {
return nil, fmt.Errorf("loadConfig: decode %q: %w", path, err)
}
return &cfg, nil
}
// 呼び元
cfg, err := loadConfig("/etc/app.yml")
if err != nil {
// → "loadConfig: open \"/etc/app.yml\": open /etc/app.yml: no such file or directory"
log.Fatal(err)
}ラップで「コンテキスト」を積む
エラーは どこで何をしようとして失敗したか が分からないと役に立たない。
BAD:
"no such file or directory"だけ → どのファイル? GOOD:"loadConfig: open \"/etc/app.yml\": no such file or directory"→ 何の操作で、どのファイルか分かる「現在の関数名 + 何をしようとしたか + 元エラー」をパターンとして積む。
アンチパターン: ラップせずに文字列結合
return nil, fmt.Errorf("open failed: %s", err) // ← %w じゃなく %sなぜNG:
errors.Is(err, os.ErrNotExist)のような 型チェック / センチネル比較が使えなくなる。エラーハンドリングのチェーンが切れる。修正: 上に返すときは必ず
%wでラップ。握りつぶす意図がない限り%w。
4. errors.Is - センチネルエラーの比較
import (
"errors"
"io/fs"
)
_, err := os.Open("nonexistent.txt")
if errors.Is(err, fs.ErrNotExist) {
fmt.Println("ファイルなし")
}errors.Is は「ラップを潜り抜けて比較」
エラーをラップしても、
errors.Is(wrapped, target)で 元のtargetと一致するか を判定できる。動作:
wrappedを順にUnwrap()していき、targetと一致するものがあれば true。e1 := errors.New("base") e2 := fmt.Errorf("layer1: %w", e1) e3 := fmt.Errorf("layer2: %w", e2) errors.Is(e3, e1) // true
5. センチネルエラーの定義パターン
// パッケージレベルで公開する「特定のエラー値」
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidInput = errors.New("invalid input")
ErrUnauthorized = errors.New("unauthorized")
)
func GetUser(id int) (*User, error) {
if id <= 0 {
return nil, ErrInvalidInput
}
// ...
if /* DB に見つからなかった */ {
return nil, ErrUserNotFound
}
}
// 呼び元
u, err := GetUser(42)
switch {
case errors.Is(err, ErrUserNotFound):
http.NotFound(w, r)
case errors.Is(err, ErrInvalidInput):
http.Error(w, "bad request", 400)
case err != nil:
http.Error(w, "internal error", 500)
}センチネルエラーのユースケース
「エラーの 種類 で分岐したい」場面で使う。
例:
io.EOF- ストリームの終わりsql.ErrNoRows- DB クエリで行が無いcontext.Canceled/context.DeadlineExceeded- コンテキスト関連fs.ErrNotExist/fs.ErrPermission- ファイルシステム共通点: 値の同一性で判定できる「分岐用エラー」。
センチネルエラーの過剰使用に注意
センチネルだらけにすると、エラーごとに変数を export することになり API が肥大する。
「呼び元が型/種別で分岐したいエラー」だけセンチネル化する。それ以外は普通の
errors.Newかfmt.Errorfで十分。
6. errors.As - エラー「型」での取り出し
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
// 関数
func validateEmail(s string) error {
if !strings.Contains(s, "@") {
return &ValidationError{Field: "email", Message: "must contain @"}
}
return nil
}
// 呼び元
err := validateEmail("bad")
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("validation failed: field=%s msg=%s\n", ve.Field, ve.Message)
// ↑ 型の中身(Field, Message)にアクセスできる
}errors.Is と errors.As の使い分け
関数 用途 比較対象 errors.Is(err, target)特定の値との一致を見る センチネル変数 errors.As(err, &dst)特定の型へ取り出す エラー構造体ポインタ 覚え方: 「Is は値、As は型」。
型の中身にアクセスしたい(フィールド値で分岐 / エラーメッセージ整形)なら
As。単に「このエラーかどうか」を見たいならIs。
7. Behavior インターフェース(Dave Cheney流)
// 「振る舞い」でエラーを判定するパターン
type Temporary interface {
Temporary() bool
}
func isTemporary(err error) bool {
var t Temporary
if errors.As(err, &t) {
return t.Temporary()
}
return false
}
// HTTP のネットワークエラーは net.OpError などが Temporary() を持つ
if isTemporary(err) {
// リトライ
}Behavior インターフェース という設計
「エラーは型ではなく 振る舞い で分類する」考え方。Dave Cheney(Go 界の論客)が提唱。
- エラーの具体型に依存しない(疎結合)
Temporary() bool、Retryable() bool、StatusCode() intのようなメソッドを足せる- 標準ライブラリの
netパッケージや AWS SDK が採用弱点: 1.13 以降の
errors.Is/Asの方が主流。Behavior は 追加機能 として理解すれば OK。
セッション②: panic / recover とセキュリティ(30分)
8. panic とは何か
func divide(a, b int) int {
return a / b // b が 0 なら panic
}
func main() {
result := divide(10, 0) // panic: integer divide by zero
fmt.Println(result) // ここには到達しない
}panic は「異常終了の合図」
panic はプログラムが「もう続行不可能」と判断した時に発生する。
- ゼロ除算
- nil ポインタ dereference
- スライスの範囲外アクセス
- 型アサーション失敗(
v.(string)で型違い)panic("...")の明示呼び出しpanic が起きると defer された関数を実行しつつスタックを巻き戻し、最終的にプログラムが落ちる。
9. panic は「制御フロー」ではない
アンチパターン: panic でエラー処理する
// NG: 通常のエラーで panic func getUser(id int) *User { u, err := db.Find(id) if err != nil { panic(err) // ← これはダメ } return u }なぜNG: Go のエラーは 値で返す のが文化。panic は予期せぬバグ(プログラマの想定外)にのみ使う。
errで済むものを panic させると:
- 呼び元がエラーハンドリングを忘れる
- ログ・観測・リトライの仕組みが効かない
- 全体的にコントロール不能になる
修正:
return u, errでエラーを上に伝える。
10. panic / recover を使ってよい場面
panic を「許容してよい」3つの場面
プログラム起動時の致命的エラー
cfg, err := loadConfig() if err != nil { log.Fatal(err) // log.Fatal は os.Exit(1) を呼ぶ。panic ではない }起動できないなら panic より
log.Fatalが定石。絶対起こり得ない条件(コードのバグの証)
switch x.Kind() { case A: ... case B: ... default: panic(fmt.Sprintf("unexpected kind: %v", x.Kind())) }プロセス境界での recover(HTTP サーバー、gRPC サーバー) 1リクエストの panic で サーバー全体を落とさない ために、リクエスト境界で recover する。
11. recover - panic の捕捉
func safeCall() {
defer func() {
if rec := recover(); rec != nil {
fmt.Println("recovered:", rec)
}
}()
panic("boom")
}
func main() {
safeCall() // panic を吸収して通常終了
fmt.Println("continue")
}recover の使い方の鉄則
- 必ず
defer内で呼ぶ(直接呼ぶと nil が返るだけ)recover()は同じ goroutine の panic のみ捕捉(別 goroutine の panic は届かない)- 「キャッチオール」は本来禁忌。境界(HTTP リクエスト / ジョブ単位)に限定する
12. goroutine 内の panic 問題
// NG: この panic は recover で捕まらない
func handler(w http.ResponseWriter, r *http.Request) {
go func() {
// バックグラウンド処理
panic("oops") // ← サーバー全体が落ちる!
}()
w.Write([]byte("ok"))
}goroutine 内の panic は親 goroutine では捕捉できない
別 goroutine で panic すると どこにも捕捉されず、プロセス全体が落ちる。サーバー全体が落ちる事故の典型。
対策: バックグラウンド goroutine の中に 自分で recover を仕込む:
go func() { defer func() { if rec := recover(); rec != nil { slog.Error("background panic", "err", rec, "stack", string(debug.Stack())) } }() // バックグラウンド処理 }()内部ヘルパー関数
goSafe(fn func())を用意してプロジェクト全体で使うのがベスト。
13. セキュリティ: エラーメッセージに内部情報を漏らさない
エラーメッセージ漏洩は実害のあるセキュリティ事故
NG例1: SQL 構造を漏らす
// クライアントに返す http.Error(w, fmt.Sprintf("query failed: %v", err), 500) // → "query failed: pq: column \"password_hash\" of relation \"users\" does not exist" // → 攻撃者が「users テーブルに password_hash カラムがある」と知るNG例2: ファイルパス漏らす
http.Error(w, err.Error(), 500) // → "open /var/secrets/db.key: permission denied" // → 攻撃者がパス構造を知るNG例3: スタックトレース漏らす
http.Error(w, fmt.Sprintf("%v\n%s", err, debug.Stack()), 500) // → 関数名、ファイルパス、ライブラリバージョンが全て見える
正しい二重構造
func handler(w http.ResponseWriter, r *http.Request) { err := doSomething() if err != nil { // サーバー側ログには詳細を残す slog.Error("operation failed", "err", err, "user_id", userID, "request_id", requestID, ) // クライアントには汎用エラーだけ http.Error(w, "internal server error", http.StatusInternalServerError) return } }鉄則: 「ログには全部、クライアントには最小限」。
14. errors.Join (Go 1.20+) - 複数エラーをまとめる
// 複数の検証エラーを集める
func validate(u User) error {
var errs []error
if u.Name == "" {
errs = append(errs, errors.New("name is required"))
}
if u.Email == "" {
errs = append(errs, errors.New("email is required"))
}
if u.Age < 0 {
errs = append(errs, errors.New("age must be >= 0"))
}
return errors.Join(errs...)
}
err := validate(u)
if err != nil {
fmt.Println(err)
// name is required
// email is required
// age must be >= 0
}errors.Join の利用シーン
- 入力バリデーションで複数フィールドのエラーを返す
- 並列処理で複数の goroutine から返ったエラーを集める
- クリーンアップで複数の defer エラーをまとめる
Go 1.20 で正式追加。それ以前は
multierrorライブラリを使っていた。
15. 実装: 構造化エラー + recover ミドルウェア
// errors.go
package main
import (
"errors"
"fmt"
"net/http"
)
// AppError - HTTP ステータスと公開メッセージを持つ構造化エラー
type AppError struct {
StatusCode int
Message string // クライアントに返す安全な文字列
Internal error // 内部詳細(ログ用、クライアントには出さない)
}
func (e *AppError) Error() string {
if e.Internal != nil {
return fmt.Sprintf("[%d] %s: %v", e.StatusCode, e.Message, e.Internal)
}
return fmt.Sprintf("[%d] %s", e.StatusCode, e.Message)
}
func (e *AppError) Unwrap() error {
return e.Internal
}
// 専用コンストラクタ
func NotFound(msg string) *AppError {
return &AppError{StatusCode: 404, Message: msg}
}
func BadRequest(msg string, internal error) *AppError {
return &AppError{StatusCode: 400, Message: msg, Internal: internal}
}
func Internal(internal error) *AppError {
return &AppError{StatusCode: 500, Message: "internal server error", Internal: internal}
}
// HTTP に応答する関数
func WriteError(w http.ResponseWriter, r *http.Request, err error) {
logger := LoggerFromCtx(r.Context())
var appErr *AppError
if errors.As(err, &appErr) {
if appErr.StatusCode >= 500 {
logger.Error("server error", "err", err)
} else {
logger.Warn("client error", "err", err)
}
http.Error(w, appErr.Message, appErr.StatusCode)
return
}
// AppError 以外は 500 として扱う(内部情報は出さない)
logger.Error("unhandled error", "err", err)
http.Error(w, "internal server error", 500)
}
// 使う側
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
u, err := db.FindUser(id)
if errors.Is(err, sql.ErrNoRows) {
WriteError(w, r, NotFound("user not found"))
return
}
if err != nil {
WriteError(w, r, Internal(fmt.Errorf("FindUser %s: %w", id, err)))
return
}
json.NewEncoder(w).Encode(u)
}練習課題
fmt.Errorf("%w", err)でラップし、errors.Isでラップ後でもセンチネル比較できることを確認- カスタムエラー型
*ValidationErrorを作り、errors.Asでフィールド取り出し errors.Joinで3つの検証エラーをまとめて返す- goroutine 内で panic させてサーバー全体が落ちることを再現 → recover を仕込んで防ぐ
AppError構造で「クライアント向けメッセージ」と「ログ向け詳細」を分離した HTTP API を実装- 思考課題:
errors.Isとerrors.Asのどちらを使うべきか、3つのシチュエーションで判定
締め: git で証跡を残す
cd ~/learn/go/level2/day05
git add .
git commit -m "feat(go-errors): AppError構造化・errors.Is/As使い分け・recover境界設計"
exitアンチパターン集 - やらかし事例
エラー処理の事故
1.
%sでエラー文字列だけ埋め込むreturn fmt.Errorf("load failed: %s", err) // ← %w に!
errors.Is/Asでラップを潜れなくなる。チェーン分断。2. エラーを
_で握りつぶすdata, _ := os.ReadFile(path) // 失敗時 nil でアクセスしてpanic後で必ず NPE 系の派生事故が起きる。
3. panic でエラー処理
if err != nil { panic(err) }業務ロジックでは絶対NG。
return errで上に伝える。4. goroutine内の panic を放置
go func() { panic("oops") }() // ← サーバー全体落ちる必ず
defer recover()を仕込む。goSafe(fn)ヘルパー作るのが定石。5. クライアントに SQL/パス情報を返す
http.Error(w, err.Error(), 500) // 内部漏洩ログに詳細、クライアントには汎用 の二層構造。
対比表で違いを明確化
errors.Is vs errors.As
観点 errors.Is(err, target)errors.As(err, &dst)比較 値の同一性 型一致 用途 センチネル分岐 構造体のフィールド取得 例 errors.Is(err, sql.ErrNoRows)var ve *ValidationError; errors.As(err, &ve)覚え方 Is は値 As は型
エラー設計パターンの選び分け
種類 用途 例 センチネル ( errors.New)種類で分岐 ErrUserNotFoundカスタム型 ( type MyErr struct {...})詳細情報を載せる *ValidationError(Field, Message)Behavior ( interface { Temporary() bool })振る舞いで分類 リトライ可否判定 AppError(構造化)HTTP との対応 StatusCode + Message + Internal
panic vs error vs log.Fatal
用途 推奨 業務ロジックの失敗 return err起動時の致命的問題 log.Fatalプログラマの想定外バグ panicHTTP リクエスト境界 defer recover()バックグラウンド goroutine goSafe(fn)で recover
自己評価チェックリスト
手を動かせた
-
fmt.Errorf("...: %w", err)で意味あるラップ -
errors.Isでセンチネル比較 -
errors.Asで型取り出し - カスタムエラー型 (
*ValidationError) を定義しUnwrap()実装 -
errors.Joinで複数エラーを集約 -
AppErrorで StatusCode + Message + Internal の3層構造
説明できる
-
errorインターフェース定義を空で書ける -
%wと%sの違い - panic を使ってよい3場面、NG な場面
- goroutine 内 panic がプロセス落とす理由
- 「ログには詳細、クライアントには汎用」の鉄則
やらかし回避
-
_でエラー握り潰しを避ける - goroutine 内に必ず
defer recover()仕込む - エラーメッセージで SQL/パス情報を漏らさない
- 業務ロジックで panic を使わない
詰まった時のチートシート
| やりたいこと | コード |
|---|---|
| 静的エラー | errors.New("msg") |
| 動的エラー | fmt.Errorf("xxx %d", n) |
| ラップ | fmt.Errorf("ctx: %w", err) |
| 値で比較 | errors.Is(err, ErrSentinel) |
| 型で取り出し | var e *MyErr; errors.As(err, &e) |
| 複数まとめる | errors.Join(err1, err2) |
| panic 捕捉 | defer func() { recover() }() |
| クライアント向け汎用エラー | http.Error(w, "internal error", 500) |
「実務OK」基準
- エラーをラップする時に必ず関数名 + 操作 +
%wを入れる - センチネル / 型 / Behavior の使い分けを語れる
- panic を「予期せぬバグ」専用と認識し、エラー処理に panic を使わない
- HTTP ハンドラで内部情報を漏らさないエラーレスポンスが書ける
- goroutine 内に必ず recover を仕込む癖がある
さらに深掘りするなら
- 公式 blog: Error handling and Go / Go 1.13 errors
- Dave Cheney: “Don’t just check errors, handle them gracefully” - Behavior インターフェース提唱記事
- Rob Pike: “Errors are values” (2015) - 「
if err != nilが冗長と感じたら設計を見直せ」の本家記事 - 標準ライブラリ:
errors/wrap.go- Unwrap / Is / As の実装 - Go 1.20 リリースノート: errors.Join 追加の経緯
次のレッスン
2-6 パッケージ設計 でコードを「分けるべき所で分ける」訓練に進む。handler / service / repository のレイヤード設計と、internal/ の使い方、循環依存の避け方まで。
つながりの予告
- 本章の
AppErrorは次章で handler/service/repository の各層 での使い分けに発展 errors.Is(err, sql.ErrNoRows)は 3-1_database_sql で頻繁に登場recover設計は 3-7_本番準備 の Graceful Shutdown と合体