Using domain exceptions in your app is an important step if you want to create abstractions over different 3rd party networking libraries like Retrofit or gRPC. Handling and mapping of these exceptions can quickly become a boilerplate that you and your colleagues have to think about and sooner or later you will probably forget to do it somewhere in your code. I want to explore how this task can be done automatically in a generic and clean way when using the Retrofit library.

Disclaimer: The reasoning about domain exceptions does not apply only to networking but to any layer in your code that accesses 3rd party libraries.

Recap

This post is a follow up to my previous post about mapping into domain exceptions. To quickly summarise what it’s all about — you should abstract all logic behind low level exceptions like `IOException` or Retrofit `HttpException` inside the M part of your MV* architecture and not handle these exceptions on the presentation layer. This gives you the opportunity to switch HTTP library or the logic behind detecting `NoInternetException` without the need to change your presentation layer.

Another disclaimer: I have mentioned that you should do this mapping inside the repository layer but well, we all evolve, and now my opinion is that the correct place to do that is inside your remote data source layer.

First implementation

I’ve created a sample project that will guide us through the process of improving exceptions mapping from manual tedious work to automatic mapping. 

The example is pretty simple: User can perform two actions, download a list of recipes or download a single recipe by its id. These exceptions are defined:

/**
 * Exception when communicating with the remote api. Contains http [statusCode].
 */
data class ApiException(val statusCode: String) : Exception()

/**
 * Exception indicating that device is not connected to the internet
 */
class NoInternetException : Exception()

/**
 * Not handled unexpected exception
 */
class UnexpectedException(cause: Exception) : Exception(cause)

/**
 * Exception indicating that recipe was not found on the API
 */
data class RecipeNotFoundException(val recipeId: RecipeId) : Exception()

The first solution is based on the previous post — mapping is done in data source

interface RemoteRecipesDataSource {

    suspend fun recipes(): List<Recipe>

    suspend fun recipe(id: RecipeId): Recipe
}

class RetrofitRecipesDataSource(
    private val recipesApiDescription: RecipesApiDescription
) : RemoteRecipesDataSource {

    override suspend fun recipes(): List<Recipe> {
        return try {
            recipesApiDescription.recipes().map { it.toRecipe() }
        } catch (e: Exception) {
            throw mapToDomainException(e)
        }
    }

    override suspend fun recipe(id: RecipeId): Recipe {
        return try {
            recipesApiDescription.recipeDetail(id.value).let {
                it.toRecipe()
            }
        } catch (e: Exception) {
            throw mapToDomainException(e) {
                if (it.code() == 404) {
                    RecipeNotFoundException(id)
                } else {
                    null
                }
            }
        }
    }
}

fun mapToDomainException(
    remoteException: Exception,
    httpExceptionsMapper: (HttpException) -> Exception? = { null }
): Exception {
    return when (remoteException) {
        is IOException -> NoInternetException()
        is HttpException -> httpExceptionsMapper(remoteException) ?: ApiException(remoteException.code().toString())
        else -> UnexpectedException(remoteException)
    }
}

As you can imagine, this approach works but you need to think about this every time you call your Retrofit method and it can be easily forgotten or a new developer on the project can easily miss that. Can we do better?

Custom CallAdapter

Retrofit has the support for custom `CallAdapter` that you register during initialisation. If you used RxJava with Retrofit you would need to use one so Retrofit can recognize Rx return types like `Single` or `Completable`. We use Coroutines now for asynchronous Retrofit calls and they do not require any special `CallAdapter` since version 2.6.0 but we can still register one and use it as a generic place where mapping of exceptions can be done.

First we need to create an instance of `CallAdapter.Factory`

class ErrorsCallAdapterFactory : CallAdapter.Factory() {

    override fun get(
        returnType: Type,
        annotations: Array<Annotation>,
        retrofit: Retrofit
    ): CallAdapter<*, Call<*>>? {
        if (getRawType(returnType) != Call::class.java || returnType !is ParameterizedType || returnType.actualTypeArguments.size != 1) {
            return null
        }

        val delegate = retrofit.nextCallAdapter(this, returnType, annotations)
        @Suppress("UNCHECKED_CAST")
        return ErrorsCallAdapter(
            delegateAdapter = delegate as CallAdapter<Any, Call<*>>
        )
    }
}

We first check if the return type is really a `Call` (even though you do not specify it directly in Retrofit interface, it’s still a `Call` underground) and if it is, we ask Retrofit to give us the built-in `CallAdapter` which we pass to the custom `ErrorsCallAdapter`

class ErrorsCallAdapter(
    private val delegateAdapter: CallAdapter<Any, Call<*>>
) : CallAdapter<Any, Call<*>> by delegateAdapter {

    override fun adapt(call: Call<Any>): Call<*> {
        return delegateAdapter.adapt(CallWithErrorHandling(call))
    }
}

We delegate all implementation of `CallAdapter` class to the retrieved `CallAdapter` and only override the `adapt` method where we intercept the call and wrap it with the `CallWithErrorHandling` where the actual magic happens.

class CallWithErrorHandling(
    private val delegate: Call<Any>
) : Call<Any> by delegate {

    override fun enqueue(callback: Callback<Any>) {
        delegate.enqueue(object : Callback<Any> {
            override fun onResponse(call: Call<Any>, response: Response<Any>) {
                if (response.isSuccessful) {
                    callback.onResponse(call, response)
                } else {
                    callback.onFailure(call, mapToDomainException(HttpException(response)))
                }
            }

            override fun onFailure(call: Call<Any>, t: Throwable) {
                callback.onFailure(call, mapToDomainException(t))
            }
        })
    }

    override fun clone() = CallWithErrorHandling(delegate.clone())
}

 

The same pattern with delegating implementation of `Call` interface to the original `Call` is applied and only the `enqueue` method is overrode with the logic of mapping the exceptions.

At this point we have a generic mapping of `ApiException` or `NoInternetException` in one place! But.. there is still one issue. How would we solve the `RecipeNotFoundException`? The remote data source still needs to have `try/catch` to handle this case:

class RetrofitRecipesDataSource(
    private val recipesApiDescription: RecipesApiDescription
) : RemoteRecipesDataSource {

    override suspend fun recipes(): List<Recipe> {
        return recipesApiDescription.recipes().map { it.toRecipe() }
    }

    override suspend fun recipe(id: RecipeId): Recipe {
        return try {
            recipesApiDescription.recipeDetail(id.value).let {
                it.toRecipe()
            }
        } catch (apiException: ApiException) {
            throw if (apiException.statusCode == "404") {
                RecipeNotFoundException(id)
            } else {
                apiException
            }
        }
    }
}

 

But it’s still an improvement — now we need to handle only special cases and in 90% of other cases it’s handled for us. But can we do even better?

Invocation with custom annotations

We can leverage another great feature of the Retrofit/OkHttp which is the pair of  `okhttp3.Request.tag(Class<*>)` method and `retrofit2.Invocation` class. Each OkHttp request can contain a `tag` which can be any object instance. When Retrofit uses OkHttp under the hood while performing the HTTP request it sets the instance of `Invocation` class as this tag. When you look how the class looks it’s only a wrapper over `java.lang.reflect.Method` and list of arguments of the interface function

public final class Invocation {

  private final Method method;
  private final List<?> arguments;
}

Through `Method` we have access for example to the annotations that are defined in the Retrofit interface on the called method. Can you see where I’m heading to? We can create custom annotation

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class ExceptionsMapper(val value: KClass<out HttpExceptionMapper>)

that contains a reference to the `KClass` of `HttpExceptionMapper` class

abstract class HttpExceptionMapper(protected val callArguments: List<String>) {

    abstract fun map(httpException: HttpException): Exception?
}

and now we can create this mapper instance for the `RecipeNotFoundException`

class RecipeDetailExceptionMapper(arguments: List<String>) : HttpExceptionMapper(arguments) {

    override fun map(httpException: HttpException): Exception? {
        return if (httpException.code() == 404) {
            RecipeNotFoundException(RecipeId(callArguments.first()))
        } else {
            null
        }
    }
}

How to tie all these pieces together? We update the `CallWithErrorHandling` from previous section with this improvement:

class CallWithErrorHandling(
    private val delegate: Call<Any>
) : Call<Any> by delegate {

    override fun enqueue(callback: Callback<Any>) {
        delegate.enqueue(object : Callback<Any> {
            override fun onResponse(call: Call<Any>, response: Response<Any>) {
                if (response.isSuccessful) {
                    callback.onResponse(call, response)
                } else {
                    callback.onFailure(call, mapExceptionOfCall(call, HttpException(response)))
                }
            }

            override fun onFailure(call: Call<Any>, t: Throwable) {
                callback.onFailure(call, mapExceptionOfCall(call, t))
            }
        })
    }

    fun mapExceptionOfCall(call: Call<Any>, t: Throwable): Exception {
        val retrofitInvocation = call.request().tag(Invocation::class.java)
        val annotation = retrofitInvocation?.method()?.getAnnotation(ExceptionsMapper::class.java)
        val mapper = try {
            annotation?.value?.java?.constructors?.first()
                ?.newInstance(retrofitInvocation.arguments()) as HttpExceptionMapper
        } catch (e: Exception) {
            null
        }
        return mapToDomainException(t, mapper)
    }

    override fun clone() = CallWithErrorHandling(delegate.clone())
}

In the method `mapExceptionOfCall` first the `Invocation` is retrieved, then the `ExceptionsMapper` annotation is queried and if it can be found, new instance is created and passed to the `mapToDomainException` helper function which we know from the beginning of the post

fun mapToDomainException(
    remoteException: Throwable,
    httpExceptionsMapper: HttpExceptionMapper? = null
): Exception {
    return when (remoteException) {
        is IOException -> NoInternetException()
        is HttpException -> httpExceptionsMapper?.map(remoteException) ?: ApiException(remoteException.code().toString())
        else -> UnexpectedException(remoteException)
    }
}

Now the only thing missing is to mark the method in Retrofit interface with the annotation

interface RecipesApiDescription {

    @GET("recipes")
    suspend fun recipes(): List<ApiRecipe>

    @GET("recipes/{recipeId}")
    @ExceptionsMapper(value = RecipeDetailExceptionMapper::class)
    suspend fun recipeDetail(@Path("recipeId") recipeId: String): ApiRecipe
}

and voilá. The remote data source now may look like this

class RetrofitRecipesDataSource(
    private val recipesApiDescription: RecipesApiDescription
) : RemoteRecipesDataSource {

    override suspend fun recipes(): List<Recipe> {
        return recipesApiDescription.recipes().map { it.toRecipe() }
    }

    override suspend fun recipe(id: RecipeId): Recipe {
        return recipesApiDescription.recipeDetail(id.value).let {
            it.toRecipe()
        }
    }
}

and it does not need to handle any exception mapping.

This approach has also one additional benefit — you can have a setup of your networking Retrofit code in one gradle library module and your mapping logic can be in a feature module that has a dependency on this module. You do not need to combine all of this together in one module.

Summary

We have leveraged a great mechanism of combination of Retrofit/OkHttp libraries to mitigate the need for handling remote call exceptions in the data sources/repositories. By passing all the logic to the low level `CallAdapter` and retrieving the `Invocation` instance of the Retrofit call we can keep our data sources clean. But even if you don’t want to mess with the annotations and `Invocation`s you can still use `CallAdapter` for common error handling or mapping of the responses.

You can find sample code here. Feel free to comment or write to me on my Twitter. Thanks for your time!

Last disclaimer: The sample code is mainly for demonstration purposes, the code can be optimised by eg. caching instances of the ExceptionMapper s and so on.

Leave a Reply

Your email address will not be published. Required fields are marked *