Edited at

RetrofitのKotlin Coroutine Adapterにひそむ罠


はじめに

RetrofitKotlin Coroutine Adapter を使ってHTTP通信を行うAndroidアプリを開発しているのですが、Coroutineのキャンセル後にHTTP通信の例外が発生すると、思いがけず例外が未キャッチになる → アプリがクラッシュする、という挙動があり、ハマったので、問題の内容と解決方法をシェアします。


問題の説明


コード例

例えば、Retrofitで次のようにHTTP API呼び出しを定義し

interface GitHubService {

@GET("users/{user}/repos")
fun listRepos(@Path("user") user: String): Deferred<List<Repo>>
}

これをViewModelから利用することを考えます1

class MyViewModel : ViewModel() {

private val viewModelJob = Job()
private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)

private val service: GitHubService by lazy {
// 略: Retrofitを使って GitHubService の実体を作成する
}

override fun onCleared() {
super.onCleared()
viewModelJob.cancel()
}

fun showMyGitHubRepos() {
uiScope.launch {
try {
val deferred = service.listRepos("kafumi")
val repos = deferred.await()
showRepos(repos)
} catch (e: Throwable) {
showError(e)
}
}
}

// ...
}

API呼び出しに成功したら showRepos() を呼び出し、何らかの例外が発生したら、例外の種類によらず全てキャッチして showError() を呼び出す、という実装です。HTTP通信まわりでどのような例外が発生しても、未キャッチ例外となることはないように見えます。


問題となるケース

さて、uiScope.launch で作成されたCoroutineが await() で中断している間 (= HTTP通信のレスポンスを待っている間) に、ユーザー操作によって画面が終了して、MyViewModel.onCleared() が呼び出されたらどうなるでしょうか? つまり、HTTP通信中に画面が終了されて、その後にHTTP通信がエラーになる、というケースです。

今回のCoroutineは uiScope をスコープとして作成されており、viewModelJob が親Jobとして設定されているので、viewModelJob.cancel() が呼ばれると即座にキャンセルされ、await() から CancellationException が投げられます (参考: await のリファレンス)。


キャンセルされない Deferred

ここで、ViewModelのクリアによってCoroutineがキャンセルされ、await() での中断もキャンセルされるのですが、service.listRepos("kafumi") から返される Deferred は実はキャンセルされていません。理由は、Kotlin Coroutine Adapterの実装にあります。

  private class BodyCallAdapter<T>(

private val responseType: Type
) : CallAdapter<T, Deferred<T>> {

override fun responseType() = responseType

override fun adapt(call: Call<T>): Deferred<T> {
val deferred = CompletableDeferred<T>()

deferred.invokeOnCompletion {
if (deferred.isCancelled) {
call.cancel()
}
}

call.enqueue(object : Callback<T> {
override fun onFailure(call: Call<T>, t: Throwable) {
deferred.completeExceptionally(t)
}

override fun onResponse(call: Call<T>, response: Response<T>) {
deferred.complete(response)
}
})

return deferred
}
}

Kotlin Coroutine Adapterは、上記のように CompletableDeferred を作り、それを Deferred として返すのですが、CompletableDeferred を作るときに親Jobを設定していません (呼び出し側の CoroutineContext が取得できないので仕方ありません)。アプリ側のCoroutineがキャンセルされたとしても、親子関係がないのでキャンセルが伝播せず、この CompletableDeferred はキャンセルされない、というわけです。

なお、Coroutineのキャンセル時に await() から CancellationException が投げられますが、これは、await() での中断がキャンセルされるために発生しており、CompletableDeferred がキャンセルされているわけではありません。


キャッチされない例外

Coroutineがキャンセルされても、上記のように CompletableDeferred はキャンセルされていないので、RetrofitによるHTTP通信もキャンセルされておらず、レスポンス待ちの状態が続いています。この状態で、HTTP通信が失敗して、例外が発生したらどうなるでしょうか。HTTP通信やJSONの変換で例外が発生すると、Retrofitから Callback.onFailure() がコールバックされますが、このとき、Kotlin Coroutine Adapterは CompletableDeferredcompleteExceptionally() を呼び出します。

      call.enqueue(object : Callback<T> {

override fun onFailure(call: Call<T>, t: Throwable) {
deferred.completeExceptionally(t)
}

この時点で deferred はまだ未完了の CompletableDeferred なので、completeExceptionally() が呼ばれると、失敗として完了するための処理が実行されます。await() における中断はCoroutineのキャンセルによりすでにキャンセルされているので、発生した例外は await の継続処理 (try-catch がある上記のMyViewHolder のコード) には送られず、CoroutineExceptionHandler に送られます。MyViewHolder では CoroutineContextCoroutineExceptionHandler を設定していないので、これは、CoroutineExceptionHandlerのリファレンスの説明の通りに処理されます。


By default, when no handler is installed, uncaught exception are handled in the following way:

- If exception is CancellationException then it is ignored (because that is the supposed mechanism to cancel the running coroutine)

- Otherwise:

- if there is a Job in the context, then Job.cancel is invoked;

- Otherwise, all instances of CoroutineExceptionHandler found via ServiceLoader

- and current thread’s Thread.uncaughtExceptionHandler are invoked.


つまり、最終的に Thread.uncaughtExceptionHandler に未キャッチ例外として処理され、Androidの場合はアプリの強制終了を引き起こします。


問題の解決方法


案1: CoroutineExceptionHandlerを明示的に設定する

CoroutineContextCoroutineExceptionHandler を設定することで、未キャッチ例外をアプリで処理して、Thread.uncaughtExceptionHandler への送出を防ぐことができます。

val defaultExceptionHandler = CoroutineExceptionHandler { _, e ->

Log.w("Coroutine", "uncaught coroutine exception", e)
}

class MyViewModel : ViewModel() {
private val viewModelJob = Job()
private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob + defaultExceptionHandler)

手軽ではありますが、別のケースの例外も握りつぶしてしまっている、例外に気がつきにくくなる、という欠点もあります。


案2: Kotlin Coroutine Adapterを使うのをやめる

Kotlin Coroutine Adapterの利用をやめて、自前の処理で Call をsuspend関数に変換することで、Coroutineキャンセル後の例外を無視する、という方法もあります。例えば、suspendCancellableCoroutine を使うことで、次のように Call の拡張関数として実装できます2

suspend fun <T> Call<T>.await(): Response<T> = suspendCancellableCoroutine { cont ->

enqueue(object : Callback<T> {
override fun onFailure(call: Call<T>, t: Throwable) {
if (!cont.isCancelled) {
cont.resumeWithException(t)
}
}

override fun onResponse(call: Call<T>, response: Response<T>) {
cont.resume(response)
}
})

cont.invokeOnCancellation {
cancel()
}
}

この場合、Retrofit のインターフェースは Call を返すように修正する必要があります。

interface GitHubService {

@GET("users/{user}/repos")
fun listRepos(@Path("user") user: String): Call<List<Repo>>
}

呼び出し側の使い勝手は、Kotlin Coroutine Adapterを利用しているときと同等です。

        uiScope.launch {

try {
val call = service.listRepos("kafumi")
val repos = call.await()
showRepos(repos)
} catch (e: Throwable) {
showError(e)
}
}

RetrofitのAdapterではsuspend関数を実装できませんが、Call の拡張関数であればsuspend関数として実装でき、Deferred を使わなくてもCoroutinesに適した処理に変換できます。


おまけ: Responseが不要な場合

Response が不要な場合、await() は次のように実装できます 2

suspend fun <T> Call<T>.awaitBody(): T = suspendCancellableCoroutine { cont ->

enqueue(object : Callback<T> {
override fun onFailure(call: Call<T>, t: Throwable) {
if (!cont.isCancelled) {
cont.resumeWithException(t)
}
}

override fun onResponse(call: Call<T>, response: Response<T>) {
if (response.isSuccessful) {
cont.resume(response.body()!!)
} else if (!cont.isCancelled) {
cont.resumeWithException(HttpException(response))
}
}
})

cont.invokeOnCancellation {
cancel()
}
}





  1. JetPackを使う際の推奨設計に従うなら、Repositoryモデルを採用し、ViewModelからRetrofitのAPIを直接呼び出すべきではないですが、今回はコード量を抑えるために簡略化しています 



  2. kotlinx.coroutines1.1.0-alpha から CancellableContinuation のキャンセル後の例外は無視されるようになったので、1.1.0-alpha 以降のバージョンを使っている場合は、cont.isCancelled の検査は不要で、失敗時に常に cont.resumeWithException() を呼び出して問題ありません