Android
Kotlin

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() は次のように実装できます。

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() を呼び出して問題ありません