Elegant way of handling error using Retrofit + Kotlin Flow

I have a favorite way of doing network request on Android (using Retrofit). It looks like this:

// NetworkApi.kt

interface NetworkApi {
  @GET("users")
  suspend fun getUsers(): List<User>
}

And in my ViewModel:

// MyViewModel.kt

class MyViewModel(private val networkApi: NetworkApi): ViewModel() {
  val usersLiveData = flow {
    emit(networkApi.getUsers())
  }.asLiveData()
}

Finally, in my Activity/Fragment:

//MyActivity.kt

class MyActivity: AppCompatActivity() {
  private viewModel: MyViewModel by viewModels()

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    viewModel.usersLiveData.observe(this) {
      // Update the UI here
    }
  }
}

The reason I like this way is because it natively works with Kotlin flow, which is very easy to use, and has a lot of useful operations (flatMap, etc).

However, I am not sure how to elegantly handle network errors using this method. One approach that I can think of is to use Response<T> as the return type of the network API, like this:

// NetworkApi.kt

interface NetworkApi {
  @GET("users")
  suspend fun getUsers(): Response<List<User>>
}

Then in my view model, I can have an if-else to check the isSuccessful of the response, and get the real result using the .body() API if it is successful. But it will be problematic when I do some transformation in my view model. E.g.

// MyViewModel.kt
class MyViewModel(private val networkApi: NetworkApi): ViewModel() {
  val usersLiveData = flow {
    val response = networkApi.getUsers()
    if (response.isSuccessful) {
      emit(response.body()) // response.body() will be List<User>
    } else {
       // What should I do here?
    }
  }.map { // it: List<User>
    // transform Users to some other class
    it?.map { oneUser -> OtherClass(oneUser.userName) }
  }.asLiveData()

Note the comment "What should I do here?". I don't know what to do in that case. I could wrap the responseBody (in this case, a list of Users) with some "status" (or simply just pass through the response itself). But that means that I pretty much have to use an if-else to check the status at every step through the flow transformation chain, all the way up to the UI. If the chain is really long (e.g. I have 10 map or flatMapConcat on the chain), it is really annoying to do it in every step.

What is the best way to handle network errors in this case, please?


Solution 1:

You should have a sealed class to handle for different type of event. For example, Success, Error or Loading. Here is some of the example that fits your usecases.

enum class ApiStatus{
    SUCCESS,
    ERROR,
    LOADING
}  // for your case might be simplify to use only sealed class

sealed class ApiResult <out T> (val status: ApiStatus, val data: T?, val message:String?) {

    data class Success<out R>(val _data: R?): ApiResult<R>(
        status = ApiStatus.SUCCESS,
        data = _data,
        message = null
    )

    data class Error(val exception: String): ApiResult<Nothing>(
        status = ApiStatus.ERROR,
        data = null,
        message = exception
    )

    data class Loading<out R>(val _data: R?, val isLoading: Boolean): ApiResult<R>(
        status = ApiStatus.LOADING,
        data = _data,
        message = null
    )
}

Then, in your ViewModel,

class MyViewModel(private val networkApi: NetworkApi): ViewModel() {

  // this should be returned as a function, not a variable
  val usersLiveData = flow {
    emit(ApiResult.Loading(true))   // 1. Loading State
    val response = networkApi.getUsers()
    if (response.isSuccessful) {
      emit(ApiResult.Success(response.body()))   // 2. Success State
    } else {
       val errorMsg = response.errorBody()?.string()
       response.errorBody()?.close()  // remember to close it after getting the stream of error body
       emit(ApiResult.Error(errorMsg))  // 3. Error State
    }
  }.map { // it: List<User>
    // transform Users to some other class
    it?.map { oneUser -> OtherClass(oneUser.userName) }
  }.asLiveData()

In your view (Activity/Fragment), observe these state.

 viewModel.usersLiveData.observe(this) { result ->
      // Update the UI here
    when(result.status) {
      ApiResult.Success ->  {
         val data = result.data  <-- return List<User>
      }
      ApiResult.Error ->   {
         val errorMsg = result.message  <-- return errorBody().string()
      }
      ApiResult.Loading ->  {
         // here will actually set the state as Loading 
         // you may put your loading indicator here.  
      }
    }
 }

Solution 2:

//this class represent load statement management operation /*

  • What is a sealed class
  • A sealed class is an abstract class with a restricted class hierarchy.
  • Classes that inherit from it have to be in the same file as the sealed class.
  • This provides more control over the inheritance. They are restricted but also allow freedom in state representation.
  • Sealed classes can nest data classes, classes, objects, and also other sealed classes.
  • The autocomplete feature shines when dealing with other sealed classes.
  • This is because the IDE can detect the branches within these classes.
  • */

ٍٍٍٍٍ

sealed class APIResponse<out T>{

    class Success<T>(response: Response<T>): APIResponse<T>() {
        val data = response.body()
    }


    class Failure<T>(response: Response<T>): APIResponse<T>() {
        val message:String = response.errorBody().toString()
    }


    class Exception<T>(throwable: Throwable): APIResponse<T>() {
        val message:String? = throwable.localizedMessage
    }


}

create extention file called APIResponsrEX.kt and create extextion method

fun <T> APIResponse<T>.onSuccess(onResult :APIResponse.Success<T>.() -> Unit) : APIResponse<T>{
    if (this is APIResponse.Success) onResult(this)
    return this
}

fun <T> APIResponse<T>.onFailure(onResult: APIResponse.Failure<*>.() -> Unit) : APIResponse<T>{
    if (this is APIResponse.Failure<*>)
        onResult(this)
    return this
}

fun <T> APIResponse<T>.onException(onResult: APIResponse.Exception<*>.() -> Unit) : APIResponse<T>{
    if (this is APIResponse.Exception<*>) onResult(this)
    return this
}

merge it with Retrofit

inline fun <T> Call<T>.request(crossinline onResult: (response: APIResponse<T>) -> Unit) {
    enqueue(object : retrofit2.Callback<T> {
        override fun onResponse(call: Call<T>, response: Response<T>) {
            if (response.isSuccessful) {
               // success
                onResult(APIResponse.Success(response))
            } else {
               //failure 
                onResult(APIResponse.Failure(response))
            }
        }

        override fun onFailure(call: Call<T>, throwable: Throwable) {
            onResult(APIResponse.Exception(throwable))
        }
    })
}