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/AsAppError 設計が出来ないと、本番障害解析で詰む。

前章とのつながり

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/day05

1. 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.Newfmt.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() boolRetryable() boolStatusCode() 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つの場面

  1. プログラム起動時の致命的エラー

    cfg, err := loadConfig()
    if err != nil {
        log.Fatal(err)  // log.Fatal は os.Exit(1) を呼ぶ。panic ではない
    }

    起動できないなら panic より log.Fatal が定石。

  2. 絶対起こり得ない条件(コードのバグの証)

    switch x.Kind() {
    case A: ...
    case B: ...
    default:
        panic(fmt.Sprintf("unexpected kind: %v", x.Kind()))
    }
  3. プロセス境界での 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)
}

練習課題

  1. fmt.Errorf("%w", err) でラップし、errors.Is でラップ後でもセンチネル比較できることを確認
  2. カスタムエラー型 *ValidationError を作り、errors.As でフィールド取り出し
  3. errors.Join で3つの検証エラーをまとめて返す
  4. goroutine 内で panic させてサーバー全体が落ちることを再現 → recover を仕込んで防ぐ
  5. AppError 構造で「クライアント向けメッセージ」と「ログ向け詳細」を分離した HTTP API を実装
  6. 思考課題: errors.Iserrors.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
プログラマの想定外バグpanic
HTTP リクエスト境界defer recover()
バックグラウンド goroutinegoSafe(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 と合体