Skip to content

Instantly share code, notes, and snippets.

@vkostyukov
Last active July 19, 2018 16:39
Show Gist options
  • Save vkostyukov/6192a5280e485661d3d5f88dd5899f54 to your computer and use it in GitHub Desktop.
Save vkostyukov/6192a5280e485661d3d5f88dd5899f54 to your computer and use it in GitHub Desktop.
Finch: A Life Without Exceptions

Finch: A Life Without Exceptions

Historically, Finch's error handling machinery was built on a very simple yet silly idea that an Endpoint may return a failed Future (i.e., Future.exception). While that doesn't really promote a purely functional approach to error handling (i.e., treat errors as values), it enables quite a few handy setups, including:

  • embedding 3rd-party Finagle clients (that may fail) within endpoints w/o extra boilerplate
  • simple/minimal required endpoints (i.e., body, param, etc) that return A, not Try[A] nor Either[Error, A]

However this part of Finch's design was heavily influenced by Finagle itself (w.r.t. embedding all of its failures in Future.exception), nothing stops us from revisiting this trade-off and possibly discussing paths forward more idiomatic error handling.

Implicit Errors vs. Explicit Errors

One of the most adopted functional patterns is to work with errors explicitly. This implies both treating errors as values and using monadic/applicative compositors to encode the fail-fast or error-accumulation behaviors respectively. Even though functional error handling goes a long way in making it easier to understand how program reacts on failures, it's definitely more verbose and comes with a corresponding degree of boilerplate.

Before (error is not part of a type):

val divOrFail: Endpoint[Int] =
  post("div" :: int :: int) { (a: Int, b: Int) => 
    Ok(a / b)
  } handle { case e: Exception => BadRequest(e) }

After (error is part of a type):

val divOrFail: Endpoint[Either[ArithmeticException, Int]] =
  post("div" :: int :: int) { (a: Int, b: Int) => 
    try Ok(Right(a / b))
    catch { case e: ArithmeticException => BadRequest(Left(e)) }
  }

Both Finch and Finagle deal with errors/exceptions implicitly so that clients and pre-defined endpoints may return a failed Future representing a failure (e.g., transport error, parsing error, etc). Yet given Finch's endorsement of functional programming, it maybe a reasonable design decision to pretend Futures can't fail hence promote an explicit failure handling model.

Currently Available Tooling

Turns out most of the tooling needed for functional error handling is already in Finch and the example above works/compiles just fine. It's perfectly possible to return disjoint unions (i.e., Either) from an endpoint and then explicitly handle/encode a given error, which not necessary is an exception.

This, however, doesn't mean Finch follows this structure within its API. All the built-in required endpoints (i.e., body, param, header) may fail (may evaluate into a failed Future).

While it's quite easy to simulate functional error handling by lifting those "non-safe" endpoints into their "safe" counterparts (through the liftToTry combinator), it still doesn't indicate what type of error is expected on a given endpoint.

val b: Endpoint[Try[Stirng]] = stringBody.liftToTry

Proposed Changes

This writeup proposes a path forward first-class support to functional error handling in Finch. Eventually, Finch should be pretending Futures can't fail and all the errors should be dealt with explicitly (via Either or any other similar tooling).

The transition involves quite a few breaking change hence should be done incrementally, providing certain migration/adoption guides.

Step 1: Safe Required Endpoints

Providing a new set of "safe" endpoints that either return Either[io.finch.Error, A] (single error) or ValidatedNel[io.finch.Error, A] (multiple errors) should be the fist step in this transition. For example, stringBody may only produce single Error.NoPresent, while jsonBody[User] can fail both ways: Error.NoPresent and Error.NoParsed.

Note this step requires finagle/finch#786 since otherwise, it's impossible to distinguish single- vs. multiple-errors cases.

Step 2: Error Accumulation

Currently, Finch accumulates its own errors (encoded as failure Futures) into an Errors type. This has been working pretty great in the past and it might be a good idea to provide symmetric tooling for the new safe endpoints.

Step 3: Deprecating and then Removing Unsafe Endpoints

This has to be done before making any changes to the Output API.

Step 4: Stripping out Output and Outputs

At this point, Output could be just a case class (no need for an ADT). Smart-constructors from Outputs can now all be pointing to just Output.

Step 5: No need for Endpoint.handle

Given that Endpoint should never contain a failed Future, there is no need for handle method.

Note: It's still possible to handle "un-handled" Futures within a Finagle filter.

Step 6: Figuring Out The Story For finch-oauth2

Assuming Step 4 is complete, an Output loses the ability to terminate early through Output.Failure. The only project that was using it is finch-oauth2. That said, it should be restructured.

Note: It's still possible to "terminate early" by returning a failed Future from an endpoint.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment