# Random Scala Tip #534: Adopt an Error Handling Convention for `Future`
We seem to be living in the future. Surrounded by an ever increasing number of advanced effect-systems, capability trackers, and even salads1. Yet I'm sure that many of us, in an effort to bring food on the table, are still stuck in the past, using Future
.
There are quite a few things to say about Scala's default Future
implementation, some of them good, some not so much, but I'll focus on one thing: error handling. For better or for worse2, errors in Future
are not tracked by the compiler. That is to say, it's impossible to statically distinguish between a potentially failing Future
instance, and another instance that cannot fail3. More concretely in code:
def doStuff(): Stuffval defaultStuff: Stuff
def foo() = Future(doStuff())
def bar() = Future(doStuff()).recover: case NonFatal(ex) => defaultStuff
val x = foo()val y = bar()
The types of x
and y
are both Future[Stuff]
, despite the fact that bar
handles exceptions, and "cannot fail". There is no way to reflect that difference between x
and y
with types. This leads to some unfortunate consequences.
With this in mind, and without any help from the compiler, we must live in constant fear of exceptions. Can an exception lurk in this Future
or that4?.. What should we do about it? Should we do anything? Every developer touching the codebase might have different answers to these questions which can lead to a host of different surprises.
This is no way to maintain an existence, so in lieu of the type system's support, here is today's tip:
When working with
Future
be explicit about your error handling convention, and make sure your whole team and codebase are aligned to it.
If you adopt this tip, some of the anxiety from what I wrote above might dissipate.
Lest this stays completely vague, let's discuss some concrete error handling conventions we can use.
YOLO
As the name of this convention implies, here we don't worry much about anything, and about Future
errors in particular. You use exceptions in Future
as you see fit, and you never bother handling them in any specific place.
Pros: There's only one answer to all of the questions above, and it requires zero effort or discipline.
Cons: Let me know if you find any.
Full On Defensive
In this convention we move to the other end of the worrying scale: every instance of Future
is suspect, we trust no one but ourselves, and we call recover
(and maybe add some logging for good measure) every time we stumble on Future
in the code.
Pros: Yet again, we have only one answer to all the questions above, it's always recover
.
Cons: This gets old fast, requires discipline, the code gets cluttered with error handling, and letting loose your paranoia is not likely to get you anywhere productive anyways.
Only Defects in Error Channel
This convention is more subtle5: we decree that we only use Future
's error channel for defects in code. I.e., errors that we didn't predict during development. As a rule, we don't handle defects till the "top-level" of the app, where we might recover from the defect with some generic logging6. Defects are not the norm, and when we discover one, we try to eliminate it from our code.
Now what I mean by the "top-level" is context dependent. It might be the actual top-level of the application, somewhere in the "main" of the code. But more typically that would be "per request". So, for example, if you're writing a web server, you will handle errors at the top-level of the request. We wouldn't want to crash the whole server just for one failing request. Similarly, if you're writing some kind of message handler (like with streams, or actors), you would handle errors once per message.
If the Future
's error channel is only used for defects, where do we put all other errors? Somewhere where the compiler can track them, of course. So every time we intentionally want to raise and handle an error we will reflect it with a dedicated type like Either
or one of a panoply of Validation
types that are available in the Scala ecosystem7. As a result, when working with code that can fail and be meaningfully recovered, we will have to deal with something like Future[Either[MyError, Stuff]]
. A bit verbose though it may be, this makes it very explicit when we have an error to deal with, versus when all errors have already been dealt with (when we end up with Future[Stuff]
again). And we are letting the compiler guide us in the right direction in case we forget anything.
Pros: Most of the time6 we know how to answer all of the questions about error-handling, and the compiler is there to help us, so that we don't have to rely on discipline too much.
Cons: Ergonomics, working with nested types can be a bit cumbersome. And if performance is at stake, the extra layer of indirection might be a price that we don't want to pay.
Possible mitigation: If we settle for some specific type for error representation (like Either
), we can add some utilities and convenience syntax for the nested Future[Either[_, _]]
type, like support for mapping/flat-mapping the inner value (or use an existing library solution like EitherT
8).
Others
I'm sure there are many other viable error handling conventions9. The two main criteria I would judge them by:
- How much discipline they require? The less, the better.
- How well can you answer questions about error handling at compile-time? The less thought it requires, the better.
If you have other Future
error handling conventions that you find useful, I'll be glad to hear about them in the comments.
Footnotes
-
An Italian one. ↩
-
Worse. ↩
-
For the purposes of this discussion I'll be ignoring any fatal errors, like out-of-memory, and only consider "user space" errors, like IO exceptions and the like. ↩
-
I'll ignore the fact that Scala has regular runtime exceptions as well. I think it's pretty conventional for Scala developers to eschew the explicit use of
throw
. As a result most of the time I don't feel much of a need to get all defensive about it. On the other hand, sinceFuture
is a datatype, people seem to feel more comfortable to useFuture
's error channel, just as you would withTry
. I don't harp aboutTry
because it's not as contagious asFuture
, and its foremost purpose is error handling, so I expect people to be sufficiently careful around it.Future
's main purpose in not about errors, so it's easier to forget about them. ↩ -
The terminology is inspired by the error channels in the ZIO library. ↩
-
We might have to break the convention in various rare cases. Like when interfacing with
Future
-based code that we don't control. In such cases we will hitrecover
as soon as possible, and then continue pretending that our convention was never broken (kind of like you would withnull
handling when interfacing with Java). ↩ ↩2 -
Or
Try
, but it feels kind of strange to work withFuture[Try[?]]
, sinceFuture
already conceptually contains aTry
, and you cannot define custom errors for it that are reflected in the type. ↩ -
Like adapting something like the capability-tracked "submarine" exception from Cats Effect. It's probably just as applicable to
Future
as it is to Cats Effect. Especially once capture-checking and co. gain some more mainstream adoption. ↩