Open4

Flutterのネットワーク周りのエラーハンドリングについて

koji-1009koji-1009

Flutterでhttpパッケージを使ってAPIリクエスト周りの処理を書いている時に考えたこと。
具体的には、httpリクエストやレスポンスのエラーケースをどうハンドリングするのがいいのか、という話。

koji-1009koji-1009

ざっくり考えると、2通りの対応方法がある。

  1. Exceptionを利用する
  2. Errorを表現するクラスを作成する

Exceptionを利用するケースについて。
400や500系のレスポンスをハンドリングするクラスは、次のような形を想定する。

class ErrorResponseException implements Exception {
  const ErrorResponseException({
    required String message,
    required this.code,
    required this.body,
  });

  final int code;
  final Map<String, dynamic> body;

  
  String toString() {
    return 'ErrorResponseException: code=$code, body=$body';
  }
}

また、ネットワークエラーなどは次のsealed classが良さそうに思える。


class ConnectionException with _$ConnectionException implements Exception {
  const factory ConnectionException.noNetwork() = NoNetworkException;

  const factory ConnectionException.timeout() = TimeoutException;

  
  String toString() {
    return 'ConnectionException';
  }
}

Dartの場合、 ConnectionExceptionNoNetworkException が継承する関係になるので、 NoNetworkExceptionConnectionException としても Exception としても扱えるようになる。
処理の全体像を書くと、次のようなコードになる。

try {
  final response = repository.post(request: resuest);
} on ErrorResponseException catch (e) {
  debugPrint(e.toString());
} on NoNetworkException catch (e) {
  debugPrint(e.toString());
} on TimeoutException catch (e) {
  debugPrint(e.toString());
} catch (e) {
  debugPrint(e.toString());
}

もしもネットワーク周りのエラーケースで分岐をしないのであれば、次のように省略することもできる。

try {
  final response = repository.post(request: resuest);
  return response.token;
} on ErrorResponseException catch (e) {
  debugPrint(e.toString());
} on ConnectionException catch (e) {
  debugPrint(e.toString());
} catch (e) {
  debugPrint(e.toString());
}

Errorを表現するクラスを作る場合は、次のようなクラスを想定する。


class RequestResult with _$RequestResult {
  const factory RequestResult.success({
    required int code,
    required Map<String, dynamic>? body,
  }) = RequestResultSuccess;

  const factory RequestResult.failure({
    required int code,
    required Map<String, dynamic>? body,
  }) = RequestResultFailure;
}

ネットワーク周りの問題をカバーする場合は、クラスに要素を足していく。


class RequestResult with _$RequestResult {
  const factory RequestResult.success({
    required int code,
    required Map<String, dynamic>? body,
  }) = RequestResultSuccess;

  const factory RequestResult.failure({
    required int code,
    required Map<String, dynamic>? body,
  }) = RequestResultFailure;

  const factory RequestResult.noNetwork() = RequestResultNoNetwork;

  const factory RequestResult.timeout() = RequestResultTimeout;
}

処理を記述すると、次のようになる。

final response = repository.post(request: resuest);

response.when(
  success: (code, body) {},
  failure: (code, body) {},
  noNetwork: () {},
  timeout: () {},
);
koji-1009koji-1009

改めて書いてみると、Exceptionを利用したほうがシンプルになる印象がある。
このため、下記の2点が気になる場合にはErrorを表現するクラスを利用した方が良さそう、というのが現在の見解。

  1. DartにはExceptionをハンドリングさせることを、強制する仕組みがない
  2. freezedのsealedクラスを利用することで、メソッドの返り値として統一的な処理ができる

Dartには「Javaにおける検査例外がない」というのが辛さがある。SwiftにはJavaよりも便利な検査例外的な仕組みがあるので、この課題感はAndroidエンジニアでもiOSエンジニアでも感じるものなのでは、という気がする。(なお、RuntimeExceptionによる検査例外の形骸化については、取り扱わない。)
このため、あるメソッドが例外的な処理をハンドリングさせることを、型情報で伝える場合にはErrorを表現するクラスが便利そうに思える。

https://docs.swift.org/swift-book/LanguageGuide/ErrorHandling.html


また、世間的にはExceptionを避けるという風潮もある。例えば、Kotlinでは次のようなdiscussionがあったりする。
https://discuss.kotlinlang.org/t/why-does-first-not-return-nullable/1465/6

golangやRustにはExceptionがなく、複数の値を返したりResult型のような仕組みで、エラーを伝播している。
https://doc.rust-lang.org/book/ch09-00-error-handling.html

koji-1009koji-1009

話の結論としては、「チームメンバーや開発するアプリケーションによって選びましょう」といういつものものになってしまうのだけれど、現時点での見解は下記の通り。

  1. 設計した時に想定しきれないExceptionがあり得るので、Exceptionを利用する方が網羅的
  2. async/awaitをKotlinやSwiftっぽく書くのであれば、Exceptionの方が近い書き味になる
  3. freezedによるExceptionのsealed classを作れば、Exceptionの処理をまとめたい時に実装者が工夫しやすい