📑

【1日1zenn - day17】エラーハンドリングの概要キャッチアップ

2025/01/10に公開

エラーハンドリングについても、ここまでフワッとした理解で来てしまっている。
hogeがnullだった場合はthrowして、上の階層でcatchして、みたいなのはわかっていつつ、どういう時に500エラーが返るのかとか、そもそもどう考えていくものなのか(全部まとめてcatchすれば良くない??)とかがわかっていません。

なので具体的な使い方と、考え方みたいなのをキャッチアップしていこうと思います。

いつものごとく、記事を読んでメモっていきます。

ステータスコードについて

まずこれキャッチアップせねば。

ざっくり

https://www.seohacks.net/blog/1147/

  • HTTPステータスコード100番台 情報レスポンス
    • リクエストが処理中を表すステータスコード
      • リクエスト継続中とか処理中とか
  • HTTPステータスコード200番台 成功レスポンス
    • リクエストが正常に受付されたことを示すステータスコード
      • 200 OK
        • リクエスト成功
      • 201 Created
        • 新たに作成されたリソースのURIが返される
  • HTTPステータスコード300番台 リダイレクション
    • リダイレクトが発生した際に表示されるステータスコード
      • 301 Moved Permanently
        • ウェブサイトが恒久的に移転している
          • ウェブサイトを新しいドメインに切り替えた時や、末尾に「/」をつけずにアクセスしたとき
      • 302 Found
        • ウェブサイトが一時的に移動している
          • サイトメンテナンス時など
          • 認証系もこれで処理する場合がありそう
      • 307 Temporary Redirect
        • 一時的なリダイレクト
          • ほぼ302
          • レアらしい
      • 308 Permanent Redirect
        • 恒久的なリダイレクト
          • 301よりも厳格
          • ほぼ301
  • HTTPステータスコード400番台 クライアントエラー
    • 「クライアントエラー」が出たときに表示されるステータスコード
      • 400 Bad Request
        • 「不正なリクエスト」を表すステータスコード
          • ブラウザか端末のどちらかに原因があり、サーバーが要求を処理できなかった
          • Cookieのデータサイズが大きすぎて処理できない場合など
          • ブラウザを変える、端末を変える、ブラウザ上でCookieを削除することで解消する場合が多い
      • 401 Unauthorized
        • 「アクセス権がない、または認証失敗」を表すステータスコード
      • 403 Forbidden
        • 「禁止されている」ことを表すステータスコード
          • リクエストはしたものの拒否されたり、処理できなかったりしたり
          • 社内のイントラネットからしかアクセスできないURLに、社外からアクセスしたり
      • 404 Not Found
        • 「リクエストしたウェブサイトが見つからない」場合に表示されるステータスコード
          • Webサイトが閉鎖されていたりWebサイト内の特定のページが消えていたりするとき
          • 他のページへの導線を設けたページを表示させることが多い
  • HTTPステータスコード500番台 サーバーエラー
    • 500 Internal Server Error
      • https://developer.mozilla.org/ja/docs/Web/HTTP/Status/500
        • このエラーは、サーバーの課題に対する一般的な「すべてをカバーする」レスポンスであり、サーバーがレスポンスとして返すのにより適切な 5XX エラーを見つけられなかったことを示します。
          • やっぱ別でcatchしてなければ500を返すっぽい。
          • これが出るとユーザーは操作できなくなったりするから、ちゃんと適切なエラーをthrowしておこうという。
    • 503 Service Unavailable
      • 「サービス利用不可」を意味するステータスコード
        • ウェブサーバが過負荷状態に陥るなどして一時的にウェブサイトが表示できないとき

記事を読む

1記事目

  • 例外の区別
    • 業務エラー
      • ポイントが足りないなど、ユーザーが間違えたとき
    • システムエラー
      • DBダウンとか実装バグとかデータ不整合とかAPI連携失敗とか
    • 検査例外
      • コンパイル時に検査を行う例外
      • 例外がでる可能性のあるメソッドを呼び出す時に、try-catchでハンドリングをしないとコンパイル自体できない
      • IOExceptionやSQLExceptionなど
        • ファイルが読み込めないとかDBにアクセスできないとか
      • kotlinではないらしい
    • 非検査例外
      • Java以外で例外といったらこの非検査例外に該当する
      • 検査例外と違い、try-catchで例外ハンドリングをしなくて大丈夫
      • NullPointerExceptionやArrayIndexOutOfBoundsExceptionなど
    • 回復可能なエラー
      • ネットワークエラーが起こり、リトライするなど
      • 回復可能なエラーであればtry-catchを使う必要が出てきたりするらしい
        • どういう基準??
    • 回復不可能なエラー
      • コードの誤用やプログラミングエラー
      • 回復不可能なエラーは基本的にはcatchせず、Webフレームワークまで例外を伝えるのが良い
        • catchしない方がいい場合はどういうとき?
  • 例外のメリット、デメリット
    • メリット
      • 例外が発生した場合、もしくは例外をthrowした場合にどんなに階層が深くても、実行中の処理を中止し、その例外をtry-catchしているところ(catchしていなければWebフレームワークの例外処理機構まで)、まで処理を戻すことができる
      • 例外は基本catchしない、Webフレームワークの例外機構に例外のハンドリングを任せる という方針があるらしい
  • 基本事項
    • 基本的にはcatchしない。フレームワークの例外機構に任せる
      • 個別にtry-catchを書くと、ログを落としたり、通知すべきところに通知をしたりという処理が開発者単位で違ってきてしまったり、本来上に例外を伝えてあげないといけないのに例外を握りつぶしてしまったり、ということが起こり得る
        • try-catchをすると例外を握りつぶすらしい
      • それであれば上位層で、例外が投げられたらログを落とす、通知すべきところに通知をするという仕組みを整えておいて、try-catchは基本しない というふうにしておくと良い
    • 回復処理が必要な箇所でのみtry-catchを書く
      • 書かないといけない場面は回復可能なエラーが起こった時
        • ネットワークエラーや接続ができなかったというのであればリトライするとか
        • IO系の処理でエラーが起こったのであればリソースを閉じるとか
        • 深刻ではないタスクエラーの場合はあえてログだけ落として、ソフトウェア自体の実行を継続するとか
      • 回復できない例外に関してはcatchする意味はあまりない
    • できる限り抽象的な型でcatchはしない。具象的な型でcatchする。
      • 抽象的な型でキャッチした場合、その型でキャッチできる具体的な型の例外に必要な回復処理を網羅しにくい
      • 本来キャッチしてはいけない例外をキャッチしてしまう恐れもある
        • 上の階層まで伝えるべきエラーをcatchしてしまい検知できない
        • Exceptionクラスでcatchすると全部catchしちゃったり
    • 早い失敗、目立つ失敗
      • 失敗が後ろに倒れ込むと、もとの失敗している時点まで遡ったりしないといけない
      • ここで失敗しているように見えるけど、実際はここですでに失敗していたのか。。。みたいな
      • 早い失敗は、回復可能なエラーだった場合、呼び出し元がエラーから、適切かつ安全に回復できる可能性を最大化する
        • 回復可能なエラーが発生し得る処理は投げるタイミングでcatchしておけば適切に回復できる、は納得感ある
    • 例外が起こった時の挙動は上位の層でハンドリングする
      • 例外が起こった時に回復する必要があるかどうか、どのように回復処理をするのかは上位層が知っていることが多い
      • 下位の層は例外を投げるだけ、上位の層はその例外をcatchするかしないかを決める。catchするとしたら適切な回復処理をするのがおすすめ
  • 今の疑問点
    • 回復可能なエラー以外はcatchしなくていいとのことだけど、catchしなかった例外はどうなる?
      • throwもしなかった場合に500になる?catchしなかったら500になる?
    • エラーハンドリングした結果、具体的にどんな場合にどうなる?エラーハンドリングって何から何までのことを指す?
    • catchしたあとでは、どんなときに何をする?

2記事目

https://qiita.com/chika_s_it/items/48897c98c8b9749d66b5

  • 『Exceptionをもみ消す』ことの弊害
    • エラーが発生した事実が呼び出し元に通知されないため、呼び出し元は成功したものとして処理を続けてしまう。
    • エラーの詳細が記載されていないため、デバッグが困難になる
  • 絶対にやること
    • エラーの内容を、いつ、どんな状況で、何の入力に対して例外が発生したかをlogに出力する
      • デバッグするため
      • loggerライブラリを用いてlogを出力する
        • うちではdatadogに吐き出してそう
      • input内容と、何をしようとした時に起きたかを書く
      • 個人情報に注意
    • エラーが発生したことを呼び出し元のクラスやユーザーに対して通知する。
      • booleanで結果を返すパターン
        • 失敗したらfalseを返し、falseの場合の挙動を書く
        • 失敗の原因が通達されないためおすすめではない
      • Optionalで返すパターン
        • ほぼbooleanと同じ
        • ある操作に対して結果が存在するかしないか不定の時などに使う
        • 例外を投げたくない時に使う
      • 別の例外を実装し、投げられた例外を包んで呼び出し元に投げる
        • ロジックの中で呼び出したメソッドが非チェック例外を投げる場合、チェック例外などに包んで返す
        • 呼び出し元にExceptionのハンドリングを委託し、自分のロジック内でのハンドリングを諦めるパターン
        • 実際の開発現場ではこのように独自の例外を実装して呼び出し元に返す方法はよく使われている
          • 発生した例外に対し、自分の実装したメソッド内での消化が難しい場合
          • 呼び出し先クラスがチェック例外を投げるべきところで非チェック例外を投げている時
          • 例外のハンドリングに自信がない場合(早く抜けてください。許されるのは最初だけです。)
      • 返り値のクラスで結果を表現する
        • Resultクラスを実装し、それを呼び出し元に返す
        • エラーが返ってきたら、IllegalArgumentExceptionやAuthFailedExceptionなど型ごとにlogを出し分ける

AIとおしゃべり

SpringとKotlinの場合の話です。

ステータスコードは、throwしなかった場合に500になる?catchしなかったら500になる?

(1) コントローラー内で発生した例外

Springアプリケーションでは、コントローラーやサービスで例外が発生し、catchしない場合、その例外はフレームワークのエラーハンドラーに伝播します。

Springはデフォルトで「全ての未キャッチ例外」をキャッチし、適切なHTTPレスポンスを返します。
デフォルトでは500 Internal Server Errorのステータスコードが返されます。

(2) グローバル例外ハンドラーがない場合

グローバル例外ハンドラー(@ControllerAdviceや@ExceptionHandler)を設定していない場合、Springの組み込みエラーハンドラー(BasicErrorController)が実行されます。
これはSpring Bootのエラーページのデフォルト実装であり、ステータスコードは500 Internal Server Errorとなります。

自分でthrowを書かない場合でも、何らかのエラー(例外)が発生した場合には、Springがその例外をキャッチしてデフォルトでHTTP 500エラーが返る仕組みになっています

Springでエラーが発生した時の流れを教えて

Springで例外が発生した場合の流れ

例外の発生:

コントローラーやサービスなどのコードでエラーが発生すると、例外(Exceptionやそのサブクラス)がスローされます。
例外がcatchされずに伝播すると、Springがその例外を受け取ります。

Springのデフォルトハンドラー:

Springは「未処理例外」に対して**デフォルトのエラーハンドラー(BasicErrorController)**を使用します。
このエラーハンドラーが例外の内容を解析し、HTTPレスポンスを生成します。
例外が明示的にハンドリングされていない場合、HTTP 500 Internal Server Errorが返されます。

デフォルトエラーハンドラーの動作をカスタマイズするには

自分でエラー内容やレスポンスを制御したい場合、Springの@ControllerAdviceや@ExceptionHandlerを使います。

(1) カスタムエラーハンドリングの例

@ControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(NullPointerException::class)
    fun handleNullPointerException(ex: NullPointerException): ResponseEntity<String> {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Null値が見つかりました: ${ex.message}")
    }

    @ExceptionHandler(Exception::class)
    fun handleGeneralException(ex: Exception): ResponseEntity<String> {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("サーバーエラーが発生しました: ${ex.message}")
    }
}

結果

  • NullPointerExceptionが発生した場合: HTTP 400 Bad Request
  • その他の例外が発生した場合: HTTP 500 Internal Server Error

(2) コントローラー単位でカスタマイズしたい場合

コントローラーごとに例外処理を設定する場合、@ExceptionHandlerをコントローラー内で定義します。

@RestController
class ExampleController {

    @GetMapping("/error")
    fun causeError(): String {
        val str: String? = null
        return str!!.toUpperCase()
    }

    @ExceptionHandler(NullPointerException::class)
    fun handleNullPointerException(ex: NullPointerException): ResponseEntity<String> {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("コントローラー内エラー: ${ex.message}")
    }
}

4. throwしなかった場合の補足

プログラムがエラーを発生させず正常に終了した場合(つまり、何もthrowされない場合)、SpringはHTTP 200 OKを返します。

こういう感じでエラーハンドリングをした場合、ユーザーの画面には何が表示されますか?それも自分たちで実装する?その場合どんな実装があり得るか教えて。

エラーハンドリングをした場合、ユーザーの画面に何が表示されるかは、バックエンドが返すレスポンスと、フロントエンドでのエラーハンドリングの実装に依存します。

何もしない場合

もしフロントエンドがこのエラーレスポンスを処理しない場合、ユーザーにはブラウザのデフォルトエラーメッセージが表示されることがあります。
例: 「500 Internal Server Error」

フロントエンドでの実装例

エラーページを表示する

エラーページ

import React from "react";

function ErrorPage({ message }: { message: string }) {
  return (
    <div>
      <h1>エラーが発生しました</h1>
      <p>{message}</p>
    </div>
  );
}

export default ErrorPage;

エラーをキャッチする例

import axios from "axios";
import { useState } from "react";
import ErrorPage from "./ErrorPage";

function App() {
  const [error, setError] = useState<string | null>(null);

  const fetchData = async () => {
    try {
      const response = await axios.get("/api/data");
      console.log(response.data);
    } catch (err: any) {
      setError(err.response?.data.message || "未知のエラーが発生しました");
    }
  };

  if (error) {
    return <ErrorPage message={error} />;
  }

  return (
    <div>
      <button onClick={fetchData}>データを取得</button>
    </div>
  );
}

export default App;

エラーメッセージをモーダルや通知で表示

トースト通知でエラーを表示

import { useState } from "react";

function App() {
  const [error, setError] = useState<string | null>(null);

  const fetchData = async () => {
    try {
      const response = await axios.get("/api/data");
      console.log(response.data);
    } catch (err: any) {
      setError(err.response?.data.message || "エラーが発生しました");
    }
  };

  return (
    <div>
      <button onClick={fetchData}>データを取得</button>
      {error && <div className="error-toast">{error}</div>}
    </div>
  );
}

特定のエラーコードに応じた動作を実装

エラーコードごとに処理を分ける

import axios from "axios";

function App() {
  const fetchData = async () => {
    try {
      const response = await axios.get("/api/data");
      console.log(response.data);
    } catch (err: any) {
      const status = err.response?.status;

      if (status === 404) {
        alert("データが見つかりません");
      } else if (status === 500) {
        alert("サーバーエラーが発生しました。管理者にお問い合わせください。");
      } else {
        alert("予期しないエラーが発生しました");
      }
    }
  };

  return (
    <div>
      <button onClick={fetchData}>データを取得</button>
    </div>
  );
}

export default App;

その他の実装例

ログインページやホームにリダイレクト

import { useNavigate } from "react-router-dom";

function App() {
  const navigate = useNavigate();

  const fetchData = async () => {
    try {
      const response = await axios.get("/api/data");
      console.log(response.data);
    } catch (err: any) {
      navigate("/error");
    }
  };

  return (
    <div>
      <button onClick={fetchData}>データを取得</button>
    </div>
  );
}

logとかに出力するのって、throwするところで書く?catchするところで書く?

エラーログをどこで出力するかは、アプリケーションの設計方針やエラーハンドリングの仕組みによりますが、以下のような考え方が一般的です:

基本的なルール

throwする場所:

エラーの発生元で詳細情報(例: 入力値、状況など)を記録することが重要。
ただし、throwするたびにログを出力すると、同じエラーが複数箇所でログに記録されて冗長になる可能性があります。

catchする場所:

エラーを実際に処理・吸収する場所でログを記録する。
システム全体のログに統一的に記録することが推奨されます。

2. 各ケースの役割とログ出力ポイント

(1) throwする場所でログを出力

エラーが発生した場所の詳細情報を記録します。
目的: エラーの原因を特定するための情報を正確に記録する。
主に利用する状況:
エラーが発生した状況やデータを詳細に記録する必要がある場合。
エラーが他の層まで伝播し、そこで十分にハンドリングされるとわかっている場合。

fun performOperation(data: String) {
    if (data.isEmpty()) {
        val errorMessage = "データが空です: $data"
        logger.error(errorMessage) // エラーの詳細を記録
        throw IllegalArgumentException(errorMessage) // エラーを投げる
    }
}

(2) catchする場所でログを出力

エラーがキャッチされ、アプリケーションのフローにおいて「最終的に処理される」箇所でログを出力します。

目的: システム全体で一貫性のあるエラーログを記録する。
主に利用する状況:
エラーをユーザー向けに処理する際にログを記録。
エラーを再スローする場合に、そのエラーが適切にハンドリングされるまで詳細なログ出力を控える。

@RestController
class ExampleController {

    @GetMapping("/data")
    fun fetchData(): String {
        try {
            performOperation("") // 例外が発生する可能性がある
        } catch (ex: IllegalArgumentException) {
            logger.error("操作中にエラーが発生しました: ${ex.message}", ex) // ログに詳細を記録
            throw ResponseStatusException(HttpStatus.BAD_REQUEST, "無効な入力")
        }
        return "成功"
    }
}

(3) 両方でログを出力するのは避けるべき

throwとcatchの両方でログを出力すると、同じエラーが複数回記録されてしまいます(重複ログ)。
例外の発生元で詳細情報を記録するか、最終的な処理で記録するか、どちらかに絞るのが理想的です。

3. 最適な設計のためのガイドライン

(1) ドメインロジック内でエラーが発生する場合

throwする場所で詳細な情報を付加(ただし、ログ出力は控える)。
エラーを伝播させ、アプリケーション層でcatchしてログを記録。

(2) インフラ層(データベースや外部API呼び出しなど)でエラーが発生する場合

エラーの詳細はインフラ層で記録。
上位層に伝播させて、ビジネスロジックに応じたエラーハンドリングを行う。

総括

エラーハンドリングでUXがどうなるのかみたいなところも含めて気になっていたのですが、ざっくり以下の理解です。

  • エラーが発生し得る処理では、期待しない挙動だった場合にエラーをthrowする
    • ここでlog出力するかcatchしたタイミングで出力するかは設計次第
    • どの道出力できるようにthrowする
  • ユーザーの挙動はフロントで制御
    • このステータスコードだったらこうする、みたいな処理を書いておく
    • 500の予期せぬエラーは、そのエラーに対してユーザーに親切な画面を出したりしにくいので、なるべく適切な例外を投げて、そのステータスコードだった場合の挙動をフロントで定義する
  • そもそも回復不可能なエラーだったりする場合は、catchせず、そのままエラーを出力する
    • springとかであれば定義していないエラーは500で返してくれる
      実際のコードを見たりするところまでやりたかったけど、それは積み残しにします。
      少なくとも昨日よりは理解が深まったのでヨシ!

Discussion