Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions tonic/src/transport/channel/endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ pub struct Endpoint {
pub(crate) tcp_keepalive_interval: Option<Duration>,
pub(crate) tcp_keepalive_retries: Option<u32>,
pub(crate) tcp_nodelay: bool,
pub(crate) eager_connect_errors: bool,
pub(crate) http2_keep_alive_interval: Option<Duration>,
pub(crate) http2_keep_alive_timeout: Option<Duration>,
pub(crate) http2_keep_alive_while_idle: Option<bool>,
Expand Down Expand Up @@ -121,6 +122,7 @@ impl Endpoint {
tcp_keepalive_interval: None,
tcp_keepalive_retries: None,
tcp_nodelay: true,
eager_connect_errors: false,
http2_keep_alive_interval: None,
http2_keep_alive_timeout: None,
http2_keep_alive_while_idle: None,
Expand Down Expand Up @@ -152,6 +154,7 @@ impl Endpoint {
tcp_keepalive_interval: None,
tcp_keepalive_retries: None,
tcp_nodelay: true,
eager_connect_errors: false,
http2_keep_alive_interval: None,
http2_keep_alive_timeout: None,
http2_keep_alive_while_idle: None,
Expand Down Expand Up @@ -457,6 +460,33 @@ impl Endpoint {
}
}

/// Sets whether connection errors should be surfaced immediately, rather than only
/// once an actual call is made.
///
/// When enabled, a failed connection attempt is reported right away instead of being
/// deferred and retried transparently on the next call.
///
/// This has no effect on channels that connect eagerly which already surface their
/// initial connection error immediately; it only matters for lazily-connecting channels
/// (see [`Endpoint::connect_lazy`] and [`Endpoint::connect_with_connector_lazy`]).
///
/// For endpoints used with
/// [`Channel::balance_channel`](crate::transport::Channel::balance_channel) or
/// [`Channel::balance_list`](crate::transport::Channel::balance_list), enabling this
/// lets the load balancer identify a broken backend immediately and skip it rather
/// than routing a call to it. Such a backend is removed from the balancer as soon
/// as its connection attempt fails; it automatically rejoins the balancer shortly
/// after (on a short, fixed backoff) rather than being lost until the caller
/// explicitly re-adds it.
///
/// Defaults to `false`.
pub fn eager_connect_errors(self, enabled: bool) -> Self {
Endpoint {
eager_connect_errors: enabled,
..self
}
}

/// Set http2 KEEP_ALIVE_INTERVAL. Uses `hyper`'s default otherwise.
pub fn http2_keep_alive_interval(self, interval: Duration) -> Self {
Endpoint {
Expand Down
2 changes: 1 addition & 1 deletion tonic/src/transport/channel/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ impl Channel {
E: Executor<Pin<Box<dyn Future<Output = ()> + Send>>> + Send + Sync + 'static,
{
let (tx, rx) = channel(capacity);
let list = DynamicServiceStream::new(rx);
let list = DynamicServiceStream::new(rx, tx.clone());
(Self::balance(list, DEFAULT_BUFFER_SIZE, executor), tx)
}

Expand Down
112 changes: 107 additions & 5 deletions tonic/src/transport/channel/service/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
*
*/

use super::{AddOrigin, Reconnect, SharedExec, UserAgent};
use super::{AddOrigin, Change, Reconnect, SharedExec, UserAgent};
use crate::{
body::Body,
transport::{Endpoint, channel::BoxFuture, service::GrpcTimeout},
Expand All @@ -34,7 +34,9 @@ use hyper_util::rt::TokioTimer;
use std::{
fmt,
task::{Context, Poll},
time::Duration,
};
use tokio::sync::mpsc;
use tower::load::Load;
use tower::{
ServiceBuilder, ServiceExt,
Expand All @@ -44,12 +46,16 @@ use tower::{
};
use tower_service::Service;

/// How long to wait before re-inserting a balanced endpoint that was evicted after a
/// failed connection attempt. See [`ReinsertOnError`].
const REINSERT_BACKOFF: Duration = Duration::from_secs(1);

pub(crate) struct Connection {
inner: BoxService<Request<Body>, Response<Body>, crate::BoxError>,
}

impl Connection {
fn new<C>(connector: C, endpoint: Endpoint, is_lazy: bool) -> Self
fn new<C>(connector: C, endpoint: Endpoint, is_lazy: bool, fail_early: bool) -> Self
where
C: Service<Uri> + Send + 'static,
C::Error: Into<crate::BoxError> + Send,
Expand Down Expand Up @@ -102,7 +108,7 @@ impl Connection {
let make_service =
MakeSendRequestService::new(connector, endpoint.executor.clone(), settings);

let conn = Reconnect::new(make_service, endpoint.uri().clone(), is_lazy);
let conn = Reconnect::new(make_service, endpoint.uri().clone(), is_lazy, fail_early);

Self {
inner: BoxService::new(stack.layer(conn)),
Expand All @@ -119,7 +125,10 @@ impl Connection {
C::Future: Unpin + Send,
C::Response: rt::Read + rt::Write + Unpin + Send + 'static,
{
Self::new(connector, endpoint, false).ready_oneshot().await
let fail_early = endpoint.eager_connect_errors;
Self::new(connector, endpoint, false, fail_early)
.ready_oneshot()
.await
}

pub(crate) fn lazy<C>(connector: C, endpoint: Endpoint) -> Self
Expand All @@ -129,7 +138,100 @@ impl Connection {
C::Future: Send,
C::Response: rt::Read + rt::Write + Unpin + Send + 'static,
{
Self::new(connector, endpoint, true)
let fail_early = endpoint.eager_connect_errors;
Self::new(connector, endpoint, true, fail_early)
}

/// Like [`Connection::lazy`], but for connections managed by a discovery-driven
/// [`tower::balance::p2c::Balance`].
///
/// `Balance` polls its services through a `tower::ready_cache::ReadyCache`, which
/// permanently evicts any service whose `poll_ready` returns `Err` and never
/// retries it on its own. Since [`Endpoint::eager_connect_errors`] intentionally
/// causes connect failures to surface from `poll_ready` (so `Balance` can skip a
/// broken endpoint instead of routing a call to it), a connection built here is
/// wrapped in [`ReinsertOnError`] so that an eviction like that is followed by an
/// automatic re-insert of the same key after a short backoff, instead of the
/// endpoint being lost until the caller notices and re-inserts it manually.
pub(crate) fn lazy_for_discovery<C, K>(
connector: C,
endpoint: Endpoint,
key: K,
reinsert: mpsc::Sender<Change<K, Endpoint>>,
) -> Self
where
C: Service<Uri> + Send + 'static,
C::Error: Into<crate::BoxError> + Send,
C::Future: Send,
C::Response: rt::Read + rt::Write + Unpin + Send + 'static,
K: Clone + Send + 'static,
{
let fail_early = endpoint.eager_connect_errors;
let executor = endpoint.executor.clone();
let retry_endpoint = endpoint.clone();
let inner = Self::new(connector, endpoint, true, fail_early);

Self {
inner: BoxService::new(ReinsertOnError {
inner,
key,
endpoint: retry_endpoint,
executor,
reinsert,
}),
}
}
}

/// Wraps a discovery-managed [`Connection`] so that a `poll_ready` error (which causes
/// `tower`'s `Balance`/`ReadyCache` to evict it permanently, see [`Connection::lazy_for_discovery`])
/// is followed by scheduling a fresh [`Change::Insert`] for the same key after
/// [`REINSERT_BACKOFF`], so the endpoint can rejoin the balancer on its own.
struct ReinsertOnError<K> {
inner: Connection,
key: K,
endpoint: Endpoint,
executor: SharedExec,
reinsert: mpsc::Sender<Change<K, Endpoint>>,
}

impl<K> ReinsertOnError<K>
where
K: Clone + Send + 'static,
{
fn schedule_reinsert(&self) {
let key = self.key.clone();
let endpoint = self.endpoint.clone();
let reinsert = self.reinsert.clone();

Executor::<BoxFuture<'static, ()>>::execute(
&self.executor,
Box::pin(async move {
tokio::time::sleep(REINSERT_BACKOFF).await;
let _ = reinsert.send(Change::Insert(key, endpoint)).await;
}) as _,
);
}
}

impl<K> Service<Request<Body>> for ReinsertOnError<K>
where
K: Clone + Send + 'static,
{
type Response = Response<Body>;
type Error = crate::BoxError;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;

fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
let poll = Service::poll_ready(&mut self.inner, cx);
if let Poll::Ready(Err(_)) = &poll {
self.schedule_reinsert();
}
poll
}

fn call(&mut self, req: Request<Body>) -> Self::Future {
self.inner.call(req)
}
}

Expand Down
22 changes: 17 additions & 5 deletions tonic/src/transport/channel/service/discover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use std::{
pin::Pin,
task::{Context, Poll},
};
use tokio::sync::mpsc::Receiver;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio_stream::Stream;
use tower::discover::Change as TowerChange;

Expand All @@ -44,23 +44,35 @@ pub enum Change<K, V> {

pub(crate) struct DynamicServiceStream<K: Hash + Eq + Clone> {
changes: Receiver<Change<K, Endpoint>>,
/// A sender back into the same channel `changes` reads from, handed to each
/// discovered [`Connection`] so it can re-insert itself after being evicted by
/// `Balance` for a connect failure. See [`Connection::lazy_for_discovery`].
reinsert: Sender<Change<K, Endpoint>>,
}

impl<K: Hash + Eq + Clone> DynamicServiceStream<K> {
pub(crate) fn new(changes: Receiver<Change<K, Endpoint>>) -> Self {
Self { changes }
pub(crate) fn new(
changes: Receiver<Change<K, Endpoint>>,
reinsert: Sender<Change<K, Endpoint>>,
) -> Self {
Self { changes, reinsert }
}
}

impl<K: Hash + Eq + Clone> Stream for DynamicServiceStream<K> {
impl<K: Hash + Eq + Clone + Send + 'static> Stream for DynamicServiceStream<K> {
type Item = Result<TowerChange<K, Connection>, crate::BoxError>;

fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
match Pin::new(&mut self.changes).poll_recv(cx) {
Poll::Pending | Poll::Ready(None) => Poll::Pending,
Poll::Ready(Some(change)) => match change {
Change::Insert(k, endpoint) => {
let connection = Connection::lazy(endpoint.http_connector(), endpoint);
let connection = Connection::lazy_for_discovery(
endpoint.http_connector(),
endpoint,
k.clone(),
self.reinsert.clone(),
);
Poll::Ready(Some(Ok(TowerChange::Insert(k, connection))))
}
Change::Remove(k) => Poll::Ready(Some(Ok(TowerChange::Remove(k)))),
Expand Down
10 changes: 8 additions & 2 deletions tonic/src/transport/channel/service/reconnect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ where
error: Option<crate::BoxError>,
has_been_connected: bool,
is_lazy: bool,
fail_early: bool,
}

#[derive(Debug)]
Expand All @@ -58,16 +59,21 @@ where
M: Service<Target>,
M::Error: Into<crate::BoxError>,
{
pub(crate) fn new(mk_service: M, target: Target, is_lazy: bool) -> Self {
pub(crate) fn new(mk_service: M, target: Target, is_lazy: bool, fail_early: bool) -> Self {
Reconnect {
mk_service,
state: State::Idle,
target,
error: None,
has_been_connected: false,
is_lazy,
fail_early,
}
}

fn return_connection_errors_on_poll(&self) -> bool {
self.fail_early || !(self.has_been_connected || self.is_lazy)
}
}

impl<M, Target, S, Request> Service<Request> for Reconnect<M, Target>
Expand Down Expand Up @@ -121,7 +127,7 @@ where

state = State::Idle;

if !(self.has_been_connected || self.is_lazy) {
if self.return_connection_errors_on_poll() {
return Poll::Ready(Err(e.into()));
Comment on lines +130 to 131

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might cause problems, such as iirc tower will remove this connection from the readycache and make it never available again. I don't think that is the behavior you want right?

@hermeGarcia hermeGarcia Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we definitely don't want that.
Thanks for pointing it out!

} else {
let error = e.into();
Expand Down
Loading