クライアントアプリでのレベル別エラー分類の提案


動機

「正常系」「準正常系」「異常系」など言葉の定義がありますが、参考媒体によって定義が異なるため自分自身がうまく再現性を得られずに疑問が消えなかったのが今回の主な動機です。


例えば正常系でも「定常状態で仕様通り動作するものは正常系」だったり、「仕様として考慮されて処理されているものはすべて正常系」だったりします。そのため、 「単純に正常系、異常系などの区切りで分けるよりも、もっとクライアントエラーを理解しやすく再現性のある区切り方があるのでは?」 と思っていたので、今回の記事を書くに至りました。


自分でこうすればうまくいくんじゃない?と考えたもの、かつ、完成度5割ぐらいで公開してるので、コメントやツッコミ等お待ちしております!


提案

今回自分が提案するのは、以下のような レベル別でのエラー分類 です。
この話は言語仕様におけるエラー分類の話ではなく、ユーザーがアプリケーションを利用した際にクライアントで起こりうるエラーをレベル別に分類することによって、エラー仕様を考えやすくする事を目的としています。

Lv1: システムによって回復すべきエラー
Lv2: ユーザーによって回復されるべきエラー
Lv3: システムでもユーザーでも回復不能なエラー

上のレベルの方がユーザー体験が良く、Lv1、Lv2が回復可能、Lv3が回復不能です。


なぜこの区切りにするのか?

「回復可能かどうか」「誰によるものか」という、ハッキリした2軸を用いてユーザービリティ視点でレベルを付けており、ある程度の解決方針とセットで分類が可能になるのが良さそうなため。


正常系の定義

仕様として考慮されており、エラーとして型表現されていないものとして分類しています。


Lv1: 「システムによって回復すべきエラー」について

仕様として考慮されており、エラーとして型表現されているが、 ユーザーに知らせることなくシステムで解決すべきエラーとして分類しています。


(ex) システムによって回復すべきエラー

「アクセストークン切れ、リフレッシュトークンを利用してトークン更新して再度接続。」「プライマリシステムで検索したが見つからなかった、セカンダリシステムへアクセスして発見」「通信状態が悪い環境に入ったので、通信環境が良くなるまで通信実行を待機。環境改善したら再開」「冪等性のあるGETリクエストを3度までretry」

iPhoneアプリの通信エラー処理を考えるという記事によると投稿系のサービスで、通信状態が悪くなって投稿には失敗したがユーザーのタイムラインには反映させ、ユーザーの通信環境が良くなったタイミングで通信して整合性を取る。みたいなサービスもあったようです。(今だとFirebaseのCloud FireStoreで実現可能ですね


Lv2: 「ユーザーによって回復されるべきエラー」について

仕様として考慮されており、エラーとして型表現されているが、システムによって解決できず、 ユーザーによって解決されるべきエラーとして分類しています。


代表的なハンドリング

「ユーザーにメッセージ(アラート)を出し、次の行動を促す」 というものになります。

ただ、こちらは外部環境が原因とそうでないもので分類できるとコメント頂いて納得したので、中分類を作ります。


Lv2-1: 「外部環境が原因でない、ユーザーによって回復されるべきエラー」について

こちらはあくまで方針の分類ですが、外部環境が原因でないのでその場でユーザーに回復のアクションを促せることが多いです。

ex) 「バリデーションエラーを赤文字で表示」 「クレジットカードの期限切れエラー、ユーザーに更新を促すアラートを出す。更新ボタンを押すと更新画面へ行く」


Lv2-2: 「外部環境が原因の、ユーザーによって回復されるべきエラー」について

システムによって自動で回復できずに、その場ユーザーによって回復も出来ませんでしたが、時間や場所を変えたり外部の環境変化によって解決可能で回復されるべきエラーです。

ex) 「通信環境の悪化、retryにも失敗、あとでもう一度お試しくださいのアラートを表示する」「下書きをローカルに保存して、状況が良くなった時に下書きから投稿可能」


Lv3: 「システムでもユーザーでも回復不能なエラー」について

仕様として考慮されており、エラーとして型表現されているが、システムでもユーザーでも解決不能。もしくは、エラーとして型表現されているが、仕様として考慮されていないもの。として分類しています。


仕様として考慮していればほとんどはユーザーに次の行動を促せるという話はありますが、そうならなかったもの&仕様として考慮していなかったものです


(ex) APIクライアントのライブラリ

unexpectedObject のようなパースが失敗したときのレスポンスエラーが定義されている場合がありますが、 原因不明でパースに失敗時のハンドリングが仕様で定められていないとユーザーにもシステムにも解決する手段はありません。


基本的にLv3はユーザーでも定義済みのシステムでもなく開発側での対応が必要だと考えられるので、「ユーザーに真摯に振る舞いつつ、最速で問題解決できるような仕組みを用意する」が大事だと考えられます。

社内で相談しながら、簡単にLv3対応の松竹梅を考えてみました。


梅(最低でもやっておきたいこと)

ハンドリング方法が良く分からないエラーに関してもすべてエラーコードを振っておき、解決不能エラーが起こった時にはユーザーにアラートでごめんなさいしつつ、Crashlytics等でエラーコードとエラーを送信する。

ex) エラー仕様表

エラーコード エラー名 LV ハンドリング
900 unexpectedObject LV3 Lv3梅
999 unknownError LV3 Lv3梅

イメージハンドリングコード

Lv3Handling.swift
switch error {
    ...)中略
    case .unexpectedObject(let object):
        // error.code is 900
        let nsError = NSError(domain: "server", code: error.code, userInfo: nil)
        Crashlytics.shared.recordError(nsError)
        presentAlert(reason: error.reason)
    case .unknown:
        // error.code is 999
        let nsError = NSError(domain: "server", code: error.code, userInfo: nil)
        Crashlytics.shared.recordError(nsError)
        presentAlert(reason: error.reason)
}

梅の内容に加えて、いくつか問題解決に役立ちそうなメタ情報を送信する。例えばユーザーIDや、最近ではエラー発生時の画面録画の送信ができるツールもある。


アプリ側で完全なログを取って、エラー発生時に送信する。

なかなかコストがかかりそうなのと、大概はサーバー側でログ取りをしているのでクライアントでここまでする必要があるのか?という疑問はありますが、完全に防衛的な仕組みとしてはこちらが考えられそうです。


この分類を使って実装を進めるとどうなるのか?

「このエラーはLv2で、特別なハンドリングはできないのでアラートを出しておこう。」
「このエラーは定義として置いてあるけど、発生した場合にどうしようもないのでLv3として扱おう。」

という、ある程度のエラーハンドリングの定型化が行えるのではないかなと思っております。
エラーは第一級仕様なので用件によって柔軟に対応すべきですが、方針を提供しただけで実際の実装は各種プロジェクトに委ねられるので、コンフリクトはしないかと思っています。


クライアントでのエラー仕様の作り方

クライアントのエラーハンドリングについてプロジェクトをまたいだ再現性が欲しかったので、ついでに書いておきます。


1. 発生しうるエラーをすべてエラーコード表にマッピングする

上で説明したLv3のエラー発生の原因を最短で突き止めてバグ修正するには、どのエラーが発生したのか突き止める必要があります。そのため、最低限アプリで起こりうるエラーは列挙してマッピングしておくと良いです。


自分はチケット駆動で進めているのでこのようなフローでエラーコードを決定しています。

  1. 「このSDKこんなエラー起こるけど、000~100の範囲で各種コード振っていい?」みたいなJIRAチケットを作成して、iOS/Android/サーバーなどの関係者にコメントもらう。
  2. OKの場合は、チケットをクローズしてConfluenceに作成した「エラーコード表」に決まったエラーコードを記載する

ここは技術とビジネスレイヤを行ったり来たりになります。


2. エラーコード表をソースコードで表現する

エラーコード表がプロジェクトごとにどうなるか分からないですが、多くの場合はこんな決め方をしているのではないでしょうか。


大分類 中分類 分類名 概要
0000~0099 クライアントエラー クライアントで発生しうるエラー分類
0100~0199 アカウントエラー アカウントで発生しうるエラー分類
0200~0399 サーバーエラー サーバーで発生しうるエラー分流
0200~0299 サービスAエラー サービスAで発生しうるエラー分流
0300~0399 サービスBエラー サービスBで発生しうるエラー分流

エラーコード 概要
0000 メールアドレスの長さが足りない
0001 パスワードの強度が足りない

上のような表をソースコードで表現します。

以前書いた記事でも似たようなこと言ってます。
https://qiita.com/guitar_char/items/8f79b69694c28c38c8fd


ネストしたenumでエラー表を表現する

Swiftはenumが便利なので表の表現に使います。サンプルですが、このような実装を良くします。ドキュメントコードをしっかり書いておくと仕様とコードとの対応性が上がり検索性や分かりやすさが劇的に向上します。(URLはこの記事では適当に書いてます

もしかしたら、プロジェクトによっては規模が小さくエラーの数が少ないこともあるかもしれませんが、そのような時はネストしないエラー表になるかもしれません。


ErrorTable.swift

/// プロジェクトで発生するエラー。
///
/// - seealso: [エラー表](https://attrasian/confluence/error-table)
enum ProjectError: Error {

    /// クライアントで発生するエラー。0000~0099まで定義されている
    enum ClientError: Int, Error {
        case invalidEmailAddress = 0
        case weakPassword = 1
        ...
    }

    /// アカウントで発生するエラー。0100~0199まで定義されている
    enum AccountError: Int, Error {
       case banned = 100
       ...
    }

    /// サーバーで発生するエラー。0200~0399まで定義されている
    enum ServerError: Error {

      /// サービスAで発生するエラー。0200~0299まで定義されている
        enum ServiceAError: Error { 
            ... 
        }

      /// サービスBで発生するエラー。0300~0399まで定義されている
        enum ServiceBError: Error {
            ...
        }

        case serviceA(ServiceAError)
        case serviceB(ServiceBError)
    }

    case client(ClientError)
    case account(AccountError)
    case server(ServerError)
}

Swiftで上記のコードを使う場合のサンプルを置いておきます。

使う時.swift

func fetch(offset: Int, completion: ((Result<Value, ProjectError) -> Void)) {
    // 雰囲気コード
    APIClient.send(someRequest) { result
        case .success(let value):
            completion(.success(value))

        case .failure(let error):
            let projectError: ProjectError = ProjectErrorConverter.converted(from: error)
            completion(.failure(projectError))
    }
}

レベル3をクラッシュさせるべきかどうか

fatalError()が正しく実行されるべきなのは、その問題が発生している限り一切ユーザーがアプリケーションを利用できない時のみで、それ以外のケースでは部分的にでもユーザーはアプリケーションが利用できるのでクラッシュさせてはいけないと考えています。

参考記事

iPhoneアプリの通信エラー処理を考える

モバイルアプリのエラーにどう対処するか

iOSアプリエラー関しの設計とその効果


感謝

TwitterでAPIクライアント周りのエラーに意見を聞いてみたのですが、いくつかコメント頂けました。ありがとうございます。

この投稿は具体的なAPIクライアント周りのエラーハンドリングに対する投稿でしたが、iOSでは connectionError はOSレベルで再送してくれることがある(Lv1対応)ので、アプリとしてはLv2のアラートで別環境でユーザーに再度接続を試して貰うのを促す。request/responseエラーは大体開発側での問題として発生するので、最短で解決するようなLv3対応をしようという内容でした。