🔌

【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通信の中に任意の処理を挟める機能であると説明しました。
通信に挟めたい機能として主要なものはリトライとキャッシュが挙げられます。

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

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

設定の仕方

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

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

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

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

  return dio;
}

インターセプターを作る方法

以下の二つの方法があります。

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

基本はこの方法が推奨されます。

class ExampleInterceptor extends Interceptor {
  const ExampleInterceptor();

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

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

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

  
  void onError(DioException err, ErrorInterceptorHandler handler) {
    // このインターセプターで挟みたい処理を記述
    //...
    handler.next(err); // 次のInterceptorに進む
  }
}

追加する時は以下のように設定します。

  dio.interceptors.addAll([
    ExampleInterceptor();
  ]);

InterceptorsWrapper を使う

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

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
    },
  ),
);

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

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

  
  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 では処理する必要がないので、 onRequestonError はオーバーライドしていません。

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

リトライ機能を dio_smart_retry で簡単に実装する

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

https://pub.dev/packages/dio_smart_retry

使い方は公式通りに行うと以下のような形です。

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: リトライ回数で初期値は3回
  • retryDelays: リトライを実行する間隔で初期値は1秒->3秒->5秒

簡単に動作を確認するだけなら上記で良いのですが、実際には以下の問題があります。

  • 依存が強すぎてもしもの時に差し替えがしづらい
  • テストがしづらい
  • リトライする内容ごとにインターセプターを分けられない

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

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

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

lib/data/sources/http/interceptors/retry/retry_interceptor.dart
(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

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

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

lib/data/sources/http/interceptors/retry/auth_error_retry_interceptor.dart
(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内かテストのみで使用する
  
  static bool retryEvaluator(DioException error, int attempt) {
    if (error.type == DioExceptionType.badResponse &&
        error.response?.statusCode == 401) {
      return true;
    }

    return false;
  }

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

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

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

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

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

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

NetworkRetryInterceptor
lib/data/sources/http/interceptors/retry/network_retry_interceptor.dart
(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));

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

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

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

dioにセットする

lib/data/sources/http/client/poke_api_http_client_with_interceptors.dart
(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で追加するだけです。

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');

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

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 の依存性を注入する

lib/data/sources/http/interceptors/cache/dio_cache_interceptor.dart
(keepAlive: true)
DioCacheInterceptor dioCacheInterceptor(Ref ref) {
  return DioCacheInterceptor(
    options: CacheOptions(
      store: MemCacheStore(),
      maxStale: const Duration(minutes: 10),
    ),
  );
}

ここで options に設定値を渡しています。ほとんどはデフォルトの設定をつかています。
キャッシュ場所は MemCacheStore とし、キャッシュを保持する時間の設定 maxStale を10分にしています。

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

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);

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

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

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

テストについて

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

https://zenn.dev/harx/articles/7b45826886de60

終わりに

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

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

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

Discussion