はじめに
Dio を使って API 通信を行う中で、アクセストークンの有効期限切れ(401エラー)に備えて、QueuedInterceptor を利用したトークンのリフレッシュ処理を実装していました。
しかし、複数のリクエストが同時に 401 を返したタイミングで、アプリがデッドロック(無限待機)状態に陥る問題が発生しました。
本記事ではその原因と対処法を、備忘録としてまとめます。
排他制御の方法は他にもあると思いますので、あくまで「こんなケースもあるんだな〜」と参考程度に見ていただけたらと思います。
もし間違っていたこと書いていたら指摘いただけると嬉しいです。
結論
-
QueuedInterceptorを使ったインターセプター内で、リクエストの再試行に同じDioインスタンスを使い回してしまったのが原因だった。
その結果、リクエストがキューの中で詰まってしまい、デッドロックを引き起こしていた -
Dioでトークンリフレッシュ処理には、Interceptor が付与されていない別の Dio インスタンスを使う必要がある
背景
複数のリクエストが同時に 401 エラーを返した場合でも、アクセストークンのリフレッシュ処理は一度だけ行い、他のリクエストはその完了を待つようにしたい、という要件がありました。
この要件を満たすために、QueuedInterceptor と Completer を組み合わせて、リクエストの排他制御・競合回避を行いました。
最初に401エラーを受け取ったリクエストがリフレッシュ処理を開始し、後続のリクエストはCompleterの完了を待機するような実装を行いました。
QueuedInterceptorとは?
Dio には通常の Interceptor とは別に、リクエスト処理を順番に実行するための QueuedInterceptor という仕組みがあります。
通常のInterceptorは、複数リクエストが同時に発生すると、それぞれの onRequest や onError が並行で実行されるため、競合が発生しやすくなります。一方、QueuedInterceptorはリクエストを1件ずつ順に処理するため、こうした競合の回避に有効です。
※本記事では QueuedInterceptor の具体的な使い方は扱っていません。
何が起きたか
あるタイミングで 401 エラーが同時に複数発生し、トークンリフレッシュ処理の再リクエストが永遠に待機し続けるという、いわゆる デッドロック状態 に陥りました。
原因を調査する中で、以下の Issue を見つけました。
このコメントでは、QueuedInterceptor を使う場合、トークンリフレッシュ処理は別の Dio インスタンスで行うべき であると指摘しています。
なぜデッドロックが発生するのか
QueuedInterceptorの特性
QueuedInterceptor は、1つのリクエスト処理が完了するまで、後続のリクエストをキューに入れて待たせる仕組みを持っています。
リクエストの順序を保証できる便利な仕組みですが、
リクエスト中にさらに別のリクエストを投げるような処理(たとえばトークンのリフレッシュ) を同じキューで実行しようとすると問題が発生します。
つまり、「今のリクエストが終わらないと次に進めない」 という性質により、再試行で自分自身の完了を待つことになり、処理が永遠に進まなくなるデッドロックが発生します。
QueuedInterceptor は、先に来たリクエストが終わるまで次のリクエストを待たせる「順番待ち(キュー)」の仕組みを持っています。
今回発生したデッドロックの流れ
以下の図は、リクエストAの再試行(A’)が同じキューを通ろうとして詰まってしまった図になります。
- リクエスト A が 401 エラーを返し、トークンリフレッシュ処理を開始
- リフレッシュ完了後、リクエスト A を同じ Dio インスタンスで再試行
- この再試行リクエストも QueuedInterceptor を通るため、キューに入ろうとするが待機状態になる
- なぜなら、QueuedInterceptorは最初のリクエストAに対する
onErrorの処理が完了するまで、キューをロックしているため。
そのonErrorの処理内部で実行された再試行リクエスト(A')も同じキューに入ろうとするので「onErrorが終わるのを待つonError」という自己参照の待機状態になっている。 - 結果として、リクエスト A 自身が自分の完了を待つ状態になり、後続のリクエスト(Bなど)も止まってしまう(=デッドロック)
対応
別インスタンスでリフレッシュ処理を実行する
対策として、トークンリフレッシュだけは Interceptor のついていない別の Dio で実行することで、キューに詰まらず即実行できるようになります。
公式のサンプルコードでも、トークンリフレッシュ処理には QueuedInterceptor の付いていない 別の Dio インスタンスを使用しています。
// InterceptorなしのDioを用意(リフレッシュ専用)
final tokenRefreshDio = Dio()
どう変わるのか
以下のように、トークンリフレッシュがキューを経由しないため即時実行され、後続のリクエストも詰まらずに処理が進みます。
このように、トークンリフレッシュ処理だけ別インスタンスのDioで通すことで、キューと干渉せず、安全に再試行できるようになります。
参考