[Android/Multiplatform] Kotlin Flows + Ktor = Flawless HTTP requests (- ArrowKt)

Iliyan Germanov
13 min readMar 14, 2023

--

Let’s face it! The majority of our work is making requests to an API (usually an HTTP or a GraphQL one) and providing a UI for all possible states — like Loading, Success, Error, and Empty.

This should be simple! But… Have we set the time to think more deeply about it? From my 8+ years as an Android Developer, more often than not, we tend to complicate things that shouldn’t be complicated at all.

That’s the topic of today’s article — how to send and receive data from a server in a simple and elegant way using Kotlin Flows (asynchronous data stream) and Ktor Client (a multiplatform HTTP client; nevertheless the code snippets below can work with Retrofit, Apollo Kotlin GraphQL or whatever client you prefer).

Bonus: Read till the end, there’s a cute Husky at the bottom! :D

Symbolizing client-server community (generated by Dall-E 2)

The Client-Server communication problem

It’s a problem as old as the internet. We all (frontend devs) face it, no matter the platform — Android, iOS, Web, or Desktop. So let’s examine it!

That’s how requests should be — either Ok or Error.

In an ideal world, the diagram above should perfectly illustrate what a backend call should look like. Translated in Kotlin:

// Throws no exceptions and completes in (0, TIMEOUT] seconds
suspend fun backendCall(r: Request): Result<Err, Ok>

However, in practice in many Android projects, disguised as an “OOP best practice”, I’ve seen something so simple turned in this:

// The "Exception Monster" in prod
// Skill: can throw up to N different exceptions, where N is unknown
suspend fun backendCall(r: Request): Ok
The “Exception Monster” (zoom in to see its beauty!)

In the “Exception Monster” diagram you can notice that:

  1. You can never be sure that these are all the possible exceptions that it can produce. For example, how about an SSLException?
  2. It’s not easy to differentiate system exceptions (e.g. no internet) from domain exceptions (e.g. invalid credentials, item out of stock).
  3. Even if you receive a Ok , you can’t be sure that all necessary fields are present and not null.
  4. The root of all evil — it’s not type-safe.

So let’s fix that! There’s something called ADTs (Algebraic Data Types) which in fact are simply immutable data class’s (PRODUCT *types),sealed interface‘s (SUM + types) and combinations between them.

For the backend call result, we’ll need a special type Result that has two possible cases: Ok (containing all the data expected on success) | (OR) Err (which can be any of the possible errors).

Either<E, T> from ArrowKt (a Functional Companion to Kotlin’s Standard Library) is a perfect candidate for the job. Of course, we can easily create our own sealed class Result<E,T> but you’ll see deeper in the article why Arrow’s Either dependency would be a better choice. The short answer is: monad!

// Note: You need dependency to 'io.arrow-kt:arrow-core'
// https://arrow-kt.io/docs/quickstart/#bom-file
import arrow.core.Either

// Throws no exceptions. Always terminates (no inf. loops).
suspend fun <R, E, T> remoteCall(request: R): Either<E, T> = TODO()

Def. A good remote call: is one such that 1) is type-safe → always results in either Ok or Err with well-defined types; 2) is a total function → does not throw exceptions and does not get stuck (completes in 0..Timeout seconds)

Def. A total function: is a function that is defined (throws no exceptions and never gets stuck) for all input values. For example, fun divide(a: Int, b: Int): Int = a / b is partial (not total) because for b = 0 it throws an exception.

Now that we’ve defined what a “good” remote call is, let’s dive into the Ktor implementation.

“The messy Retrofit + OktHttp network layer of an old-school Android app.”

Ktor implementation of a “good” HTTP API call

Here things get interesting cuz we finally reached the coding part! The full code in this article can be found in the ”ILIYANGERMANOV/flawless-requests” Github repo.

  1. Install: Add the Ktor Client dependencies

This might already be outdated but here are the dependencies I use for the Ktor Client. For JSON serialization/deserialization I use GSON but it doesn’t matter — you can use any other Ktor compatible that you prefer.

File: build.gradle (Groovy DSL)

// region Ktor Http Client
var ktor = "2.0.3" // use the latest stable version
implementation "io.ktor:ktor-client-core:$ktor"
implementation "io.ktor:ktor-client-okhttp:$ktor"
implementation "io.ktor:ktor-client-logging:$ktor"
implementation "io.ktor:ktor-client-content-negotiation:$ktor"
implementation "io.ktor:ktor-serialization-gson:$ktor"
implementation "com.google.code.gson:gson:2.9.0" // use the latest stable version
// endregion

2. Configure: Create a Ktor Client instance

Configuring will vary based on your needs and use cases. We’ll demonstrate a simple setup with a function that creates a new instance of the Ktor HttpClient configured with GSON and the standard Log.d logging.

We won’t spend much time on this because it’s not that essential and depends on your project. Of course, you can make the Ktor client a Singleton (which is recommended) and wire it via your DI framework (like Hilt, Dagger, or Koin).

File: KtorClient.kt

import android.util.Log
import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.http.*
import io.ktor.serialization.gson.*


/**
* Creates and configures a new instance of the Ktor [HttpClient].
*
* [Official docs](https://ktor.io/docs/create-client.html#close-client):
* Note that **creating HttpClient is not a cheap operation**,
* and it's better to **reuse (@Singleton)** its instance in the case of multiple requests.
*
* **Note:** You also need to call the [HttpClient.close] function when you're done with it
* to free resources. If you need to use [HttpClient] for a single request,
* call the [HttpClient.use] function, which automatically calls [HttpClient.close].
*
* @return a new pre-configured Ktor [HttpClient] instance.
*/
fun ktorClient(): HttpClient = HttpClient {
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
Log.d("KTOR", message)
}
}
level = LogLevel.ALL // logs everything
}

install(HttpTimeout) {
requestTimeoutMillis = 10_000 // 10s
connectTimeoutMillis = 10_000 // 10s
}

install(ContentNegotiation) {
gson(
contentType = ContentType.Any // workaround for broken APIs
)
}
}

Official doc: Note that creating a KtorHttpClient is not a cheap operation, and it's better to reuse its instance in the case of multiple requests.

Also, .close() your Ktor client when it’s no longer needed to free resources.

3. Sending HTTP requests using the client

That’s the essential part! We need a total function that can make arbitrary HTTP requests to arbitrary Web APIs resulting in either success or error. Let’s look at the code.

File: HttpRequest.kt

import arrow.core.Either
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext


sealed interface HttpError {
data class API(val response: HttpResponse) : HttpError
data class Unknown(val exception: Exception) : HttpError
}

suspend inline fun <reified Data> httpRequest(
ktorClient: HttpClient,
crossinline request: suspend HttpClient.() -> HttpResponse
): Either<HttpError, Data> = withContext(Dispatchers.IO) {
try {
val response = request(ktorClient)
if (response.status.isSuccess()) {
// Success: 200 <=status code <=299.
Either.Right(response.body())
} else {
// Failure: unsuccessful status code.
Either.Left(HttpError.API(response))
}
} catch (exception: Exception) {
// Failure: exceptional, something wrong.
Either.Left(HttpError.Unknown(exception))
}
}

Okay, we’re ready to send our first HTTP requests using Ktor and what we’ve built.

Using our Ktor Implementation

Let’s start with a simple GET request example. This article is already getting long so we’ll not examine things that are self-explanatory to speed up things. Feel free to ask or suggest improvements in the comments section :)

HTTP GET using Ktor

import arrow.core.Either
import com.flawlessrequests.network.HttpError
import com.flawlessrequests.network.httpRequest
import com.flawlessrequests.network.ktorClient
// Depends on: 'com.google.code.gson:gson' if you're using GSON
import com.google.gson.annotations.SerializedName
import io.ktor.client.request.*

val ktorSingleton = ktorClient()
const val PRODUCTS_PER_PAGE = 24

data class ProductsResponse(
@SerializedName("products")
val products: List<Product>
)

data class Product(
@SerializedName("name")
val name: String,
@SerializedName("price")
val price: Double,
)

// Imaginary API
suspend fun fetchProductsFromAPI(page: Int): Either<HttpError, ProductsResponse> =
httpRequest(ktorSingleton) {
get("https://www.myawesomeapi.com/prodcuts") {
headers {
set("API_KEY", "{YOUR_API_KEY}")
}

parameter("offset", page * PRODUCTS_PER_PAGE)
parameter("limit", PRODUCTS_PER_PAGE)
}
}

HTTP POST Ktor

import arrow.core.Either
import com.flawlessrequests.network.HttpError
import com.flawlessrequests.network.httpRequest
import com.google.gson.annotations.SerializedName
import io.ktor.client.*
import io.ktor.client.request.*

const val API_BASE = "https://www.myawesomeapi.com"

data class LoginRequest(
@SerializedName("email")
val email: String,
@SerializedName("password")
val password: String,
)

data class LoginResponse(
@SerializedName("userId")
val userId: String,
@SerializedName("sessionToken")
val sessionToken: String,
)

// Imaginary API
suspend fun HttpClient.login(request: LoginRequest): Either<HttpError, LoginResponse> =
httpRequest(this) {
post("$API_BASE/login") {
setBody(request) // the body will be serialized to JSON
}
}

Sending the standard HTTP requests is quite simple and type-safe. I’ve demonstrated one of the many ways to construct a request using Ktor. The HttpClient supports a powerful Kotlin DSL, feel encouraged to explore the official docs — Making Requests | Ktor.

Chaining Ktor Requests

I’ve promised you that we’ll explore why we choose Either<E,T> (from ArrowKt’s Core) over a custom Result<Err, Ok> type. The reason is that Either is a monadic type. Either supports a lot of built-in features including chaining via .bind() (which is comprehension for nested .flatMap{}’s) — see Arrow’s official docs about Either or simply the code below.

import arrow.core.Either
import arrow.core.computations.either
import com.flawlessrequests.network.HttpError

@JvmInline
value class TrackingId(val id: String)

suspend fun getOrderId(): Either<HttpError, String> = TODO()
suspend fun confirmOrder(orderId: String): Either<HttpError, Boolean> = TODO()
suspend fun trackOrder(orderId: String): Either<HttpError, TrackingId> = TODO()

suspend fun placeOrderChain(): Either<HttpError, TrackingId?> = either { // 0
val orderId = getOrderId().bind() // 1
val canBeTracked = confirmOrder(orderId).bind() // 2
if (canBeTracked) {
trackOrder(orderId).bind() // 2a
} else {
null
}
}

Monads are an enormous topic and deserve a series of articles on their own but we’ll try to explain practically what’s happening here.

0: An either effect scope (monad comprehension) providing access to the .bind() extension function.

1: Sends the getOrderId(): Either<HttpError, String> request and if it fails terminates the entire chain with Either.Left<HttpError>. Otherwise, on success getOrderId().bind() will return String which is theEither.Right type.

2, 2a: Works the same way as “1:”. In a nutshell, .bind() continues only if the operation is successful → returns Either.Right<T>.

Tip: To chain multiple requests they must have the same error type (Either.Left) because in the case of failure the entire chain can terminate only with a single “Left” (error) type. To chain requests having different error types use Either#mapLeft{} like: request1().mapLeft { ... }.bind().

Alternatively, the .bind() code above translates to the following .flatMap {} chain “under the hood”:

// Re-written without the "either" effect scope (it's uglier!)
suspend fun placeOrderFlatMap(): Either<HttpError, TrackingId?> =
getOrderId().flatMap { orderId ->
confirmOrder(orderId).flatMap { canBeTracked ->
if (canBeTracked) {
trackOrder(orderId).flatMap { trackingId ->
Either.Right(trackingId)
}
} else Either.Right(null)
}
}

Enough about Monads and Either for now. Let’s focus on something more Android Dev related — how to seamlessly handle request states (Loading, Success, Error) in the UI.

Combining the power of Ktor and Kotlin Flows.

Reactive HTTP request using Ktor and Kotlin Flows

If you’ve reached this far — congrats! That would be the most beneficial part for us, Android Developers. We’ll create a reactive machinery that’ll send HTTP requests and automatically handle Loading, Success, and Error states with all the required retry logic using Kotlin Flows.

For the full code, visit my GitHub repo associated with this article.

  1. Define a “Loading | Success | Error” type.

We’ll first need to define a type that represents the 3 possible states that an HTTP request can have in the UI: Loading | Success(data)| Error(error). Because having a Loading and Error state is very common for many UI-related things (like saving a large file locally, or transforming a bitmap) let’s make our definition more general purpose so we can re-use it.

File: Operation.kt

/**
* Defines a potentially long-running operation that:
* 1) Must have a [Operation.Loading] state
* 2) Results in either [Operation.Ok] or [Operation.Error]
*
* _Example: A good use-case for an [Operation] is sending HTTP requests to a server._
*/
sealed interface Operation<out Err, out Data> {
/**
* Loading state.
*/
object Loading : Operation<Nothing, Nothing>

/**
* Success state with [Data].
*/
data class Ok<out Data>(val data: Data) : Operation<Nothing, Data>

/**
* Error state with [Err].
*/
data class Error<out Err>(val error: Err) : Operation<Err, Nothing>
}

Tip: A good software design practice is when possible to make your abstractions more generic (general purpose) and re-usable. A great book on the topic is A Philosophy of Software Design by John Ousterhout.

Now that we have the type we need. Let’s define two more helper functions for mapping (transforming) the Ok and Error states which may come in handy.

File: Operation.kt (the same file)

// sealed interface Operation {}
// ...


/**
* Transforms the [Operation.Ok] case using the [transform] lambda.
* [Operation.Loading] and [Operation.Error] remain unchanged.
* @param transform transformation (mapping) function for the [Operation.Ok]'s case.
* @return a new [Operation] with transformed [Operation.Ok] case.
*/
fun <E, D1, D2> Operation<E, D1>.mapSuccess(
transform: (D1) -> D2
): Operation<E, D2> = when (this) {
is Operation.Error -> this
is Operation.Loading -> this
is Operation.Ok -> Operation.Ok(
data = transform(this.data)
)
}

/**
* Transforms the [Operation.Error] case using the [transform] lambda.
* [Operation.Loading] and [Operation.Ok] remain unchanged.
* @param transform transformation (mapping) function for the [Operation.Error]'s case.
* @return a new [Operation] with transformed [Operation.Error] case.
*/
fun <E, E2, D> Operation<E, D>.mapError(
transform: (E) -> E2
): Operation<E2, D> = when (this) {
is Operation.Error -> Operation.Error(
error = transform(this.error)
)
is Operation.Loading -> this
is Operation.Ok -> this
}

2. Build an abstraction that’ll transform HTTP requests into a flow of LoadingSuccess | Error .

Again, why limit ourselves only to Ktor HTTP requests? We can easily make the abstraction work for any arbitrary operation that needs Loading and Error states.

File: OperationFlow.kt

/**
* Transforms a **potentially long-running operation that can result in [Either] success or error**
* to a [Flow]<[Operation]> that'll automatically emit [Operation.Loading] before the operation
* is started and provide an out of the box [OperationFlow.retry] capabilities.
*
* **Usage:**
* 1) Extend [OperationFlow].
* 2) Implement (override) [OperationFlow.operation] :: [Input] -> Either<[Err], [Data]>.
* 3) Call [OperationFlow.flow] ([Input]) to trigger a [Flow] of
* [Operation.Loading] -> [Operation.Ok]/[Operation.Error].
* 4) In case of an error, you can retry the [Operation] by calling [OperationFlow.retry].
*/
abstract class OperationFlow<in Input, out Err, out Data> {
protected abstract suspend fun operation(input: Input): Either<Err, Data>

/**
* Used to trigger the [operation] execution.
*/
private val triggerFlow = MutableSharedFlow<Unit>(
// trigger the first operation() execution when flow(Input) is called later
replay = 1,
)

init {
// trigger the first operation() execution when flow(Input) is called later
triggerFlow.tryEmit(Unit)
}

/**
* Creates a flow that'll immediately execute the [OperationFlow.operation].
* @param input input that will be supplied to the [OperationFlow.operation]
* @return a new [Flow]<[Operation]> with the supplied [Input].
*/
fun flow(input: Input): Flow<Operation<Err, Data>> = channelFlow {
// "channelFlow" because we may collect from different coroutines
triggerFlow.collectLatest {
send(Operation.Loading)
send(
when (val result = operation(input)) {
is Either.Left -> Operation.Error(result.value)
is Either.Right -> Operation.Ok(result.value)
}
)
}
}

/**
* Makes the [OperationFlow.flow] re-execute the operation with the last supplied [Input].
* If the [OperationFlow.flow] isn't collected, nothing will happen.
*/
suspend fun retry() {
triggerFlow.emit(Unit)
}
}

It’s a lot! But if you read the docs and comments in the code, it’ll shed some light. TL;DR;

  • The OperationFlow abstraction turns a suspend fun f(i: Input): Either<Err, Ok> input a Flow<Operation> that automatically emits Operation.Loading before f is executed and then emits either Operation.Ok<Data> or Operation.Error<Err> depending on the outcome.
  • Provides the method Operation#retry() that will retry the operation with the last provided input.

In the next final chapter, we’ll see a few examples of how we can use everything that we’ve built so far...

A Husky making “Flawless” HTTP requests using Kotlin Flow + Ktor + ArrowKt.

Kotlin Flow + Ktor Usage

Without further ado, let’s into the code!

GET Request example

import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import arrow.core.Either
import com.flawlessrequests.network.Operation
import com.flawlessrequests.network.OperationFlow
import com.flawlessrequests.network.httpRequest
import com.google.gson.annotations.SerializedName
import dagger.hilt.android.lifecycle.HiltViewModel
import io.ktor.client.*
import io.ktor.client.request.*
import kotlinx.coroutines.launch
import javax.inject.Inject


object PeopleError // Domain error
data class Person( // Domain type
val names: String,
val age: Int,
)

data class PersonDto(
@SerializedName("first_name")
val firstName: String,
@SerializedName("last_name")
val lastName: String?,
@SerializedName("age")
val age: Int,
)

data class PeopleResponse(
@SerializedName("people")
val people: List<PersonDto>
)

class PeopleRequest @Inject constructor(
private val ktorClient: HttpClient,
) : OperationFlow<Unit, PeopleError, List<Person>>() {
override suspend fun operation(input: Unit): Either<PeopleError, List<Person>> =
httpRequest<PeopleResponse>(ktorClient) {
get("{PEOPLE_API_URL}")
}.mapLeft { httpError ->
PeopleError // map HttpError to domain error
}.map { response ->
// map Response (DTO) to domain
response.people.map { dto ->
Person(
names = listOfNotNull(dto.firstName, dto.lastName).joinToString(" "),
age = dto.age,
)
}
}
}

@HiltViewModel
class PeopleViewModel @Inject constructor(
private val peopleRequest: PeopleRequest
) : ViewModel() {
val opPeopleFlow = peopleRequest.flow(Unit)

fun retryPeopleRequest() {
viewModelScope.launch {
peopleRequest.retry()
}
}
}

@Composable
fun PeopleScreen() {
val viewModel: PeopleViewModel = viewModel()
val opPeople by viewModel.opPeopleFlow.collectAsState(Operation.Loading)

when (opPeople) {
is Operation.Error -> {
Button(onClick = {
viewModel.retryPeopleRequest()
}) {
Text(text = "Error! Tap to retry.")
}
}
Operation.Loading -> {
// Loading state
}
is Operation.Ok -> {
// Success state
}
}
}

Note: This is not a recommended way to write ViewModels + Compose UI. In fact, it’s bad! However, it’s the shortest code that we can write to demo what we’ve built.

A few comments, I usually use the FRP (Functional Reactive Programming) paradigm where you have a single state ViewModel which would be a topic for another article!

POST Request example

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import arrow.core.Either
import com.flawlessrequests.network.HttpError
import com.flawlessrequests.network.OperationFlow
import com.flawlessrequests.network.httpRequest
import com.google.gson.annotations.SerializedName
import dagger.hilt.android.lifecycle.HiltViewModel
import io.ktor.client.*
import io.ktor.client.request.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import javax.inject.Inject


data class Credentials(
val username: String,
val password: String,
)

data class AuthRequestBody(
@SerializedName("username")
val username: String,
@SerializedName("password")
val password: String,
)

data class AuthResponseDto(
@SerializedName("accessToken")
val accessToken: String,
)

class AuthenticationRequest @Inject constructor(
private val ktorClient: HttpClient
) : OperationFlow<Credentials, HttpError, AuthResponseDto>() {
override suspend fun operation(
input: Credentials
): Either<HttpError, AuthResponseDto> = httpRequest(ktorClient) {
post("{API URL GOES HERE") {
setBody(AuthRequestBody(input.username, input.password))
}
}
}

@HiltViewModel
class AuthViewModel @Inject constructor(
private val authRequest: AuthenticationRequest
) : ViewModel() {
private val credentialsFlow = MutableStateFlow<Credentials?>(null)

@OptIn(ExperimentalCoroutinesApi::class)
val authFlow = credentialsFlow.flatMapLatest { credentials ->
// the request will be send only when valid credentials are provided
if (credentials != null)
authRequest.flow(credentials) else flowOf(null)
}

fun retry() {
viewModelScope.launch {
// the request will be retried with the last non-null credentials if any
// else nothing will happen
authRequest.retry()
}
}
}

Again the minimal code to demonstrate a OperationFlow that has an input.

We’re getting close to the 15-minute reading time mark — you’re probably getting tired… I’m getting tired, too. Let’s call it a day!

Thank you very much for reading my entire article! I appreciate it. If you’ve enjoyed it and would like to read more like this, consider following me on Medium and clapping a few times (maybe 50 :D) to motivate me to write another one sooner.

P.S. This article is opinionated and not “politically correct”. The reason for that is — discussion and debate. I believe that having constructive criticism and being open to new ideas is the only way to grow. So if you have something to say — say it now! Or do you know how to do HTTP requests better? Feel free, actually be encouraged to comment.

[CHANGE MY MIND]: HTTP request calls must not throw exceptions.

--

--

Iliyan Germanov

Software Engineer: Android | Functional Programming - Kotlin & Haskell. Excited by innovation and science.