loading...

From Network Response to Algebraic Data Type with Kotlin

danielw profile image Daniel Waller (he/him) Updated on ・6 min read

Or, what I learned from trying to stop handling HTTP error codes as exceptions.

The other day I finally understood something that had eluded me for months now: How can we avoid using exceptions for handling HTTP error codes? More specifically, how can we cast them to something different before they are being thrown as exceptions?
Here's what I learned.


If you're using Kotlin you may have already heard about the concept of Algebraic Data Types (ADTs).
If not, there is a cool post from Dmitry Zaytsev and another one by Mike Gehard.

In short, here's what we need to know about them for this post:
The idea is to wrap a bunch of objects and data classes inside a sealed class which they all inherit from.

typealias Second = Int

sealed class PowerUp {

    object FireFlower : PowerUp()

    object SuperMushroom : PowerUp()

    data class SuperStar(val duration: Second) : PowerUp()
}

You can then use them very effectively together with Kotlin's when statement:

fun Mario.collect(powerUp: PowerUp) { 
    // thanks to the 'return' a non-exhaustive 'when' will result in a compiler error
    return when(powerUp) {
        is FireFlower -> enableFlameThrowing()
        is SuperMushroom -> grow()
        // 'powerUp' is smart cast to 'SuperStar' so we can access duration on it
        is SuperStar -> startGodMode(powerUp.duration) 
    }
}

The cool thing here is that the compiler can infer whether a when expression is exhaustive or not because the type being evaluated is a sealed class.
Sealed classes can only be extended within their own .kt file so the compiler can guarantee that FireFlower, SuperMushroom, and SuperStar are the only possible values the powerUp variable can take on.
If we then use expression syntax rather than statement syntax for the when, ie. val result = when(..) {} or return when(..) {}, the compiler will treat this as an error and not build your program.

This can come in handy when you add more objects to your sealed class but forget to implement how to react to them in the when expression. Instead of your app crashing or entering an undefined state you'll be notified of the error at compile time.

Another powerful feature, smart casting, can be seen on the last branch. Here powerUp on the right-hand side of the branch is automatically cast to the type being checked for on the left-hand side of the branch. So we can use SuperStar's duration property without explicitly writing (powerUp as SuperStar).duration.

Now as Collinn Flynn explains, this Algebraic Data Structure can be really useful for error handling without resorting to exceptions.

Error handling for network requests

When we make network requests there are many things that can go wrong and many networking libraries react to any HTTP response code outside of the 200s with an exception. This is weird because in the context of most apps, codes such as 404 (Resource not found), 401 (Unauthorized) or 403 (Forbidden) are actually meaningful to our app's business logic and should be handled accordingly to enhance user experience, eg.:

  • redirect to login on 401
  • offer premium upgrade for some subscription service on 403
  • show cutesy 404 page

Traditionally we have to catch any exceptions resulting from our network calls and handle them in order to implement these cases. This can make our network calling code messy and tricky to adapt to changing requirements.

Imagine you have network calling code in many places in your program and have to add a new HTTP code to react to. For example your team now also wants to show a cutesy service unavailable page on 503.
503 status page of the university of oregon. The title says 'Service is unavailable'. The text below it says 'We are working to clear the ducks and get the service available again. Click the 'Service Status' button for more information. Below it there is a picture of a lot of cartoon rubber ducks sitting on and around a server.

This can become a fun source of errors especially when new developers, unfamiliar with the codebase make the change (or even just you 3 months later 😉).

Also there are some people with opinions about driving control flow through exceptions 👀.


Context shift. We're now switching from Mario to a simple HackerNews reader

Using Algebraic Data Types we can avoid this by defining any response that has meaning to our app inside a sealed response class:

sealed class NetworkResult

sealed class Payload : NetworkResult() {

    data class AggregateArticles(val articleIds: List<Long>) : Payload()

    data class SingleArticle(val article: HackerNewsItem): Payload()
}

sealed class HttpError : NetworkResult() {

    object ResourceNotFound : HttpError()

    object ServiceUnavailable : HttpError()

    data class UnknownError(val code: Int, val message: String) : HttpError()
}

Now we can use the when expression at the network call site like this:

    fun handleNetworkResult(result: NetworkResult) {
        return when(result) {
            is Payload -> handleResult(result)
            is HttpError -> handleError(result)
        }
    }

    fun handleResult(result: Payload) {
        return when(result) {
            is AggregateArticles -> loadArticleDetails(result.articleIds)
            is SingleArticle -> showArticle(result.article)
        }
    }

    // this would probably live in an injected global error handler
    fun handleError(result: HttpError) {
        return when(result) {
            is ResourceNotFound -> show404Screen()
            is ServiceUnavailable -> show503Screen()
            is UnknownError -> showError("Unknown error ${result.code}: ${result.message}")
        }
    }

This is cool because as we've shown above you can't forget to implement a case now cause you'll get a compiler error on the when expression where you're missing a branch.

Great! But there's already loads of articles out there that talk about all this.
How do we actually get from a network layer response object to our sealed class members!?

How to integrate with the network layer

The following code will be Android specific and assume a Dagger 2 setup

In Android development we often use OkHttp + Retrofit to define our network layer and RxJava to handle the asynchronous nature of network calls.
A typical Retrofit initialization would look like this


    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit {
        val rxCallAdapter = RxJava2CallAdapterFactory.createWithScheduler(Schedulers.io())
        return Retrofit.Builder()
                .baseUrl("https://hacker-news.firebaseio.com/v0/")
                .addConverterFactory(GsonConverterFactory.create(gson))
                .addCallAdapterFactory(rxCallAdapter)
                .build()
    }

Here we add a call transformer that will enable us to have every network call wrapped in an RxJava Observable type and a converter to automatically parse the network response's JSON to a data type.
A typical API interface defined with Retrofit, and a Service using it could then look like this:

interface NewsAggregateApi {
    @GET("topstories.json")
    fun loadTopStories(): Single<List<Long>>

    @GET("newstories.json")
    fun loadNewStories(): Single<List<Long>>
}

class NewsAggregateService(private val aggregateApi: NewsAggregateApi) {

    fun loadTopStories(): Single<List<Long>> = aggregateApi.loadTopStories()

    fun loadNewStories(): Single<List<Long>> = aggregateApi.loadNewStories()
}

If everything goes smoothly while calling /topstories.json our Observable will get a List of articleId: Long but if we encounter any HTTP code not in the 200s the Observables onError method will be triggered. This is bad because it happens before we can wrap anything in our nice sealed class types.
In an old answer to Retrofit Issue #1218 Jake Wharton has a solution for our problem:
Jake Wharton says "There are three ways to construct your observable: Observable<BodyType>, Observable<Response<BodyType>>, or Observable<Result<BodyType>>. For the first version, there's nowhere to hang non-200 response information so it is included in the exception passed to onError. For the latter two, the data is encapsulated in the Response object and can be accessed by calling errorBody()."

So going by that, we can now change our API interface accordingly to look like this:

interface NewsAggregateApi {
    @GET("topstories.json")
    fun loadTopStories(): Single<Response<List<Long>>>

    @GET("newstories.json")
    fun loadNewStories(): Single<Response<List<Long>>>
}

And make some changes to our service and our sealed class so that the return type of the service is always a NetworkResult and we can map our Response to one of the NetworkResult subtypes.

class NewsAggregateService(private val aggregateApi: NewsAggregateApi) {

    fun loadTopStories(): Single<NetworkResult> {
        return aggregateApi.loadTopStories()
                .map { it.toAggregateResult() }
    }

    fun loadNewStories(): Single<NetworkResult> {
        return aggregateApi.loadNewStories()
                .map { it.toAggregateResult() }
    }

    private fun Response<List<Long>>.toAggregateResult() =
            if (this.isSuccessful) {
                NetworkResult.fromAggregateResponse(this.body() ?: emptyList())
            } else {
                NetworkResult.fromErrorResponse(this.code(), this.message())
            }
}

sealed class NetworkResult {

    // unmodified code omitted

    companion object {
        fun fromAggregateResponse(payload: List<Long>): NetworkResult {
            return Payload.AggregateArticles(payload)
        }

        fun fromErrorResponse(code: Int, message: String?): NetworkResult {
            return when (code) {
                404 -> HttpError.ResourceNotFound
                503 -> HttpError.ServiceUnavailable
                else -> HttpError.UnknownError(code, message ?: "")
            }
        }
    }

}

And that's all there is to it! Now every valid network response will be wrapped in your ADT class.
There are some caveats though. This will only take care of valid responses that are successfully parsed. Any errors during JSON deserialization or network stack exceptions such as timeouts and SSL protocol exceptions will still be thrown and have to be handled elsewhere.

But we now have a nice (and easily testable!) way to handle non-200 server responses that have meaning to our business logic.

Thanks for reading :)

P.S. If you would like to see the code samples with some more context check out this repo.

Discussion

pic
Editor guide