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 returnA
, notTry[A]
norEither[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.
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 Future
s can't fail hence promote an explicit failure handling model.
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
This writeup proposes a path forward first-class support to functional error handling in Finch. Eventually, Finch should be pretending Future
s 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.
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.
Currently, Finch accumulates its own errors (encoded as failure Future
s) 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.
This has to be done before making any changes to the Output
API.
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
.
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" Future
s within a Finagle filter.
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.