Kotlin, Spring & Spring Boot

Error Handling in Retrofit Using NetworkResponseAdapter Library

I’m currently working with my team on a new project. One part of it is a gateway service that distributes calls between microservices and/or merges results. We’ve been looking for a good solution to properly make requests using coroutines. Another requirement was to handle responses from the API properly so that we wouldn’t need to manually handle all common error results or IO exceptions.

After looking for some options, we found Retrofit to be the perfect fit for our purpose. However, it wasn’t easy to find a proper solution to handle successful and failed responses. I’d like to give you a simple example of how to configure Retrofit using the NetworkResponseAdapter library to properly and gracefully handle responses in your code with a simple extension function.

Let’s define a simple Retrofit client interface with one method to get a list of categories:

interface CatalogClient {
    @GET("/catalog/categories")
    suspend fun getCategories(@Path("userId") userId: String): List<CategoryResponse>
}

data class CategoryResponse(
    val id: String,
    val name: String,
)

The problem with this definition is that when there is an exception, we will expose the network exception directly to our application layer. It is not a great solution, and it’s better to throw some contextual exceptions connected with what the client is doing. To achieve this goal, we defined our own exception:

class CatalogIOException(
    val payload: String,
    val code: Int,
    cause: Throwable? = null,
) : IOException()

We added one line to the Retrofit builder:

.addCallAdapterFactory(NetworkResponseAdapterFactory())

Then the client was modified to use NetworkResponseAdapter:

interface CatalogClient {
    @GET("/catalog/categories")
    suspend fun getCategories(@Path("userId") userId: String): NetworkResponse<List<CategoryResponse>, DetailsError>
}

data class CategoryResponse(
    val id: String,
    val name: String,
)

data class DetailsError(
    val code: String,
    val message: String,
    val details: String
)

NetworkResponse is a sealed class whose result can be one of the classes below:

  • NetworkResponse.Success: an object containing the successful result with response data for any 2XX response.
  • NetworkResponse.ServerError: an object that contains the response from the call that resulted in a non-2XX status.
  • NetworkResponse.NetworkError: the result of a request that didn’t result in a response.
  • NetworkResponse.UnknownError: the result of a request that resulted in an error different from an IO or Server error.
  • NetworkResponse.Error: an object for any other possible response not covered by any of the previous cases.

As you noticed, we need to provide the type arguments for deserialization: first for the Success response and the second for the ServerError response.

To achieve the goal of easily handling all exceptions/errors in the same manner, we introduced an extension function to the NetworkResponse class:

fun <T: Any> NetworkResponse<T, DetailsError>.call(): T {
    return when (this) {
        is NetworkResponse.Success<T> -> this.body
        is NetworkResponse.ServerError<DetailsError> -> throw CatalogIOException(this.body?.message.orEmpty(), this.code, this.error)
        is NetworkResponse.NetworkError -> throw CatalogIOException("Network error", 500, this.error)
        is NetworkResponse.UnknownError -> throw CatalogIOException("Unknown error", 500, this.error)
        is NetworkResponse.Error -> throw CatalogIOException("Unknown exception", 500, this.error)
    }
}

Having all of this together, we can easily call our API in the service without worrying about leaking the IOExceptions from the Retrofit client:

@Service
class CatalogService(private val catalogClient: CatalogClient) {
    suspend fun getAll(merchantCode: String): List<CategoryResponse> = catalogClient.getCategories(
        merchantCode,
        getSumupHeaders(),
    )
        .call()
        .map { categoryResponse -> CategoryResponseFactory.create(categoryResponse) }
}

class CategoryResponseFactory {
    companion object {
        fun create(
            categoryResponse: CategoryResponse,
        ) = CategoryResponse(
            categoryId = categoryResponse.id,
            name = categoryResponse.name,
        )
    }
}

And that’s it. Adding the call() to all places where the client is used is the only downside of this solution. But in general, we consider it the most elegant and easy-to-read solution.

Leave a Reply