Copenhagen Denmark
Failure is not an Option
Error handling strategies for Kotlin programs
Nat Pryce & Duncan McGregor
@natpryce, @duncanmcg
Failure is not an Option Error handling strategies for Kotlin - - PowerPoint PPT Presentation
Failure is not an Option Error handling strategies for Kotlin programs Nat Pryce & Duncan McGregor @natpryce, @duncanmcg Copenhagen Denmark What is failure? Programs can go wrong for so many reasons! Invalid Input Strings with
Copenhagen Denmark
Error handling strategies for Kotlin programs
@natpryce, @duncanmcg
Programs can go wrong for so many reasons!
○ Strings with invalid values ○ Numbers out of range ○ Unexpectedly null pointers
○ File not found ○ Socket timeout
○ Array out of bounds ○ Invalid state ○ Integer overflow
○ Out of memory
Error handling is hard to get right
"Without correct error propagation, any comprehensive failure policy is useless … We find that error handling is occasionally correct. Specifically, we see that low-level errors are sometimes lost as they travel through [...] many layers [...]"
EIO: Error handling is occasionally correct.
File and Storage Technologies, FAST’08, 2008.
"Almost all catastrophic failures (92%) are the result of incorrect handling of non-fatal errors explicitly signaled in software"
Simple Testing Can Prevent Most Critical Failures: An Analysis of Production Failures in Distributed Data-Intensive Systems.
Ding Yuan, et al., University of Toronto. In Proceedings of the 11th USENIX Symposium on Operating Systems Design and Implementation, OSDI14, 2014
Java tried to help with checked exceptions
Checked Exception Something failed in the program's environment. The program could recover. The type checker ensures that the programmer considers all possible environmental failures in their design. RuntimeException A programmer made a mistake that was detected by the runtime. All bets are off (because of non-transactional mutable state) Error The JVM can no longer guarantee the semantics of the language. All bets are off.
It's easy to throw exceptions – maybe too easy
fun handlePost(request: HttpRequest): HttpResponse { val action = try { parseRequest_1(request) } catch (e: NumberFormatException) { return HttpResponse(HTTP_BAD_REQUEST) } catch (e: NoSuchElementException) { return HttpResponse(HTTP_BAD_REQUEST) } perform(action) return HttpResponse(HTTP_OK) } fun parseRequest(request: HttpRequest): BigInteger { val form = request.readForm() return form["id"]?.toBigInteger() ?: throw NoSuchElementException("id missing") }
Categorise errors as they cross domain boundaries
fun handlePost(request: HttpRequest): HttpResponse { val action = try { parseRequest(request) } catch (e: BadRequest) { return HttpResponse(HTTP_BAD_REQUEST) } perform(action) return HttpResponse(HTTP_OK) } fun parseRequest(request: HttpRequest) = try { val form = request.readForm() form["id"]?.toBigInteger() ?: throw BadRequest("id missing") } catch(e: NumberFormatException) { throw BadRequest(e) }
But code using exceptions can be difficult to change.
fun handlePost(request: HttpRequest): HttpResponse { val action = try { parseRequest(request) } catch (e: BadRequest) { return HttpResponse(HTTP_BAD_REQUEST) } perform(action) return HttpResponse(HTTP_OK) } fun parseRequest(request: HttpRequest) = try { val json = request.readJson() json["id"].textValue().toBigInteger() } catch(e: NumberFormatException) { throw BadRequest(e) }
Can you spot the bug?
Exception handling bugs may not be visible & are not typechecked
fun handlePost(request: HttpRequest): HttpResponse { val action = try { parseRequest(request) } catch (e: BadRequest) { return HttpResponse(HTTP_BAD_REQUEST) } perform(action) return HttpResponse(HTTP_OK) } fun parseRequest(request: HttpRequest) = try { val json = request.readJson() json["id"].textValue().toBigInteger() } catch(e: NumberFormatException) { throw BadRequest(e) }
Can throw JsonException ... which is not handled here ... … and so propagates to the HTTP layer, which returns 500 instead of 400
Fuzz test to ensure no unexpected exceptions
@Test fun `Does not throw unexpected exceptions on parse failure`() { Random().mutants(1000, validInput) .forEach { possiblyInvalidInput -> try { parse(possiblyInvalidInput) } catch (e: BadRequest) { /* allowed */ } catch (e: Exception) { fail("unexpected exception $e for: $possiblyInvalidInput") } } }
https://github.com/npryce/snodge
Exceptions are fine when...
… the behaviour of the program does not depend on the type of error. For example
Be aware of when that context changes
Total Functions
fun readFrom(uri: String): ByteArray? { ... } fun readFrom(uri: URI): ByteArray? { ... } class Fetcher(private val config: Config) { fun fetch(path: String): ByteArray? { val uri: URI = config[BASE_URI].resolve(path) return readFrom(uri) } } class Fetcher(private val base: URI) { constructor(config: Config) : this(config[BASE_URI]) fun fetch(path: String): ByteArray? = readFrom(base.resolve(path)) }
A common convention in the standard library
/** * Parses the string as an [Int] number and returns the result * or `null` if the string is not a valid representation of a number. */ @SinceKotlin("1.1") public fun String.toIntOrNull(): Int? = ...
Errors can be handled with the elvis operator
fun handleGet(request: HttpRequest): HttpResponse { val count = request["count"].firstOrNull() ?.toIntOrNull() ?: return HttpResponse(HTTP_BAD_REQUEST).body("invalid count") val startTime = request["from"].firstOrNull() ?.let { ISO_INSTANT.parseInstant(it) } ?: return HttpResponse(HTTP_BAD_REQUEST).body("invalid from time") ...
But the same construct represents absence and error
fun handleGet(request: HttpRequest): HttpResponse { val count = request["count"].firstOrNull()?.let { it.toIntOrNull() ?: return HttpResponse(HTTP_BAD_REQUEST) .body("invalid count parameter") } ?: 100 val startTime = request["from"].firstOrNull()?.let { ISO_INSTANT.parseInstant(it) ?: return HttpResponse(HTTP_BAD_REQUEST) .body("invalid from parameter") } ?: Instant.now() ...
Convert exceptions to null close to their source
fun DateTimeFormatter.parseInstant(s: String): Instant? = try { parse(s, Instant::from) } catch (e: DateTimeParseException) { null }
Using null for error cases is fine when...
… the cause of an error is obvious from the context. … optionality and errors are not handled by the same code. For example
Be aware of when that context changes And fuzz test to ensure no unexpected exceptions.
Move errors to the outer layers
fun process(src: URI, dest: File) { val things = readFrom(src) process(things, dest) } fun process(things: List<String>, dest: File) { ... } fun process(src: URI, dest: File) { val things = readFrom(src) dest.writeLines(process(things)) } fun process(things: List<String>): List<String> { ... }
(in Kotlin, a sealed class hierarchy)
"Don't mention monad. I mentioned it once but I think I got away with it all right."
An example Result type
sealed class Result<out T, out E> data class Success<out T>(val value: T) : Result<T, Nothing>() data class Failure<out E>(val reason: E) : Result<Nothing, E>()
This example is from Result4k Other Result types are available from your preferred supplier*
* Maven Central
You are forced to consider the failure case
val result = operationThatCanFail() when (result) { is Success<Value> -> doSomethingWith(result.value) is Failure<Error> -> handleError(result.reason) }
Cannot get the value from a Result without ensuring that it is a Success ☛ Flow-sensitive typing means no casting But awkward to use for every function call that might fail And... how should we represent the failure reasons?
Convenience operations instead of when expressions
fun handlePost(request: HttpRequest): HttpResponse = request.readJson() .flatMap { json -> json.toCommand() } .flatMap(::performCommand) .map { outcome -> outcome.toHttpResponse() } .mapFailure { errorCode -> errorCode.toHttpResponse() } .get()
No language support for monads
fun handlePost(request: HttpRequest): Result<HttpResponse,Error> = request.readJson() .flatMap { json -> json.toCommand() .flatMap { command -> loadResourceFor(request) .flatMap { resource -> performCommand(resource, command) .map { outcome ->
} } } }
http://wiki.c2.com/?ArrowAntiPattern
Arrow's binding API
Very clever emulation of Haskell's do syntax for monadic binding
fun handlePost(request: HttpRequest): Either<Error, HttpResponse> = Either.fx { val (json) = request.readJson() val (command) = json.toCommand() val (resource) = loadResource(request) val (outcome) = performCommand(resource, command)
}
fun handlePost(request: HttpRequest): Result<HttpResponse,Error> { val json = request.readJson().onFailure { return it } val command = json.toCommand().onFailure { return it } val resource = loadResource(request).onFailure { return it } val outcome = performCommand(resource, command).onFailure { return it } return Success(outcome.toHttpResponseFor(request)) }
Flatten nesting with inline functions & early returns
inline fun <T, E> Result<T, E>.onFailure(block: (Failure<E>) -> Nothing): T = when (this) { is Success<T> -> value is Failure<E> -> block(this) }
Exceptions or sealed class hierarchy? One hierarchy for all errors?
Separate hierarchies for bounded contexts?
Do we care about stack traces? (Nat’s conclusion: only for programming errors)
How to model error reasons in the Failure case?
A Result type is fine when...
… your team are used to a functional programming style … you don't need stack traces For example
Be aware of when that context changes And convert exceptions to Failures close to source & fuzz test
The sweet spot for our system
#KotlinConf
Nat Pryce @natpryce Duncan McGregor @duncanmcg
Failure is not an Option http://oneeyedmen.com/failure-is-not-an-option-part-1.html Result4K https://github.com/npryce/result4k Snodge https://github.com/npryce/snodge
Error handling strategies for Kotlin programs
Nat Pryce & Duncan McGregor
Compose error-prone and error-free code