RetrofitのKotlin Coroutine Adapterにひそむ罠

# はじめに

[Retrofit](https://github.com/square/retrofit) と [Kotlin Coroutine Adapter](https://github.com/JakeWharton/retrofit2-kotlin-coroutines-adapter) を使ってHTTP通信を行うAndroidアプリを開発しているのですが、Coroutineのキャンセル後にHTTP通信の例外が発生すると、思いがけず例外が未キャッチになる → アプリがクラッシュする、という挙動があり、ハマったので、問題の内容と解決方法をシェアします。

# 問題の説明

## コード例

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

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

これをViewModelから利用することを考えます[^1]。

```kotlin
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](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-cancellation-exception/index.html) が投げられます (参考: [await のリファレンス](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-deferred/await.html))。

## キャンセルされない Deferred

ここで、ViewModelのクリアによってCoroutineがキャンセルされ、`await()` での中断もキャンセルされるのですが、`service.listRepos("kafumi")` から返される `Deferred` は実はキャンセルされていません。理由は、[Kotlin Coroutine Adapter](https://github.com/JakeWharton/retrofit2-kotlin-coroutines-adapter/blob/2a70c13dc61b4377170bc932da15731d878aa29c/src/main/java/com/jakewharton/retrofit2/adapter/kotlin/coroutines/CoroutineCallAdapterFactory.kt#L86-L110)の実装にあります。

```kotlin
  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](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-completable-deferred/) を作り、それを `Deferred` として返すのですが、`CompletableDeferred` を作るときに親Jobを設定していません (呼び出し側の `CoroutineContext` が取得できないので仕方ありません)。アプリ側のCoroutineがキャンセルされたとしても、親子関係がないのでキャンセルが伝播せず、この  `CompletableDeferred` はキャンセルされない、というわけです。

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

## キャッチされない例外

Coroutineがキャンセルされても、上記のように `CompletableDeferred` はキャンセルされていないので、RetrofitによるHTTP通信もキャンセルされておらず、レスポンス待ちの状態が続いています。この状態で、HTTP通信が失敗して、例外が発生したらどうなるでしょうか。HTTP通信やJSONの変換で例外が発生すると、Retrofitから `Callback.onFailure()` がコールバックされますが、このとき、Kotlin Coroutine Adapterは `CompletableDeferred` の [completeExceptionally()](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-completable-deferred/complete-exceptionally.html) を呼び出します。

```kotlin
      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](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-exception-handler/) に送られます。`MyViewHolder` では `CoroutineContext` に `CoroutineExceptionHandler` を設定していないので、これは、[CoroutineExceptionHandlerのリファレンス](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-exception-handler/)の説明の通りに処理されます。

> By default, when no handler is installed, uncaught exception are handled in the following way:
> - If exception is [CancellationException](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-cancellation-exception/index.html) then it is ignored (because that is the supposed mechanism to cancel the running coroutine)
> - Otherwise:
>   - if there is a [Job](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-job/index.html) in the context, then [Job.cancel](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-job/cancel.html) is invoked;
>   - Otherwise, all instances of [CoroutineExceptionHandler](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-exception-handler/index.md) found via [ServiceLoader](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-exception-handler/#)
>   - and current thread’s [Thread.uncaughtExceptionHandler](http://docs.oracle.com/javase/8/docs/api/java/lang/Thread.html#uncaughtExceptionHandler) are invoked.

つまり、最終的に [Thread.uncaughtExceptionHandler](http://docs.oracle.com/javase/8/docs/api/java/lang/Thread.html#uncaughtExceptionHandler) に未キャッチ例外として処理され、Androidの場合はアプリの強制終了を引き起こします。

# 問題の解決方法

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

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

```kotlin
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](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/suspend-cancellable-coroutine.html) を使うことで、次のように `Call` の拡張関数として実装できます[^2]。

```kotlin
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` を返すように修正する必要があります。

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

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

```kotlin
        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]。

```kotlin
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を使う際の推奨設計](https://developer.android.com/jetpack/docs/guide#recommended-app-arch)に従うなら、Repositoryモデルを採用し、ViewModelからRetrofitのAPIを直接呼び出すべきではないですが、今回はコード量を抑えるために簡略化しています

[^2]: [kotlinx.coroutines](https://github.com/Kotlin/kotlinx.coroutines) の [1.1.0-alpha](https://github.com/Kotlin/kotlinx.coroutines/blob/master/CHANGES.md#version-110-alpha) から [`CancellableContinuation` のキャンセル後の例外は無視されるようになった](https://github.com/Kotlin/kotlinx.coroutines/issues/830#issuecomment-447819778)ので、1.1.0-alpha 以降のバージョンを使っている場合は、`cont.isCancelled` の検査は不要で、失敗時に常に `cont.resumeWithException()` を呼び出して問題ありません