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 invalid values Numbers out of range ○ ○ Unexpectedly null pointers External Failure ● ○ File not found ○ Socket timeout ● Programming Errors ○ Array out of bounds Invalid state ○ ○ Integer overflow System Errors ● ○ Out of memory ● …
Error handling is hard to get right "Without correct error propagation, "Almost all catastrophic failures any comprehensive failure policy is (92%) are the result of incorrect useless … We find that error handling of non-fatal errors explicitly handling is occasionally correct . signaled in software" Specifically, we see that low-level Simple Testing Can Prevent Most Critical errors are sometimes lost as they Failures: An Analysis of Production Failures in travel through [...] many layers [...]" Distributed Data-Intensive Systems . Ding Yuan, et al., University of Toronto. In Proceedings of the EIO: Error handling is occasionally correct. 11th USENIX Symposium on Operating Systems Design and Implementation, OSDI14, 2014 H. S. Gunawi, et al. In Proc. of the 6th USENIX Conference on File and Storage Technologies, FAST’08, 2008.
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.
But history happened...
And now...
What is the best way to handle errors in Kotlin?
It depends
What it depends on will change
We could… just use exceptions
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) fun parseRequest(request: HttpRequest): BigInteger { return HttpResponse ( HTTP_OK ) 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 ) } fun parseRequest(request: HttpRequest) = perform (action) try { return HttpResponse ( HTTP_OK ) 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 { Can you spot val action = try { parseRequest (request) the bug? } catch (e: BadRequest) { return HttpResponse ( HTTP_BAD_REQUEST ) } perform (action) fun parseRequest(request: HttpRequest) = return HttpResponse ( HTTP_OK ) try { } val json = request. readJson () json["id"].textValue(). toBigInteger () } catch(e: NumberFormatException) { throw BadRequest(e) }
Exception handling bugs may not be visible & are not typechecked fun handlePost(request: HttpRequest): HttpResponse { Can throw JsonException ... val action = try { parseRequest (request) } catch (e: BadRequest) { which is not handled return HttpResponse ( HTTP_BAD_REQUEST ) here ... } perform (action) fun parseRequest(request: HttpRequest) = return HttpResponse ( HTTP_OK ) try { } val json = request. readJson () json["id"].textValue(). toBigInteger () } catch(e: NumberFormatException) { throw BadRequest(e) … 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 ● It can just crash (and maybe rely on a supervisor to restart it) ● It can write a message to stderr and return an error code to the shell It can display a dialog and let the user correct the problem ● Be aware of when that context changes
Avoid errors
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)) }
We could… use null to represent errors
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 Parsing a simple typed value from a string ● ● Looking up data that may not be present Be aware of when that context changes And fuzz test to ensure no unexpected exceptions.
Move errors to the outer layers
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> { ... }
We could… use an algebraic data type (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 -> outcome. toHttpResponseFor (request) } } } } http://wiki.c2.com/?ArrowAntiPattern
Recommend
More recommend