Arrow of Outrageous Error Handling
David Rawson - July 2021
Android Worldwide
Strategies from FP
interface PokemonService {
@GET("pokemon")
fun fetchPokemonList(
@Query("limit") limit: Int = 20,
@Query("offset") offset: Int = 0
): PokemonResponse
}
An innocent piece of code
What exceptions can this function throw? 🤔
java.net.UnknownHostException
javax.net.ssl.SSLHandshakeException
java.net.SocketTimeoutException
com.squareup.moshi.JsonDataException
🤯
val result: String = pokemonService.fetchPokemonList() ❌
// Type mismatch: Required String but found PokemonResponse
@Dao
interface PokemonDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertPokemonList(pokemonList: List<Pokemon>)
}
Another innocent piece of code
What exceptions can this throw? 🤔
🤯
Concurrency and exceptions
https://github.com/skydoves/Pokedex
https://developer.android.com/jetpack/guide
Use case
☎️
interface PokemonService {
@GET("pokemon")
fun fetchPokemonList(
@Query("limit") limit: Int = 20,
@Query("offset") offset: Int = 0
): Call<PokemonResponse>
}
@Dao
interface PokemonDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertPokemonList(pokemonList: List<Pokemon>)
}
class PokemonRepository(
private val pokemonService: PokemonService,
private val pokemonDao: PokemonDao,
private val executorService: ExecutorService
) {
fun fetchPokemonList(
onSuccess: (List<Pokemon>) -> Unit,
onError: (Throwable) -> Unit
) {
pokemonService.fetchPokemonList()
.enqueue(
object : Callback<PokemonResponse> {
override fun onResponse(
call: Call<PokemonResponse>,
response: Response<PokemonResponse>
) {
if (!response.isSuccessful) {
onError(HttpException(response))
}
val body = response.body() ?: onError(HttpException(response))
executorService.execute {
try {
pokemonDao.insertPokemonList(body)
} catch (t: Throwable) {
onError(t)
}
onSuccess(body)
}
}
override fun onFailure(call: Call<PokemonResponse>, t: Throwable) {
onError(t)
}
}
)
}
}
Nesting (fireball shape) represents cognitive complexity
https://www.sonarsource.com/docs/CognitiveComplexity.pdf
@Dao
interface PokemonDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertPokemonList(pokemonList: List<Pokemon>): Completable
}
interface PokemonService {
@GET("pokemon")
fun fetchPokemonList(
@Query("limit") limit: Int = 20,
@Query("offset") offset: Int = 0
): Single<PokemonResponse>
}
Have to understand Single and Completable to work with this code 😟
class PokemonRepository(
private val pokemonService: PokemonService,
private val pokemonDao: PokemonDao,
) {
fun fetchPokemonList(): Single<List<Pokemon>> {
return pokemonService.fetchPokemonList()
.flatMap {
val results = it.results
pokemonDao.insertPokemonList(it.results)
.toSingle { results }
}
}
}
pokemonRepository.fetchPokemonList()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.main())
.subscribe(
{
// onSuccess
viewState.value.result = it
},
{
// onError - we only know it's assignable from Throwable
when (it) {
is HttpException -> viewState.value.error = Error.SERVER_ERROR
}
}
).addTo(disposables) // manually manage subscription
@Dao
interface PokemonDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPokemonList(pokemonList: List<Pokemon>)
}
interface PokemonService {
@GET("pokemon")
suspend fun fetchPokemonList(
@Query("limit") limit: Int = 20,
@Query("offset") offset: Int = 0
): PokemonResponse
}
No need to wrap return type
class PokemonRepository(
private val pokemonService: PokemonService,
private val pokemonDao: PokemonDao,
) {
suspend fun fetchPokemonList(): List<Pokemon> {
val pokemon = pokemonService.fetchPokemonList().results
pokemonDao.insertPokemonList(pokemon)
return pokemon
}
}
viewModelScope.launch {
try {
val pokemon = pokemonRepository.fetchPokemonList()
mutableViewState.value.result = pokemon
} catch (t: Throwable) {
if (t is HttpException) {
mutableViewState.value.error = Error.SERVER_ERROR
}
}
}
Write imperative code without combinators like flatMap
try/catch seems inelegant here
no need to manage subscription
flow<List<Pokemon>> {
pokemonRepository.fetchPokemonList()
}
.flowOn(Dispatchers.Main)
.onEach { mutableViewState.value.result = it }
.catch { t ->
if (t is HttpException) {
mutableViewState.value.error = Error.SERVER_ERROR
}
}
.collect()
Still no type safety - we only know t is a Throwable.
viewModelScope.launch {
try {
val pokemon = pokemonRepository.fetchPokemonList()
mutableViewState.value.result = pokemon
} catch (t: Throwable) {
if (t is HttpException) {
mutableViewState.value.error = Error.SERVER_ERROR
}
}
}
Functional companion to Kotlin's standard library
def arrow_version = "0.13.2"
dependencies {
implementation "io.arrow-kt:arrow-fx-coroutines:$arrow_version"
}
Can we do better? 🤔
val right: Either<Throwable, String> = Either.Right("a")
val left: Either<Throwable, String> = Either.Left(Throwable())
val rightResult = right.map {
it.plus("b")
}.map {
it.plus("c")
} // Either.Right("abc")
val leftResult = left.map {
it.plus("b") // short circuit
}.map {
it.plus("c")
} // Either.Left(Throwable())
sealed class DataSourceException : Exception() {
object ConnectivityException : DataSourceException()
object InternalServerException : DataSourceException()
object DatabaseException : DataSourceException()
}
class RemotePokemonDataSource(private val pokemonService: PokemonService) {
suspend fun fetchPokemonList(
limit: Int = 20,
offset: Int = 0
): Either<DataSourceException, PokemonResponse> =
Either.catch {
pokemonService.fetchPokemonList(limit, offset)
}.mapLeft { it.toDataSourceException() }
private fun Throwable.toDataSourceException(): DataSourceException = TODO()
}
class PokemonRepository(
private val pokemonDataSource: PokemonDataSource,
private val pokemonDao: PokemonDao,
) {
suspend fun fetchPokemonList(): Either<DomainException, List<Pokemon>> {
return pokemonDataSource.fetchPokemonList()
.mapLeft { dataSourceException ->
dataSourceException.toDomainException()
}
.flatMap { pokemonResponse ->
insertToDb(pokemonResponse.results).map {
pokemonResponse.results
}.mapLeft {
it.toDomainException()
}
}
}
}
import arrow.core.Either
import arrow.core.computations.either
import arrow.core.flatMap
class PokemonRepository(
private val pokemonDataSource: PokemonDataSource,
private val pokemonDao: PokemonDao,
) {
suspend fun fetchPokemonList(): Either<DomainException, List<Pokemon>> {
return fetchPokemonListFromDataSource().mapLeft { it.toDomainException() }
}
private suspend fun fetchPokemonListFromDataSource(): Either<DataSourceException, List<Pokemon>> =
either {
// monad comprehension allows you to write in an imperative style
// without combinators like flatMap
val response = pokemonDataSource.fetchPokemonList(20, 0).bind()
val results = response.results
insertToDb(results).bind()
// binding is type safe, will fail if insertToDb left type parameter is not
// DataSourceException
results
}
}
viewModelScope.launch {
repository.fetchPokemonList()
.fold(
ifLeft = { domainException ->
@Exhaustive
when (domainException) {
is ConnectivityException -> mutableViewState.value.error = R.string.no_connection
is ServerException -> mutableViewState.value.error = R.string.server_exception
is UnknownException -> mutableViewState.value.error = R.string.storage_exception
}
}, ifRight = { list ->
mutableViewState.value.result = list
}
)
}
exit the world of Either using fold
we don't have to guess what errors might bubble up from below
import arrow.core.Either
import arrow.core.computations.either
import arrow.core.flatMap
private suspend fun fetchPokemonListFromDataSource(): Either<DataSourceException, List<Pokemon>> =
either {
val response = pokemonDataSource.fetchPokemonList(20, 0).bind()
val results = response.results
insertToDb(results).bind()
results
}
short circuit ⚡
What if we don't want short circuiting? 🤔
sealed class PokemonNameError {
object InvalidSuffix : PokemonNameError()
object TooLong : PokemonNameError()
object Empty : PokemonNameError()
}
How do we represent this? 🤔
data class State(
val pokemonName: PokemonName?,
val errors: List<PokemonNameError>
)
val noOpinion = State(
pokemonName = null,
errors = emptyList()
)
data class State(
val pokemonName: PokemonName?,
val errors: List<PokemonNameError>
)
val valid = State(
pokemonName = PokemonName("Bulbasaur"),
errors = emptyList()
)
data class State(
val pokemonName: PokemonName?,
val errors: List<PokemonNameError>
)
val invalid = State(
pokemonName = null,
errors = listOf(InvalidSuffix, TooLong)
)
data class State(
val pokemonName: PokemonName?,
val errors: List<PokemonNameError>
)
val meaningless = State(
pokemonName = PokemonName("Pikachu"),
errors = listOf(InvalidSuffix, TooLong)
) // nothing prevents us creating a meaningless instance 😢
data class State private constructor(
val pokemonName: PokemonName?,
val errors: List<PokemonNameError>
) {
companion object {
fun create(pokemonName: PokemonName?, errors: List<PokemonNameError>) {
if (pokemonName != null && errors.isNotEmpty()) {
throw IllegalArgumentException()
}
return State(pokemonName, errors)
}
}
}
val meaingless = State.create(
pokemonName = PokemonName("Pikachu"),
errors = listOf(InvalidSuffix, TooLong)
)
// IllegalArgumentException at runtime. Can we rely on the type system instead?
Model with non-empty list instead 👍
import arrow.core.Nel
import arrow.core.NonEmptyList
import arrow.core.nonEmptyListOf
lateinit var a : NonEmptyList<PokemonNameError>
lateinit var b : Nel<PokemonNameError>
a = nonEmptyListOf() // compilation failure
data class State(
val pokemonName: Validated<Nel<PokemonNameError>, PokemonName>?
)
typealias ValidatedNel<E, A> = Validated<Nel<E>, A>
data class State(
val pokemonName: ValidatedNel<PokemonNameErrors, PokemonName>?
)
private fun String.validatedLength(): ValidatedNel<PokemonNameError, String> =
when {
length < 16 -> valid() // wraps `this` (String receiver) in Validated.Valid
else -> PokemonNameError.TooLong.invalidNel()
}
private fun String.validatedSuffix(): ValidatedNel<PokemonNameError, String> =
when {
endsWith("mon") || endsWith("chu") || endsWith("saur") -> valid()
else -> PokemonNameError.InvalidSuffix.invalidNel()
}
private fun String.validatedNonEmpty(): ValidatedNel<PokemonNameError, String> =
when {
isEmpty() -> PokemonNameError.Empty.invalidNel()
else -> valid()
}
fun String.validatePokemonName(): ValidatedNel<PokemonNameError, PokemonName> =
validatedNonEmpty().zip(
validatedLength(),
validatedSuffix()
) { _, _, _ -> PokemonName(this) }
fun main() {
val name = "Bulbasaur"
println(name.validatePokemonName())
//Validated.Valid(PokemonName(value=Bulbasaur))
}
fun main() {
val name = "Daviddddddddddddddddddddddddddddddddddd"
println(name.validatePokemonName())
//Validated.Invalid(NonEmptyList(TooLong, InvalidSuffix))
}
data class PokemonNameError(val reasons: Nel<Reason>) {
sealed class Reason {
object InvalidSuffix : Reason()
object TooLong : Reason()
object Empty : Reason()
}
}
fun String.validatePokemonName(): Validated<PokemonNameError, PokemonName> =
validatedNonEmpty().zip(
validatedLength(),
validatedSuffix()
) { _, _, _ -> PokemonName(this) }.mapLeft { PokemonNameError(it) }
fun main() {
val name = "Daviddddddddddddddddddddddddddddddddddd"
println(name.validatePokemonName())
//Validated.Invalid(PokemonNameError(reasons=NonEmptyList(TooLong, InvalidSuffix)))
}
Questions?
@d_rawers
Slides available on Twitter
Arrow of Outrageous Error Handling
By David Rawson
Arrow of Outrageous Error Handling
- 1,115