iOSアプリでネットワークエラーと向き合う

2023/11/27に公開1

iOS開発において、普段はあまりネットワークエラーを真剣にハンドリングしていませんでした。
正直モバイルアプリにおいてAPI叩いてエラーが発生したときにできることはそんなに選択肢がないので、雑にやってる方も多いのではないでしょうか。

今回、ネットワークエラーのときにだけ動作を変えたいという要件がありまして、
その必要に駆られてネットワークエラーと向き合うことになったので、その知見をつらつら書いていきます。

オフラインのときに挙動が変わる例(YouTube)

普段我々は電波の中で生活しているので、オフラインのときの特殊導線はあまり意識しないかと思います。
YouTubeアプリの例ですが、オフライン状態で開くとこのような導線が出ます。
(※YouTubeプレミアムに加入してる場合)

もしダウンロードしてる動画があったら、電波がなくても見れます。
飛行機に乗るときなんかに便利ですね。
いい機能だと思います。

どのようにネットワークエラーを判定するか

さあ、まずどのようにネットワークエラーを判定するか、です。
方針は2通り考えられます。

  1. API叩いてネットワークエラーに該当するものが来てるか見る
  2. NWPathMonitor使って通信状況を監視する

それぞれの方法の詳細は後述します。
ただ色々検討した結果、僕は1.を選びました。

当初はNWPathMonitorで死活監視して、その結果でハンドリングすればいいかと思ってたんですが、微妙にラグがあって信頼できませんでした。
あと、著名なiOSエンジニアであるAntoine v.d. SwiftLeeさんが、下記のようなコメントをしてたのも追い風になりました。

I want to emphasize Apple recommends not checking for network connections and instead running requests and handling potential networking errors.
(拙訳: Appleの推奨がネットワーク状況のチェックではなくて、リクエストを送ってネットワークエラーをハンドリングであることは強調しておきたい)

https://www.avanderlee.com/swiftlee-weekly/issues/140

これ、Appleの推奨についてのソースを探したんですが見つからなかったので、もし知ってる方いたら教えて欲しいです。
(本人に聞いてもいいんですけど、聞くほどでもなくて……)

(追記)
こちらの記事を読んでいたらたまたまそれっぽい記述を見つけました。
これかな?

Always attempt to make a connection. Do not attempt to guess whether network service is available, and do not cache that determination.
(拙訳: 常にコネクション確立を試してください。ネットワークサービスの利用可否を判定して、その判定結果を保持しようとしないでください)

https://developer.apple.com/library/archive/documentation/NetworkingInternetWeb/Conceptual/NetworkingOverview/WhyNetworkingIsHard/WhyNetworkingIsHard.html#//apple_ref/doc/uid/TP40010220-CH13-SW3

ネットワークエラーのレスポンス

Xcodeつなぎながらタイムアウトエラーを発生させると、長いエラーメッセージが出ます。

Error Domain=NSURLErrorDomain Code=-1009 "インターネット接続がオフラインのようです。" UserInfo={_kCFStreamErrorCodeKey=50, NSUnderlyingError=0x600000d41ef0 {Error Domain=kCFErrorDomainCFNetwork Code=-1009 "(null)" UserInfo={_kCFStreamErrorDomainKey=1, _kCFStreamErrorCodeKey=50, _NSURLErrorNWResolutionReportKey=Resolved 0 endpoints in 2ms using unknown from cache, _NSURLErrorNWPathKey=unsatisfied (No network route)}}
(※抜粋)

要はエラーコード「-1009」がURLErrorのnotConnectedToInternetなので、
これを見てあげればいい……かというとそんなこともなくて、たとえばタイムアウトエラーは「-1001」なので、拾えません。

僕がデバッグした限りだと、notConnectedToInternetで返ってくるのは機内モードや圏外など、
iOS的に即ネットワークがないと断定できるもので、少しでも通信があるとなかなか返ってきません。
(それ自体は正しい)

こちら↓などを参照しながら、ネットワークエラーっぽいエラーコードを拾うと、たぶん正確にハンドリングができます。

https://zenn.dev/swiftty/articles/20221017-cocoa-errors

が、ちょっとつらいですよね。
アプリケーション層は、もうちょっと粗くても大丈夫だよ、という気持ちになります。

もうちょっと粗く拾いたい

APIKitを使っているアプリであれば、SessionTaskErrorconnectionErrorというのが定義されているので、
これが返ってくるのであれば何らかのエラーでレスポンスが受け取れていないということになります。
なので、これが来たらネットワークエラー、ということにしました。

APIKitの実装見ると、この部分ですね。

https://github.com/ishkawa/APIKit/blob/master/Sources/APIKit/Session.swift#L92-L97

URLSession直で使ってるのであれば、こんなコードに相当します。

let url = URL(string: "https://xxx")!
let task = URLSession.shared.dataTask(with: url) { data, response, error in
    if let error = error {
	// ここをconnectionErrorとして返してる
    }
}
task.resume()

タイムアウトエラーが返らない

関連して。
URLSessionの設定によってはタイムアウトエラーが返らなくなっている場合があります。

timeoutIntervalForRequestでタイムアウト値を設定できます。
これはデフォルト60秒。

これとは別に、timeoutIntervalForResourceという設定値があります。
waitsForConnectivityにtrueを渡してると、こちらの値が有効になります。
デフォルト値がなんと7日間。

これが有効になっていると、タイムアウトエラーにならずに、通信の回復を待機します。
7日も何を待つんだという話ですが、これは元々iOSアプリ内でがんばってネットワークのポーリング処理書いてた人が使う想定だったようです。
タイムアウトさせずに、通信の回復を待たせたいときに使うのが想定されているユースケース。

https://dev.classmethod.jp/articles/nsurlsession-waitsforconnectivity/

通信が回復したら勝手に再送してくれる、と書いてあるんですが、デバッグしたところ、その再送をやってくれなかったんですよね。。。

もしデバッグ中に、タイムアウトエラーの挙動が思ったようにいかなかったら、この辺の設定を見るといいかもです。

NWPathMonitor使って通信状況を監視する

だいぶ長くなりましたが、NWPathMonitorについても一つだけ書きたいことがあって、それだけ書きます。
基本的な使い方はこちらの記事に譲ります。

https://dev.classmethod.jp/articles/nw-path-monitor-network-state/

NWPathMonitorは一度cancelすると二度とstartしない

NWPathMonitorをシングルトンで持って、それでアプリ全体のネットワーク死活監視をやってた訳なんですが、
バックグラウンドに入ったときにcancel()して、フォアグラウンドになったらstart()していました。

この仕組み、動いたり動かなかったりで、なんとなく挙動怪しいなと昔から思ってたんですが、
なにぶん公式ドキュメント見ても記述があっさりで、情報があまりない状況でした。

Appleのフォーラムに寄せられてた質問から、「NWPathMonitorは一度cancelすると二度とstartしない」という仕様が発覚しました。

https://developer.apple.com/forums/thread/124486
※質問と回答がなんかつながってないように見えますが、
たぶん最初stop()で書いてたサンプルコードをサイレント修正でcancel()に変えたと思われます。

Once you cancel a path monitor, that specific object is done. You can’t start it again. You will need to create a new path monitor
(拙訳: 一度PathMonitorをcancelすると、そのオブジェクトは終わりです。二度とstartすることはできません。新しいPathMonitorを作成する必要があります)

これ結構な罠だと思います。
せめてcancelメソッドの公式ドキュメントに書いておいて欲しかった……

(了)

Discussion