OkHttp3 Authenticatorによる認証の再試行の実装 in 2023.12

Fumiya Sato
スタディスト Tech Blog
10 min readDec 19, 2023

--

スタディスト Tech Blog Advent Calendar 2023の19日目の記事です。Androidアプリエンジニアの佐藤がお送りします。

従来よりAndroid/Kotlinネイティブの環境では、OkHttp3/Retrofit2を利用したWebAPI通信を行っているプロダクトが多い印象ですが、OkHttp3のInterceptorやAuthenticatorなどのリクエストヘッダへの操作に関する動作・仕様について今はどうなってるのか、たまに必要に駆られて、調べては沼にハマったりしませんか?(筆者は必要になる時がごくまれによくあります笑)

その上で、特に先述について調べていくと、記事がやや古くて現在との乖離がないか、心配になることが多くて、内部の実装を見に行ったりすることが往々にしてあるかと思います。

そこで今回は、Authenticatorの動作に絞って、2023年12月現在でも動作するのかについて改めて調査したので、ここにまとめていきます。

前提環境

  • kotlin : 1.9.0
  • OkHttp3 : 4.12.0

Authenticatorとは

最初のリクエスト、認証に失敗した際(401エラーコード)、再度認証にチャレンジするための機構です。
OkHttpクライアントをビルドする際に、Builderパターンで数珠繋ぎに設定します。(設定方法は後述しています)

リクエストヘッダに含めた認証情報(アクセストークン等)をここでリフレッシュする用途でよく利用されていると存じます。

また、Proxyサーバを通じた、407エラーコードに基づいた認証チャレンジにも利用されますが、今回は割愛します。

Authenticatorの裏側

再試行がどう行われているのか、流れを追ってみます。

RetryAndFollowUpInterceptor がクライアントに追加されることで、認証の再試行が行われるようになります。

以下は RealCall クラスの一部です。
RealCallによって、実際の通信が行われてます。
以下の様に上記Interceptorがリストに追加され、 RealInterceptorChain としてまとめられた後に、順次リクエストする際に Interceptors が実行されるようになっています。
詳しくはご自身のプロジェクト等より参照を辿ってみてください。

internal fun getResponseWithInterceptorChain(): Response {
// Build a full stack of interceptors.
val interceptors = mutableListOf<Interceptor>()
interceptors += client.interceptors
// ここで追加される
interceptors += RetryAndFollowUpInterceptor(client)
interceptors += BridgeInterceptor(client.cookieJar)
interceptors += CacheInterceptor(client.cache)
interceptors += ConnectInterceptor

// チェイン専用のクラスに渡される
val chain = RealInterceptorChain(
call = this,
interceptors = interceptors,
index = 0,
exchange = null,
request = originalRequest,
connectTimeoutMillis = client.connectTimeoutMillis,
readTimeoutMillis = client.readTimeoutMillis,
writeTimeoutMillis = client.writeTimeoutMillis
)

var calledNoMoreExchanges = false
try {
// 順次実行される
val response = chain.proceed(originalRequest)
if (isCanceled()) {
response.closeQuietly()
throw IOException("Canceled")
}
return response
} catch (e: IOException) {
calledNoMoreExchanges = true
throw noMoreExchanges(e) as Throwable
} finally {
if (!calledNoMoreExchanges) {
noMoreExchanges(null)
}
}
}
}

その上で、 RetryAndFollowUpInterceptor の中を見てみます。
今回は再試行の実態と方法について、以下の2点に着目してみます。

  • 認証の再試行は無限に行われるのか?
  • 再試行中のエラーハンドリングはどうなっているのか?

まずは再試行は無限に行われるのかについてみてみます。
以下のスニペットをご覧ください。

if (++followUpCount > MAX_FOLLOW_UPS) {
throw ProtocolException("Too many follow-up requests: $followUpCount")
}

これは実際の intercept のメソッド内の処理です。
この条件式を満たすと、次の再試行をせずに例外を投げます。
MAX_FOLLOW_UPS を超えた場合に諦めますが、この値は 20 に設定されています。つまり、再試行は最大で20回まで実行されます。

続いて、再試行中の接続エラーのハンドリングについてみてみます。

勘違いしやすいですが、再試行自体のトリガーはあくまで、401か407エラーコードを返した場合で行われます。
その過程での接続に、なんらかの問題が発生した場合にOkHttpクライアントは静かにその接続の回復を試みますが、デフォルトでは失敗した場合には再試行を止めます。

特に調査していると出くわすフラグとして、 retryOnConnectionFailure が挙げられますが、失敗した場合には直ちに再試行を失敗として終了させることが可能で、このフラグはユーザー側で制御することができます。

このフラグは、以下の条件を満たす場合の接続問題から回復した場合に再試行するかどうかを制御します。

  • IPアドレスに到達できない
  • 古いプールされたコネクションを利用した
  • プロキシに到達できない

勘違いしやすいと先述しましたが、筆者は再試行自体のトリガーがこれだと思ってしまって、やや混乱してしまいました。
あくまで、401または407のエラーコードによって再試行が走り、再試行の接続に問題があった場合に続行するかどうかを決めるフラグだと理解しましょう。

ほかにも再試行するかどうかの条件が存在しますので、以下のスニペットを参考にしつつ、こちらもご自身のプロジェクト等から参照を辿るなどしてみてください。

// 再試行の接続問題が回復するかどうかを、このメソッドで判定されています。
// trueの場合は、接続が回復することを指しているので、もう一度再試行されます
// falseの場合は、接続が回復不能であることを指しているので、再試行はキャンセルされます
private fun recover(
e: IOException,
call: RealCall,
userRequest: Request,
requestSendStarted: Boolean
): Boolean {
// The application layer has forbidden retries.
if (!client.retryOnConnectionFailure) return false

// We can't send the request body again.
if (requestSendStarted && requestIsOneShot(e, userRequest)) return false

// This exception is fatal.
if (!isRecoverable(e, requestSendStarted)) return false

// No more routes to attempt.
if (!call.retryAfterFailure()) return false

// For failure recovery, use the same route selector with a new connection.
return true
}

再試行回数の絞り方

再試行回数の絞り方をスニペットでご紹介します。
ポイントは3つです。

  • レスポンスは、先にリクエストに挑戦した結果のレスポンス “priorReponse” として参照が紐づく。
  • リクエストに対して必ずResponseが紐づくので、カウントは1から始まる
  • nullを返すことで認証チャレンジを諦め(Give up)られる。

上記をもとに、認証回数を制限してみましょう。
最初のリクエストも含めて3回に制限してみます。

以下スニペットが一例です。

val retryLimit = 3
OkHttpClient.Builder()
.addInterceptor { chain ->
// 省略
chain.proceed(request)
}
.authenticator(object : Authenticator {
// 実際のリトライ回数は、ResponseごとにpriorResponseの参照があるので、
// 数珠繋ぎで数えることができる。
// また、リクエストに対して必ずResponseがあるので、カウントの初期値は1である。
private val okhttp3.Response.responseCount: Int
get() = generateSequence(this) { it.priorResponse }.count()

override fun authenticate(route: Route?, response: okhttp3.Response): Request? {
if (response.responseCount > retryLimit) {
// nullを返すことで再試行を抑止可能
return null
}

// 省略 認証に必要なトークン等の情報を取得するなどの処理
return RequestWithToken(response.request)
}
}).build()

最後に

2023年現在でも、Authenticatorの再試行回数に関する仕様と、絞り方自体に変更はなさそうでした。
現在、OkHttp 5.x系の開発が進んでいるようで、alpha版まで出ているので5.x系で変わってしまうかどうかは要確認だと自分も思いました。
言語やフレームワークやら、さまざまな更新をフォローアップしていくのはなかなかコストが高いですが、陳腐化を防ぐためにも頑張っていこうと思う所存です。

We’re hiring!

スタディストでは、“伝えることを、もっと簡単に” するために、一緒に働く仲間を探しています。

開発組織に興味を持たれた方はお気軽にご連絡ください!

https://studist-engineering.gitbook.io/entrance-book

--

--