メインコンテンツにスキップ

【Go言語】エラー処理の落とし穴!TDDで暴いたサイレント障害と、その確実な対処法

😱 最も恐ろしい「エラーが起きてもエラーにならない」バグ

システム開発で何よりも恐ろしいのは、エラーが発生してもそれが報告されず、システムが静かに誤動作し続ける**「サイレント障害」**です。

ある日、僕たちは自分たちのアプリケーションの「エラー処理を担当する部分」に、まさにそのサイレント障害が潜んでいることに気づきました。

どこが悪かったの?問題のコードを見てみよう

これは、何か問題が起きたときに、クライアント(フロントエンドなど)に「内部エラーが起きました」と伝えるためのコードです。

// 危険な実装: pkg/middleware/error_handler.go

// createInternalErrorResponse は、内部エラー用のレスポンスを作ります
func (e *ErrorHandlerMiddleware) createInternalErrorResponse(message string, cause error) events.APIGatewayProxyResponse {
    // エラーの詳細情報を作成
    errorResponse := ErrorResponseData{ ... }

    // 1. エラー情報をJSONに変換
    jsonData, _ := json.Marshal(errorResponse)  // 👈 問題はココ!エラーを無視している!

    // 2. JSONをレスポンスとして返す
    return events.APIGatewayProxyResponse{
        StatusCode: http.StatusInternalServerError,
        Body:       string(jsonData), // もしjsonDataが空だったら…?
    }
}

jsonData, _ := json.Marshal(...)_ (ブランク修飾子) に注目してください。これは、json.Marshalが返す可能性のあるエラーを意図的に無視するという書き方です。

問題点リストアップ:

  1. エラー処理の最後の砦が崩壊: エラーを処理するはずの場所で、もしエラーが起きても誰もそれに気づくことができません。
  2. 完全なサイレント障害: json.Marshalが失敗すると、jsonDataは空(nil)になってしまいます。その結果、クライアントには空っぽのレスポンスが返され、一体何が起こったのか全く分からなくなってしまうんです。
  3. デバッグが超困難: ログにも残らず、エラーも起きないので、問題の根本原因を突き止めるのが非常に難しくなります。
  4. 二次災害の発生: 予期しないレスポンスを受け取ったフロントエンドが、さらなるバグを引き起こす可能性があります。

どんな時に json.Marshal は失敗するの?

例えば、Goのchan(チャンネル)型のように、JSONに変換できないデータ構造を誤ってエラー情報に含めてしまった場合などに発生します。

// json.Marshalが失敗する例
type UnsafeStruct struct {
    Channel chan int `json:"channel"` // チャンネルはJSONにできない!
}

// このようなデータを含むエラーを作ってしまうと…
err := errors.NewError(...).WithDetails(&UnsafeStruct{...})

// ↓ error_handler.go の中で json.Marshal が失敗!
// ↓ でもエラーは無視される…
// ↓ クライアントには空っぽのレスポンスが返る 😱

これは、火事のときに火災報知器が壊れているようなもので、非常に危険な状態です。

🚀 TDDで、この見えない爆弾を安全に処理しよう!

このサイレント障害を修正するために、今回も**TDD(テスト駆動開発)**を使います。

TDDの計画:

  1. Red: json.Marshalが失敗する状況を意図的に作り出し、現在のコードが正しく動かないことを証明するテストを書いていきます。
  2. Green: テストをパスさせるために、json.Marshalのエラーをちゃんとチェックし、万が一失敗しても絶対に安全なレスポンスを返す「フォールバック機能」を実装する。
  3. Refactor: コードを整理し、他の部分に同様の問題がないか確認する。

サイクル1:🔴 Red - 脆弱性を暴くテストを書く

まず、このサイレント障害を「見える化」するためのテストを書きます。

テスト1: json.Marshalが失敗するエラーを処理させてみる

// TDD Red Phase: TestNewErrorHandlerMiddleware_HandleErrorWithUnsafeDetails
// 安全でない詳細情報を含むエラーの処理をテストする
func TestNewErrorHandlerMiddleware_HandleErrorWithUnsafeDetails(t *testing.T) {
    // 準備:エラーハンドラーを用意
    logger := slog.New(...)
    errorHandler := NewErrorHandlerMiddleware(logger, true)

    // わざとJSONにできないデータを作る
    unsafeStruct := &struct {
        Channel chan int `json:"channel"`
    }{
        Channel: make(chan int),
    }
    // そのデータを含むエラーを生成
    errWithUnsafeDetails := pocErrors.NewError(...).WithDetails(unsafeStruct).Build()

    // 実行:問題の関数でエラーを処理させる
    response := errorHandler.HandleError(errWithUnsafeDetails)

    // 検証:どんな状況でも、レスポンスは必ず有効なJSONであるべき!
    assert.Equal(t, 500, response.StatusCode)
    assert.NotEmpty(t, response.Body, "レスポンスボディが空であってはならない")

    var parsedBody interface{}
    // レスポンスボディがJSONとしてパースできることを確認
    err := json.Unmarshal([]byte(response.Body), &parsedBody)
    assert.NoError(t, err, "レスポンスは常に有効なJSONであるべき")
}

実行結果:テストは「成功」してしまうが、問題はログに現れる!

$ go test -v ./pkg/middleware
=== RUN   TestNewErrorHandlerMiddleware_HandleErrorWithUnsafeDetails
time=... level=ERROR msg="Failed to marshal error response" error="json: unsupported type: chan int"
--- PASS: TestNewErrorHandlerMiddleware_HandleErrorWithUnsafeDetails (0.00s)

テスト自体はassertに引っかからずPASSしてしまいましたが、ログにはjson: unsupported type: chan intというエラーが出力されていました。これはつまり、json.Marshalが失敗したにもかかわらず、そのエラーが握りつぶされてしまい、テストでは検知できなかった、ということを示しています。

これで、問題が確かに存在することが証明できました。

サイクル2:🟢 Green - テストを通すための安全策を実装する

次に、この問題を解決するための最小限のコードを書いていきましょう。

1. json.Marshalのエラーをちゃんとチェックする

// 改善後: 適切なエラーハンドリング
func (e *ErrorHandlerMiddleware) createInternalErrorResponse(...) events.APIGatewayProxyResponse {
    // ... (エラーレスポンスの準備)

    jsonData, err := json.Marshal(errorResponse)
    // ちゃんとエラーをチェック!
    if err != nil {
        // もしJSON変換に失敗したら、ログに記録し、
        // 「最後の砦」であるフォールバック処理を呼び出す
        e.logger.Error("緊急事態: 内部エラーレスポンスのJSON変換に失敗", "error", err)
        return e.createFallbackResponse() // 👈 新しい安全策
    }

    // 成功した場合は、通常通りレスポンスを返す
    return events.APIGatewayProxyResponse{ ... }
}

2. 「最後の砦」フォールバック機能を実装する

createFallbackResponseは、どんな状況でも絶対に失敗しない、事前に用意された静的なJSON文字列を返す関数です。

// createFallbackResponse は、JSON変換すら失敗したときの最後の砦
func (e *ErrorHandlerMiddleware) createFallbackResponse() events.APIGatewayProxyResponse {
    // このJSON文字列は、絶対にパースエラーを起こさないように作られている
    staticSafeResponse := `{
        "success": false,
        "error": {
            "code": "INTERNAL_SYSTEM_ERROR",
            "message": "A critical system error occurred. Please contact support."
        }
    }`

    return events.APIGatewayProxyResponse{
        StatusCode: http.StatusInternalServerError,
        Headers:    map[string]string{"Content-Type": "application/json"},
        Body:       staticSafeResponse,
    }
}

これで、たとえエラー処理の途中で予期せぬエラーが起きても、クライアントには必ず一貫したエラーメッセージが返るようになりました。

実行結果:テストが「成功」!

今度こそ、自信を持ってテストを実行できます。

$ go test -v ./pkg/middleware
# ...
PASS
ok      poc-cognite/pkg/middleware  0.006s

すべてのテストがパスしました。サイレント障害は解消され、システムはより堅牢になりました。

サイクル3:🔵 Refactor - 他にも危険な箇所はないか探す

テストが通ったので、安心してコードベース全体を見渡します。 「他にもjson.Marshalのエラーを無視している箇所はないか?」と検索します。

$ grep -r "json\.Marshal" . --include="*.go"
# ... 検索結果 ...

幸い、他の箇所では適切にエラーが処理されていました。これでリファクタリングは完了です。

📊 改善効果はどれくらい?信頼性の劇的向上

今回の修正は、システムの信頼性を劇的に向上させました。

項目改善前改善後
Marshal失敗時❌ サイレント障害エラーを検知&ログ記録
レスポンス❌ 空 or 不正な内容必ず安全なJSONを返す
障害検知❌ 困難(ほぼ不可能)即時検知
ユーザー影響❌ 予期しない動作一貫したエラー体験
信頼性低い非常に高い

🛡️ 実装された「多層防御」戦略

私たちは、エラーハンドリングに「多層防御」の考え方を取り入れました。

  1. 第一防衛ライン: 通常のエラーレスポンスをJSONで返す。
  2. 第二防衛ライン: もしJSON変換に失敗したら、それを検知し、ログに記録する。
  3. 第三防衛ライン(最後の砦): 事前に用意された、絶対に安全な静的JSONレスポンスを返す。

これにより、エラー処理のプロセス自体が失敗するという最悪の事態を防ぎます。

🎓 今回の改善から

1. エラーハンドラーは「絶対に失敗してはならない」

エラーハンドラーは、システムの最後の安全網です。この部分の信頼性は、システム全体の信頼性に直結します。エラーを無視する_(ブランク修飾子)を使う際は、本当にそれが安全か、細心の注意を払う必要があります。

2. サイレント障害はTDDで暴く

目に見えない障害こそ、TDDの出番です。「問題が起きるはず」の状況をテストで作り出すことで、隠れたバグをあぶり出すことができます。

3. 常に「最悪の事態」を想定する

「エラー処理が失敗したらどうするか?」という、最悪の事態を想定したフォールバック機構を用意しておくことが、堅牢なシステムを構築する鍵です。

まとめ:小さな修正がシステムを救う

今回は、json.Marshalのエラーを無視するという、一見すると小さなコードの問題を修正しました。しかし、この修正が、システムのサイレント障害を防ぎ、信頼性を大幅に向上させるという、計り知れないほどの大きな価値をもたらしてくれたのです。

  • 技術的成果: サイレント障害の根絶、多層防御エラーハンドリングの実装
  • プロセス改善: TDDによる隠れたバグの発見と安全な修正
  • 学び: 「エラーを無視しない」という基本原則の再確認と、フォールバックの重要性