-
Notifications
You must be signed in to change notification settings - Fork 320
Description
The Problem
Most of the various middlewares in tower define an enum Error<E> { Inner(E), .. } to return from Service. A problem starts to arise once you compose multiple middlewares together, but still want to be somewhat generic over handling the errors. Look at this gem:
error: Inner(Inner(Inner(B(Service(Inner(A(B(Error { kind: Connect, details: "interesting" }))))))))
In this case, we've wrapped a hyper::Client in a bunch of middleware. In order to inspect and react to the error (in my case, decide if the error qualifies to retry the request), I could:
- Set my expect error type exactly, and thus use
match e { UnNestAllTheLayers => () }. This is very fragile, since adding new middleware, or changing the order, will cause compilation to fail. It's also hugely tedious being sure we've match every correct combination. - Make some
trait CanRetryand implement it for every single intermediary type. Adding a new middleware means we need to implementCanRetryfor the potentially new nested error enum.
Proposal
A different solution might be better: remove the enum Error<E>, and instead having all middleware just return Box<dyn std::error::Error + Send + Sync>.
The code needed to inspect errors if they were dynamic trait objects changes to something like this:
if let Some(e) = error.downcast_ref::<hyper::Error>() {
if e.is_connect() { // or whatever
return CanRetry::Yes;
}
}Example
Consider tower-in-flight-limit:
impl<S, Req> Service<Req> for InFlightLimit<S>
where
S: Service<Req>,
S::Error: Into<Box<dyn StdError + Send + Sync>>,
{
type Response = S::Response;
type Error = Box<dyn StdError + Send + Sync>;
// ...
}
impl<T> Future for ResponseFuture<T>
where
T: Future,
T::Error: Into<Box<dyn StdError + Send + Sync>>,
{
type Item = T::Item;
type Error = Box<dyn StdError + Send + Sync>;
fn poll(&mut self) -> Poll<Self::Item, Self::Error> {
match self.inner {
Some(ref mut f) => {
match f.poll() {
// ...
Err(e) => {
self.shared.release();
Err(e.into())
}
}
}
None => Err(NoCapacity.into()),
}
}
}This change means that if an error occurs, it will be a Box<dyn StdError + Send + Sync>, but it won't be nested (unless nesting with Error::cause() (now in 1.30, Error::source()!)), and so it should only be either S::Error or NoCapacity.
By requiring the bounds of S::Error: Into<Box<dyn StdError + Send + Sync>>, this allows only boxing the error once. If multiple middleware are wrapped up, and where they would previously return Error::Inner(e), they'll just return the Box<dyn StdError> without wrapping.
A couple downsides exist with this change:
- By moving to trait objects, we can no longer rely on
SendandSyncbeing propagated through the generics, so we would need to requireS;:Error: Send + Sync. In practice, it seems reasonable that errors would beSend + Syncanyways. - This means the error case now causes an allocation, whereas with the
enum, it didn't. The error cause should be infrequent, so the allocations shouldn't happen too often. However, this actually can mean an performance improvement, sinceResult<T, Box<Whatever>>can sometimes remove the need for the tag inResult, ifTis zero-sized. Additionally, nested enums grow in memory, so having a singleBoxcan means theResultis smaller regardless. - If you aren't nesting middleware, then the sum type
enum Errorcan be nice to ensure you handle every error case, and this proposal would force you to do downcast even in those simple cases.