Coroutines and Retrofit, best way to handle Errors

GuilhE picture GuilhE · Sep 24, 2019 · Viewed 8.3k times · Source

After reading this issue How to deal with exception and this Medium Android Networking in 2019 — Retrofit with Kotlin’s Coroutines I've created my solution which consist in a BaseService capable of making the retrofit call and forward the results and exceptions down the "chain":

API

@GET("...")
suspend fun fetchMyObject(): Response<List<MyObject>>

BaseService

protected suspend fun <T : Any> apiCall(call: suspend () -> Response<T>): Result<T> {
    val response: Response<T>
    try {
        response = call.invoke()
    } catch (t: Throwable) {
        return Result.Error(mapNetworkThrowable(t))
    }
    if (!response.isSuccessful) {
        return Result.Error...
    }
    return Result.Success(response.body()!!)
}

ChildService

suspend fun fetchMyObject(): Result<List<MyObject>> {
    return apiCall(call = { api.fetchMyObject() })
}

Repo

    suspend fun myObjectList(): List<MyObject> {
        return withContext(Dispatchers.IO) {
            when (val result = service.fetchMyObject()) {
                is Result.Success -> result.data
                is Result.Error -> throw result.exception
            }
        }
    }

Note: sometimes we need more than throwing an exception or one type of success result. To handle those situations this is how we can achieve that:

sealed class SomeApiResult<out T : Any> {
    object Success : SomeApiResult<Unit>()
    object NoAccount : SomeApiResult<Unit>()
    sealed class Error(val exception: Exception) : SomeApiResult<Nothing>() {
        class Generic(exception: Exception) : Error(exception)
        class Error1(exception: Exception) : Error(exception)
        class Error2(exception: Exception) : Error(exception)
        class Error3(exception: Exception) : Error(exception)
    }
} 

And then in our ViewModel:

when (result: SomeApiResult) {
    is SomeApiResult.Success -> {...}
    is SomeApiResult.NoAccount -> {...}
    is SomeApiResult.Error.Error1 -> {...}
    is SomeApiResult.Error -> {/*all other*/...}
}

More about this approach here.

BaseViewModel

protected suspend fun <T : Any> safeCall(call: suspend () -> T): T? {
    try {
        return call()
    } catch (e: Throwable) {
        parseError(e)
    }
    return null
}

ChildViewModel

fun fetchMyObjectList() {
    viewModelScope.launch {
        safeCall(call = {
            repo.myObjectList()
            //update ui, etc..
        })
    }
}

I think the ViewModel (or a BaseViewModel) should be the layer handling the exceptions, because in this layer lies the UI decision logic, for example, if we just want to show a toast, ignore a type of exception, call another function etc...

What do you think?

EDIT: I've created a medium with this topic

Answer

coroutineDispatcher picture coroutineDispatcher · Sep 25, 2019

I think the ViewModel (or a BaseViewModel) should be the layer handling the exceptions, because in this layer lies the UI decision logic, for example, if we just want to show a toast, ignore a type of exception, call another function etc...

What do you think?

Certainly, you are correct. The coroutine should fire on the ViewModel even though the logic is in the Repository/Service. That's why Google has already created a special coroutineScope called viewModelScope, otherwise it would be "repositoryScope" . Also coroutines have a nice feature on exception handling called the CoroutineExceptionHandler. This is where things get nicer because you don't have to implement a try{}catch{} block:

val coroutineExceptionHanlder = CoroutineExceptionHandler{_, throwable -> 
    throwable.printStackTrance()
    toastLiveData.value = showToastValueWhatever()
}

Later in the ViewModel

coroutineScope.launch(Dispatchers.IO + coroutineExceptionHanlder){
      val data = serviceOrRepo.getData()
}

Of course you can still use the try/catch block without the CoroutineExceptionHandler, free to choose.

Notice that in case of Retrofit you don't need the Dispatchers.IO scheduler because Retrofit does that for you (since Retrofit 2.6.0).

Anyways, I can't say the article is bad, but it is not the best solution. If you want to follow the articles' guide, you may want to check Transformations on the LiveData.

EDIT: What you need to know more, is that coroutines are not safe. What I mean with this is that they might cause memory leaks especially in Android lifecycle overall. You need a way to cancel coroutines while the Activity/Fragment doesn't live anymore. Since the ViewModel has the onCleared (which is called when Activity/Fragment is destroyed), this implies that coroutines should fire in one of them. And perhaps this is the main reason why you should start the coroutine in the ViewModel. Notice that with the viewModelScope there is no need to cancel a job onCleared.

A simple example:

viewModelScope.launch(Dispatchers.IO) {
   val data = getDataSlowly()
   withContext(Dispatchers.MAIN) {
      showData();
   }
} 

or without the viewModelScope :

val job = Job()
val coroutineScope = CoroutineContext(Dispatchers.MAIN + job)

fun fetchData() {
   coroutineScope.launch() {
   val data = getDataSlowly()
       withContext(Dispatchers.MAIN) {
          showData();
       }
   }
}

//later in the viewmodel:

override fun onCleared(){
  super.onCleared()
  job.cancel() //to prevent leaks
}

Otherwise, your Service/Repository would leak. Another note is NOT to use the GlobalScope in this cases.