👷

Web サービスを開発するときのエラーハンドリングについて

2022/03/11に公開1

一言にエラーといっても色々あるのでまとめてみました。

エラーの種類

エラーは、予期したエラーと予期せぬエラーの2種類に大別できます。

  • 予期したエラー
    • バリデーションエラー
    • API から返ってくるレスポンスエラー
      • クライアントエラーレスポンス(400系)
      • サーバーエラーレスポンス(500系)
  • 予期せぬエラー
    • ネットワークエラーなどの通信エラー
    • DB やサーバーがダウンしたなど
    • バックエンドのコードのバグ
    • フロントエンドのコードのバグ

大前提としてエラーが起こったら、ユーザーが次に行うべきアクションが分かるような何かを伝えることが最重要だと考えています。


まず予期したエラーが起こった場合について考えてみます。

バリデーションエラー

フロントエンドのバリデーションエラーはユーザーの入力時にチェックし、即時でユーザーに教えてあげるのが UX 的に良いです。

フロントエンドでチェックしているから、バックエンドでバリデーションチェックしなくて良いかというとそういうわけでもありません。世の中にはわるい人がいて、ブラウザ以外から curl コマンドなどを使って API へ直接よくないリクエストを送信する人もいます。
DB へ変なデータが書き込まれてしまったり、見る権限のない人に見せてはいけないリソースを返してしまったりなどしないように、バックエンドでもバリデーションチェックはする必要があります。
(使っている FW にバリデーションチェックする仕組みは用意されているはずなので、それに則って開発していれば問題ないです)
「バックエンドは最後の砦」という価値観です。

また、ブラウザで動いている JS などのクライアントコードで管理しているデータは、インスペクターなど使って自由に書き換えられます。つまりバリデーションツールを使っていてもブラウザから不正なデータは送れるので、フロントエンドから送られてくるリクエストのことを信用してはいけません。

バックエンドでバリデーションエラーが起こったら

基本的にフロントエンドでバリデーションしていたら起こらないのですが、

  • フロントエンドのバリデーションロジックの実装漏れ
  • バリデーションロジックがフロントエンドとバックエンドで乖離している
  • フロントエンドの実装ミスやブラウザのバグ
  • わるい人がインスペクターなどから操作して不正なデータをリクエストした

などでバックエンドから422エラーが返ってくることがあります。

その場合は「 入力内容に誤りがあります。内容をご確認ください。」といったメッセージを HeaderNotice 的なもので表示させます。

API から返ってくるバリデーションエラーのレスポンスには、何のエラーが起こったのかを返していると思いますが、これも本来は画面のどこかに表示させてあげたほうがユーザーにとって親切です。

// API から返ってきた422エラーレスポンスの中身
{
  title: 'バリデーションエラー',
  errors: [
    '名前は必ず入力してください',
    'メールアドレスは必ず入力してください',
  ],
}

箇条書きなどで良いので、画面のどこかに表示させてあげるのがやさしい設計かもしれません。

とはいえバックエンドから返ってくるバリデーションエラーは、フロントエンドでバリデーションをかけているのであれば基本的に返ってくることがないはずです。ユーザーが本来見ることがないはずの画面なのであまりリッチなインタラクションにする必要はなく、雑な表示でも良いかなと考えています。

フロントエンドという概念がなかった頃の牧歌的な時代のバリデーションの歴史

ここまで書いたのは2022年現在のバリデーションについての考え方であって、フロントエンドでのバリデーションという概念がなかった時代(フロントエンドとバックエンドが分離しておらず、API 通信とかしてなかった頃)は、ちょっと話が違いました。

その頃の話をすると長くなるので割愛しますが、要はよりリッチなインタラクションにしたほうが UX が良いよね、ということで今の形に落ち着いています。
結論としてバリデーションエラーはサーバーにリクエストを送信する前に、ユーザーが入力した即時で表示したほうが UX が良いです。

API から返ってくるレスポンスエラー

API からはだいたいこんなレスポンスが返ってくるかと思います。

type ProblemDetails = {
  title: string // エラーのタイトル
  detail?: string // エラーの内容、タイトルに補足したいことがあれば
  type?: TypeString // HTTP ステータスコードよりもさらに詳細なハンドリングをしたいときに
  status?: HTTPStatusCode // 基本はなくて良い
}

// 追加したほうが良いものがあれば都度拡張する

type ValidationProblemDetails = ProblemDetails & {
  errors: string[]
}
type UnauthorizedProblemDetails = ProblemDetails & {
  loginUrl: string
}

既存の API とはプロパティ名や構造などが若干違うかもしれませんが、だいたい同じような役割のプロパティがあると思います。

※ レスポンスがこの形になっているのは何故?的なところは別の記事でまとめていますので合わせてどうぞ。
ぼくのかんがえたさいきょうの API エラーレスポンスのフォーマット

ユーザーに表示するエラーメッセージを管理するのはフロントエンド?バックエンド?

注意したいのがここで返ってくる titledetail などは、ユーザーに表示するための文言ではなくあくまで開発者に何のエラーか教えてあげるための文言だということです。
エラーを返しているのは API であって、API を操作するのはフロントエンドのコード(つまりフロントエンド開発者)なので、開発者がわかるエラーメッセージで十分です。

ユーザーに表示するエラー文言については、デザイナーと相談して決めることがほとんどかと思います。ここは細かい調整が行いやすいフロントエンドで管理するのが良いでしょう。ユーザーに表示する領域はフロントエンドの領域です。

例外としてバリデーションエラーなどが返ってきた場合に画面にそのまま errors の各文言を表示することはありますが、基本的にユーザーに表示するエラーメッセージはフロントエンドで管理します。

API がエラーを返してきたら、HeaderNotice 的なものでフロントエンドで定義したエラー文言を表示させてあげることがほとんどでしょう。

あらかじめ予期したエラーレスポンスは OpenAPI などで定義しておく

API から返ってくるのが予期されるエラーで、個別にエラーハンドリングが必要なものについてはあらかじめ OpenAPI などでエラーレスポンスを定義しておきます。
このとき type を定義するとフロントエンドでエラーを細かくハンドリングすることができます。もしもっと複雑なことがしたければ( type で分類しつつ更に subType のような分類項目も必要だったりなど)、都度拡張していく形になります。

しかし中にはバックエンドでも予期していないエラーが返ってくることがあるかもしれません。(414 URI Too Long や想定外の500エラーなど)
その場合は予期せぬエラーになるため、一律同じようなエラーメッセージを返しエラーハンドリングします。

バックエンドで予期せぬエラーが起こった場合は、バックエンドの Sentry などで例外通知するのが良いでしょう。当たり前ですがバックエンドで通知しているため、API から返ってきたエラーレスポンスについてはフロントエンドで Sentry に通知する必要はなく、例外を発生させる必要もありません。

クライアントエラーレスポンス(400系)

起こったエラーに従ってよしなにハンドリングします。

  • 400 Bad Request
    • JSON のパースエラーなどのためわるい人が何かリクエスト送ったりしてるかフロントのバグかのため、予期せぬエラー扱いとする
  • 401 Unauthorized
    • ログインセッションが切れているため再度ログインを促す(ログインを促す処理はフロントエンドで共通化したほうが良い)
  • 403 Forbidden
    • 何かしらの権限がないということなので「権限がありません」的な文言を表示する
  • 422 Unprocessable Entity
    • 詳しくは前述の「バックエンドでバリデーションエラーが起こったら」を参照
  • etc... その他あらかじめ定義されているエラーレスポンスがあればそれに応じてエラーハンドリングする

サーバーエラーレスポンス(500系)

  • 500 Internal Server Error
    • あらかじめ想定されている500エラーとそうでないエラーがあります
      • 予期したエラーの場合はユーザーが次に行うべきアクションが分かるように適切にエラーハンドリングします
      • 予期せぬエラーの場合は一律同じようにエラーハンドリングします(「時間をおいて再度お試しください」的な表示)
        • API から返ってきた予期せぬエラーは Sentry に通知する必要はありません
  • etc... その他あらかじめ定義されているエラーレスポンスがあればそれに応じてエラーハンドリングする

次に予期せぬエラーが起こった場合についてです。

予期せぬエラーまとめ

※ 想定できる予期せぬエラーはたくさんあるので、ちょっと雑にまとめています 🙏

基本的にはエラーが起こったら通知するような仕組みを用意して気づけるようにすることと、対処する必要のないエラーは無視するという対応になります。

  • DB やサーバーがダウンしたなど
    • 外形監視などで気づけるようにする
  • ネットワークエラーなどの通信エラー(まれに起こるが対処する必要のないエラー)
    • Sentry などに通知せず、ユーザーに「もう一度お試しください」などを促す
  • バックエンドのコードのバグ(500 など)
    • バックエンドの Sentry などで通知する
    • フロントでは Sentry などに通知せず、ユーザーに「もう一度お試しください」などを促す
  • フロントエンドのコードのバグ(400 など)
    • フロントエンドの Sentry などで通知して、ユーザーに「もう一度お試しください」などを促す

予期せぬエラーが起こった場合のユーザーが次に行うべきアクションについては、以下のようなフローにならざるを得ないと考えています。

  1. 時間をおいて再度試す
  2. それでもエラーで機能しない場合はお問い合わせする

そのため「時間をおいて再度お試しください」といったような、一律共通化されたエラーハンドリングを行います。

想定されないエラーが起こった場合に開発者が行うフローについて

  1. Sentry などからエラーが起こった旨が通知される
  2. エラーの原因を特定する
  3. エラーについて対処するかしないかを決める
  • 対処しないのであれば、Sentry の通知を ignore する
  • 対処するのであれば、コードのバグを直すか想定されたエラーとして扱うなどする

おわりに

もうエラーハンドリングするときに悩んだりしないようにしたいと思ってまとめてみました。

Discussion

rryrry

バリデーションエラーは 422 Unprocessable Entity

これは人によっては400のほうが良いという人もいるかと思います。
422は WebDAV の RFC で定義されている拡張されたステータスコードだからです。
https://datatracker.ietf.org/doc/html/rfc4918#section-11.2

とはいえ、Rails(Rack) をはじめとした主要な FW が422を返す機能があり、400と使い分けることができるため、個人的には422を利用することが多いです。
ここについてはプロジェクトやチームで議論して422にするか400にするかをあらかじめ決めたほうが良いと思います。

議論の参考:
rest - 400 vs 422 response to POST of data - Stack Overflow