Skip to content

decide the future of tower::filter #502

Description

@hawkw

Motivation

It was pointed out that there is currently some overlap between the try_with Service combinator and tower::filter middleware (see #499 (comment)). try_with synchronously maps from a Request -> Result<DifferentRequest, Error>, while tower::filter asynchronously maps from a &Request to a Result<(), Error>. The key differences are:

  • try_with takes a request by value, and allows the predicate to return a different request value
  • try_with also permits changing the type of the request
  • try_with is synchronous, while tower::filter is asynchronous
  • tower::filter has a Predicate trait, which can be implemented by more than just functions. For example, a struct with a HashSet could implement Predicate by failing requests that match the values in the hashset.

It definitely seems like there's demand for both synchronous and asynchronous request filtering. However, the APIs we have currently differ pretty significantly. It would be nice to make them more consistent with each other.

As an aside, tower::filter does not seem all that widely used.

Meanwhile, linkerd2-proxy defines its own RequestFilter middleware, using a predicate trait that's essentially in between tower::filter and ServiceExt::try_with:

  • it's synchronous, like try_with
  • it allows modifying the type of the request, like try_with
  • it uses a trait for predicates, rather than a Fn, like tower::filter
  • it uses a similar naming scheme to tower::filter ("filtering" rather than "with"/"map")

Proposal

I think we should try to unify these APIs into something with a more consistent interface. I suggest the following:

  • continue using the "Filter"/"Predicate" terminology rather than the function composition terminology ("with"/"map")
  • take Request types by value rather than by reference, and allow returning a new request type
  • continue providing traits that can be implemented by things other than functions
  • provide both sync and async filtering APIs

It's theoretically possible to have one API for both sync and async filtering, but this would require a trait like IntoFuture that can be implemented by both Future<Output = Result<T, E> and Result<T, E>. A predicate trait could return something implementing that trait, and Result could have an impl that turns itself into a ready future. This would let us have one API for both sync and async predicates. However, it introduces some complexity in the type signatures, and requires a slight added overhead (polling the immediately ready future when the predicate is synchronous). The added complexity of using an IntoFuture-like trait might be less of an issue when the standard library stabilizes IntoFuture...

On the other hand, we could have separate sync and async filter APIs. The main disadvantage of that approach is that we'd then need to bikeshed a consistent naming scheme that makes both the distinction and the similarity similar. Historically, we've not been great at naming things efficiently ;)

cc @LucioFranco @seanmonstar @olix0r @hlb8122 --- what do you all think?

https://github.com/linkerd/linkerd2-proxy/blob/main/linkerd/stack/src/request_filter.rs

Metadata

Metadata

Assignees

Labels

A-filterArea: The tower "filter" middlewareA-new-middlewareArea: new middleware proposalsA-utilArea: The tower "util" moduleC-cleanupCategory: PRs that clean code up or issues documenting cleanup.C-feature-requestCategory: A feature request, i.e: not implemented / a PR.C-musingCategory: musings about a better worldI-needs-decisionIssues in need of decision.P-highHigh priorityT-middlewareTopic: middlewarerelnotesMarks issues that should be documented in the release notes of the next release.

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions