【Flutter】dioのインターセプターでリトライ処理やキャッシュ処理を実装する

## はじめに

FlutterでHTTP通信を行う際によく使われるパッケージの一つに **dio** があります。

https://pub.dev/packages/dio

dioには「インターセプター」と呼ばれる仕組みがあり、通信の前後に任意の処理を挟むことができます。

この記事では、dioのインターセプターの使い方について解説します。
特に需要が高い「リトライ処理」と「キャッシュ処理」に焦点を当てて紹介します。


### 記事の対象者

- Flutterで dioを使ってAPI通信を行っている
- インターセプターの存在は知っているが、具体的な使い方や設計方法に迷っている
- 通信のリトライ処理やキャッシュ処理を導入したい
- 複数のインターセプターを適切に使い分けたい
- Riverpodでの依存関係の注入を活用した設計に興味がある
- ネットワーク層をテスト可能かつ柔軟に構築したいと考えている

### 記事を執筆時点での筆者の環境

```
[✓] Flutter (Channel stable, 3.29.0, on macOS
    15.3.1 24D70 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android
    devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.100.0)
```

### サンプルプロジェクト
- PokeAPIを使ってポケモンの情報を取得する
- `PokemonsScreen` はインタセプターなしのdioを使用
- `PokemonsWithInterceptorsScreen` はインターセプターありのdioを使用
  - キャッシュ機能を追加
  - リトライ機能を追加
  - 検証のために通信開始後に条件によってエラーを流す機能を追加

https://youtu.be/4XLH9v21uNc

### ソースコード

https://github.com/HaruhikoMotokawa/dio_interceptors_sample/tree/main

## インターセプターの基本

### 基本

冒頭でインターセプターの機能はHTTP通信の中に任意の処理を挟める機能であると説明しました。
通信に挟めたい機能として主要なものはリトライとキャッシュが挙げられます。

**リトライ**
例えばネットワーク環境が不安定で通信に失敗したとします。
その場合何もしていないとただただエラーになって終わるのですが、リトライの処理をインターセプターで入れておくと３回は再チャレンジしてみる、といった処理が挟めるわけです。

**キャッシュ**
HTTP通信をするということは当たり前ですが、通信が発生します。
軽いデータならいいのですがデータ量が多いものだと毎回通信料金がかかってしまったり、読み込みが遅くなってしまうなどの弊害が発生します。
そこで、初回は通信してその結果をキャッシュとしてデバイスで保存し、２回目以降はキャッシュを使うことで通信をしないでもいいようにすることができます。

### 設定の仕方

dioにインターセプターを設定するにはインスタンス化の際に以下のように追加します。

```dart
Dio getDio() {
  final dio = Dio();

  // 複数のインターセプターを追加する場合は、addAllを使用
  dio.interceptors.addAll([
    // ここに追加
  ]);

  // 一つだけ追加する場合は、addを使用
  dio.interceptors.add(
    // ここに追加
  );

  return dio;
}
```

### インターセプターを作る方法
以下の二つの方法があります。

#### `Interceptor` を継承した自作のクラスを作る

基本はこの方法が推奨されます。
```dart
class ExampleInterceptor extends Interceptor {
  const ExampleInterceptor();

  // 必要なものだけオーバーライドすればOK

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    // このインターセプターで挟みたい処理を記述
    //...
    handler.next(options); // 次のInterceptorに進む
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    // このインターセプターで挟みたい処理を記述
    //...
    handler.next(response); // 次のInterceptorに進む
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    // このインターセプターで挟みたい処理を記述
    //...
    handler.next(err); // 次のInterceptorに進む
  }
}
```
追加する時は以下のように設定します。
```dart
  dio.interceptors.addAll([
    ExampleInterceptor();
  ]);
```

#### `InterceptorsWrapper` を使う

クラスで作るのではなく、実際に処理を書き込む場合に向いています。
本当に限定的な簡単な処理を挟むにはこちらが向いています。

```dart
dio.interceptors.add(
  // ここに追加
  InterceptorsWrapper(
    onRequest: (options, handler) {
      // なんらかの処理
      // ...
      return handler.next(options); //continue
    },
    onResponse: (response, handler) {
      // なんらかの処理
      // ...
      return handler.next(response); // continue
    },
    onError: (error, handler) {
      // なんらかの処理
      // ...
      return handler.next(error); //continue
    },
  ),
);
```

### 検証用にエラーをスローするインターセプターを自作してみる

```dart:lib/data/sources/http/interceptors/_fake_error/fake_error_interceptor.dart
class FakeErrorInterceptor extends Interceptor {
  int _count = 0;

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    final netWorkException = DioException(
      requestOptions: response.requestOptions,
      response: response,
      type: DioExceptionType.badCertificate,
    );

    final authException = DioException.badResponse(
      statusCode: 401,
      requestOptions: response.requestOptions,
      response: Response(
        requestOptions: response.requestOptions,
        statusCode: 401,
        data: response.data,
      ),
    );
    _count++;
    if (_count <= 2) {
      logger.w('FakeErrorInterceptor: Simulating error #$_count');
      // 第二位置引数のcallFollowingErrorInterceptorをtrueにすると
      // 次のInterceptorに進む
      handler.reject(netWorkException, true);
    } else if (_count == 11) {
      logger.w('FakeErrorInterceptor: Simulating error #$_count');
      handler.reject(authException, true);
    } else if (_count == 12) {
      logger.w('FakeErrorInterceptor: Simulating error #$_count');
      handler.reject(authException, true);
    } else if (_count == 20) {
      logger.i('FakeErrorInterceptor: Simulating count reset');
      _count = 0;
      handler.next(response);
    } else {
      // 正常であれば次のInterceptorに進む
      handler.next(response);
    }
  }
}
```
レスポンスを受け取ったらこのクラス内で保持している `_count` の数によってエラーを投げるか、スルーするかを `onResponse` 内で行っています。
そのほかは特にこの `FakeErrorInterceptor` では処理する必要がないので、 `onRequest` と `onError` はオーバーライドしていません。

普段はあまり使わないですが、エラーを流す場合は `handler.reject` を使います。
この時、第一位置引数にエラーを入れますが、第二位置引数の `callFollowingErrorInterceptor` を `true` にしないと後続のインターセプターに処理が流れなくなってしまうので、忘れずに入れましょう。

## リトライ機能を dio_smart_retry で簡単に実装する
ここではリトライ処理を簡単に実装できる便利なパッケージ、**dio_smart_retry**をご紹介します。

https://pub.dev/packages/dio_smart_retry

使い方は公式通りに行うと以下のような形です。
```dart
final dio = Dio();
// Add the interceptor
dio.interceptors.add(RetryInterceptor(
  dio: dio,
  logPrint: print, // specify log function (optional)
  retries: 3, // retry count (optional)
  retryDelays: const [ // set delays between retries (optional)
    Duration(seconds: 1), // wait 1 sec before first retry
    Duration(seconds: 2), // wait 2 sec before second retry
    Duration(seconds: 3), // wait 3 sec before third retry
  ],
));

/// Sending a failing request for 3 times with 1s, then 2s, then 3s interval
await dio.get('https://mock.codes/500');
```
基本の引数は以下のようになっています。
- dio: Dioのインスタンス
- logPrint: ログに出力する際のロガー
- retries: リトライ回数で初期値は３回
- retryDelays: リトライを実行する間隔で初期値は１秒->3秒->５秒


簡単に動作を確認するだけなら上記で良いのですが、実際には以下の問題があります。
- 依存が強すぎてもしもの時に差し替えがしづらい
- テストがしづらい
- リトライする内容ごとにインターセプターを分けられない

なので、次のような実装にすると良いと思います。

### riverpod を使って `RetryInterceptor` の依存性を注入する

まず、`RetryInterceptor` を外から渡せるようにプロバイダー経由でインスタンスを取得する形にします。

```dart:lib/data/sources/http/interceptors/retry/retry_interceptor.dart
@Riverpod(keepAlive: true)
RetryInterceptor retryInterceptor(
  Ref ref,
  Dio dio, {
  FutureOr<bool> Function(DioException, int)? retryEvaluator,
}) {
  return RetryInterceptor(
    dio: dio,
    retryEvaluator: retryEvaluator,
    logPrint: logger.d,
    retryDelays: Constants.dioRetryDelays,
  );
}
```

引数の `retryEvaluator`  は流れてきた任意のエラーにリトライを行いたい場合に条件判定の関数を渡します。
もしも何も指定しなければデフォルトの判定が入りますが、これは主にネットワークエラー系が入ってくるようです。 -> [Default retryable status codes list ](https://pub.dev/packages/dio_smart_retry#default-retryable-status-codes-list)

:::note

`final Set<int> retryableExtraStatuses;` という引数に判定したいステータスコードを入れることで、
ネットワークエラー + 任意のエラーという形も実現できます。
今回はリトライ対象をあえて分けたいので、未使用です。
:::

引数の `retryDelays` に入れている値はテストで差し替えができるように定数を別に入れています。
今回は詳細は割愛します。

### 自作のインターセプタークラスの中で `RetryInterceptor` を差し込む

```dart:lib/data/sources/http/interceptors/retry/auth_error_retry_interceptor.dart
@Riverpod(keepAlive: true)
AuthErrorRetryInterceptor authErrorRetryInterceptor(Ref ref, Dio dio) =>
    AuthErrorRetryInterceptor(ref, dio);

class AuthErrorRetryInterceptor extends Interceptor {
  AuthErrorRetryInterceptor(this.ref, this.dio);

  final Ref ref;
  final Dio dio;

  RetryInterceptor get _interceptor =>
      ref.read(retryInterceptorProvider(dio, retryEvaluator: retryEvaluator));

  /// 認証エラーのリトライを行うかどうかの評価関数
  ///
  /// AuthErrorRetryInterceptor内かテストのみで使用する
  @visibleForTesting
  static bool retryEvaluator(DioException error, int attempt) {
    if (error.type == DioExceptionType.badResponse &&
        error.response?.statusCode == 401) {
      return true;
    }

    return false;
  }

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    _interceptor.onRequest(options, handler);
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    _interceptor.onResponse(response, handler);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    _interceptor.onError(err, handler);
  }
}
```

このクラスで行っているのは `RetryInterceptor` をref経由で `_interceptor`に取得しています。
引数の `retryEvaluator` には `static bool retryEvaluator` を定義しており、その中でこのクラスがリトライを行うための条件を記述しています。

あとはリトライを行うのは `RetryInterceptor` に任せるので、そのまま `_interceptor.onXxx`を呼び出してるだけです。

ちなみにネットワークエラーだけのリトライクラスは上記の処理に引数の `retryEvaluator` に何も渡さないだけです。

<details><summary> NetworkRetryInterceptor </summary>

```dart:lib/data/sources/http/interceptors/retry/network_retry_interceptor.dart
@Riverpod(keepAlive: true)
NetworkRetryInterceptor networkRetryInterceptor(Ref ref, Dio dio) =>
    NetworkRetryInterceptor(ref, dio);

class NetworkRetryInterceptor extends Interceptor {
  NetworkRetryInterceptor(this.ref, this.dio);

  final Ref ref;
  final Dio dio;

  RetryInterceptor get _interceptor => ref.read(retryInterceptorProvider(dio));

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    _interceptor.onRequest(options, handler);
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    _interceptor.onResponse(response, handler);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    _interceptor.onError(err, handler);
  }
}
```
</details>

### dioにセットする

```dart:lib/data/sources/http/client/poke_api_http_client_with_interceptors.dart
@Riverpod(keepAlive: true)
Dio pokeApiHttpClientWithInterceptors(Ref ref) {
  final dio = Dio(baseOptions);
  // Interceptorsを取得
  final fakeErrorInterceptor = ref.read(fakeErrorInterceptorProvider);

  final networkRetryInterceptor =
      ref.read(networkRetryInterceptorProvider(dio));

  final authErrorRetryInterceptor =
      ref.read(authErrorRetryInterceptorProvider(dio));

  final customDioCacheInterceptor = ref.read(customDioCacheInterceptorProvider);

  // Interceptorsを追加
  dio.interceptors.addAll([
    // 今回はテストのために例外を投げるインターセプターを追加
    if (kDebugMode) ...[
      fakeErrorInterceptor,
    ],
    networkRetryInterceptor,
    authErrorRetryInterceptor,
    customDioCacheInterceptor, // <-後ほど解説
  ]);

  return dio;
}

```
## キャッシュ機能を dio_cache_interceptor で簡単に実装する

ここではキャッシュ処理を簡単に実装できる便利なパッケージ、**dio_cache_interceptor**をご紹介します。

https://pub.dev/packages/dio_cache_interceptor

使い方は `RetryInterceptor` と同様に必要な設定をしたらインスタンス化したインターセプターをaddで追加するだけです。

```dart
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';

// オプションを設定する
final options = const CacheOptions(...);

// キャッシュインターセプターを追加
final dio = Dio()..interceptors.add(DioCacheInterceptor(options: options));

// リクエスト => ステータス(200) => コンテンツがキャッシュストアに書き込まれる。
final response = await dio.get('https://www.foo.com');
```

設定項目は様々ありますが以下のような内容となります。

```dart
final options = const CacheOptions(
  // キャッシュする場所
  // MemCacheStoreはその名の通り、デバイスのメモリにキャッシュするのでタスクキルされると消える
  store: MemCacheStore(),

  // 以下のフィールドはすべて任意で、標準的な動作を得るために設定します。

  // キャッシュする動作設定
  // デフォルトはリクエストするごとにキャッシュがあれば使用、なければ通信する
  policy: CachePolicy.request,

  // 指定されたステータスコードでエラーが発生した場合にキャッシュされたレスポンスを返します。
  // デフォルトは `[]`。
  hitCacheOnErrorCodes: [500],

  // ネットワークエラー時（例: オフライン時）にキャッシュされたレスポンスを返すことを許可します。
  // デフォルトは `false`。
  hitCacheOnNetworkFailure: true,

  // この期間を過ぎるとエントリを削除するように、HTTPの指示を上書きします。
  // オリジンサーバーにキャッシュ設定がない場合やカスタム動作が必要な場合に便利です。
  // デフォルトは `null`。
  maxStale: const Duration(days: 7),

  // デフォルト。3つのキャッシュセットを許可し、クリーンアップを容易にします。
  priority: CachePriority.normal,

  // デフォルト。独自のアルゴリズムによる本文およびヘッダーの暗号化。
  cipher: null,

  // デフォルト。リクエストを識別するためのキー生成関数。
  keyBuilder: CacheOptions.defaultCacheKeyBuilder,

  // デフォルト。POSTリクエストのキャッシュを許可します。
  // `true` にする場合は [keyBuilder] を設定することが強く推奨されます。
  allowPostMethod: false,
);
```

### riverpod を使って `DioCacheInterceptor` の依存性を注入する

```dart:lib/data/sources/http/interceptors/cache/dio_cache_interceptor.dart
@Riverpod(keepAlive: true)
DioCacheInterceptor dioCacheInterceptor(Ref ref) {
  return DioCacheInterceptor(
    options: CacheOptions(
      store: MemCacheStore(),
      maxStale: const Duration(minutes: 10),
    ),
  );
}
```
ここで `options` に設定値を渡しています。ほとんどはデフォルトの設定をつかています。
キャッシュ場所は `MemCacheStore` とし、キャッシュを保持する時間の設定 `maxStale` を１０分にしています。

### 自作のインターセプタークラスの中で `DioCacheInterceptor` を差し込む


```dart:lib/data/sources/http/interceptors/cache/custom_dio_cache_interceptor.dart
class CustomDioCacheInterceptor extends Interceptor {
  CustomDioCacheInterceptor(this.ref);

  final Ref ref;

  DioCacheInterceptor get _interceptor => ref.read(dioCacheInterceptorProvider);

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    _interceptor.onRequest(options, handler);
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    _interceptor.onResponse(response, handler);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    _interceptor.onError(err, handler);
  }
}
```
:::note
今回はこのまま呼び出すだけですが、例えば特定のクエリーパラメータではキャッシュしないなどの追加処理を行う場合はこの中で記述します。
:::

## テストについて
インターセプターのテストは工夫が必要です。
別の記事にて詳細を解説していますので、気になる方はこちらをご覧ください。

https://qiita.com/Harx02663971/items/e3b02ce62d9388dd80f5

## 終わりに

本記事では、dio のインターセプターを活用して、リトライ処理やキャッシュ処理をどのように導入できるかを紹介しました。実際のアプリ開発では、通信エラーやパフォーマンス改善の観点から、これらの処理を適切に設計することが非常に重要です。

dio_smart_retry や dio_cache_interceptor といった便利なパッケージに加え、Riverpodによる依存性注入やテスト可能な設計を取り入れることで、柔軟かつ拡張性の高いネットワーク層を構築できます。

今後はぜひ、この記事で紹介したインターセプターの実装を自分のプロジェクトにも取り入れてみてください。