use tsoracle_core::Epoch;
use crate::attempt::{AttemptOutcome, HintUnusableReason};
use crate::channel_pool::{ChannelPool, LeaderHintLookup, decode_leader_hint};
pub(crate) fn classify_not_leader_hint(
pool: &ChannelPool,
endpoint: &str,
status: tonic::Status,
) -> AttemptOutcome {
let _ = endpoint;
let (hinted_endpoint, hint_epoch) = match decode_leader_hint(&status) {
LeaderHintLookup::Decoded(hint) => {
let epoch = hint
.leader_epoch
.map(|epoch| Epoch::from_wire(epoch.hi, epoch.lo).0);
(hint.leader_endpoint, epoch)
}
LeaderHintLookup::Absent => {
#[cfg(feature = "tracing")]
tracing::warn!(
endpoint = %endpoint,
"tsoracle-client: FAILED_PRECONDITION without leader-hint trailer; \
contacted peer cannot redirect us (no leader yet)",
);
return AttemptOutcome::NoLeaderYet(status);
}
LeaderHintLookup::Malformed => {
#[cfg(feature = "metrics")]
metrics::counter!("tsoracle.client.leader_hint.decode_failures.total").increment(1);
#[cfg(feature = "tracing")]
tracing::warn!(
endpoint = %endpoint,
"tsoracle-client: FAILED_PRECONDITION carried a malformed \
leader-hint trailer; treating as no hint",
);
(None, None)
}
};
let usable_endpoint = hinted_endpoint.filter(|hinted| !rejects_plaintext_hint(pool, hinted));
match usable_endpoint {
Some(hinted) => {
if pool.compare_and_set_leader(hinted.clone(), hint_epoch) {
AttemptOutcome::LeaderHint {
endpoint: hinted,
epoch: hint_epoch,
}
} else {
#[cfg(feature = "tracing")]
tracing::warn!(
from = %endpoint,
to = %hinted,
hint_epoch = ?hint_epoch,
"tsoracle-client: dropping leader hint that cannot outrank \
the cached leader — either a stale epoch behind the cached \
one, or an epoch-less hint to an off-list endpoint",
);
AttemptOutcome::HintUnusable {
status,
reason: HintUnusableReason::StaleEpoch,
}
}
}
None => AttemptOutcome::HintUnusable {
status,
reason: HintUnusableReason::Rejected,
},
}
}
fn rejects_plaintext_hint(pool: &ChannelPool, hint: &str) -> bool {
let reject = pool.tls_required() && hint.starts_with("http://");
#[cfg(feature = "tracing")]
if reject {
tracing::warn!(
hinted_endpoint = %hint,
"tsoracle-client: dropping plaintext leader-hint under tls_config; \
refusing to downgrade transport"
);
}
reject
}
#[cfg(test)]
mod tests {
use super::*;
use crate::RetryPolicy;
use crate::test_support::{enable_tracing, make_status_with_hint};
#[test]
fn plaintext_hint_policy_matches_scheme_and_tls_state() {
enable_tracing();
let tls = ChannelPool::new(vec!["a:1".into()], None, true, RetryPolicy::default());
let plain = ChannelPool::new(vec!["a:1".into()], None, false, RetryPolicy::default());
assert!(
rejects_plaintext_hint(&tls, "http://attacker:1"),
"http:// hint must be rejected under tls_required"
);
assert!(
!rejects_plaintext_hint(&tls, "https://peer:1"),
"https:// hint must be allowed under tls_required"
);
assert!(
!rejects_plaintext_hint(&tls, "peer:1"),
"bare host:port hint must be allowed under tls_required (gets rewritten to https)"
);
assert!(
!rejects_plaintext_hint(&plain, "http://peer:1"),
"http:// hint must be allowed when tls is not required"
);
}
#[test]
fn absent_hint_classifies_as_no_leader_yet() {
let pool = ChannelPool::new(vec!["peer:1".into()], None, false, RetryPolicy::default());
let status = tonic::Status::failed_precondition("electing, no leader yet");
match classify_not_leader_hint(&pool, "peer:1", status) {
AttemptOutcome::NoLeaderYet(s) => {
assert_eq!(s.code(), tonic::Code::FailedPrecondition);
}
other => panic!("absent hint must be NoLeaderYet, got {other:?}"),
}
}
#[test]
fn classify_absent_hint_returns_no_leader_yet() {
enable_tracing();
let pool = ChannelPool::new(vec!["a:1".into()], None, false, RetryPolicy::default());
let status = tonic::Status::failed_precondition("not leader");
match classify_not_leader_hint(&pool, "a:1", status) {
AttemptOutcome::NoLeaderYet(_) => {}
other => panic!("expected NoLeaderYet, got {other:?}"),
}
}
#[test]
fn classify_malformed_hint_returns_rejected() {
enable_tracing();
use tonic::metadata::{BinaryMetadataValue, MetadataKey};
let pool = ChannelPool::new(vec!["a:1".into()], None, false, RetryPolicy::default());
let mut status = tonic::Status::failed_precondition("not leader");
let key = MetadataKey::from_bytes(tsoracle_proto::v1::LEADER_HINT_TRAILER_KEY.as_bytes())
.unwrap();
status.metadata_mut().insert_bin(
key,
BinaryMetadataValue::from_bytes(&[0xff, 0xff, 0xff, 0xff]),
);
match classify_not_leader_hint(&pool, "a:1", status) {
AttemptOutcome::HintUnusable {
reason: HintUnusableReason::Rejected,
..
} => {}
other => panic!("expected HintUnusable {{ reason: Rejected }}, got {other:?}"),
}
}
#[test]
fn classify_higher_epoch_hint_returns_leader_hint() {
let pool = ChannelPool::new(
vec!["a:1".into(), "b:1".into()],
None,
false,
RetryPolicy::default(),
);
pool.record_success("a:1", 5);
let status = make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some("b:1".into()),
leader_epoch: Some(tsoracle_proto::v1::EpochWire { hi: 0, lo: 7 }),
});
match classify_not_leader_hint(&pool, "a:1", status) {
AttemptOutcome::LeaderHint { endpoint, epoch } => {
assert_eq!(endpoint, "b:1");
assert_eq!(epoch, Some(7));
}
other => panic!("expected LeaderHint, got {other:?}"),
}
}
#[test]
fn higher_epoch_hint_wins_regardless_of_arrival_order() {
for stale_first in [true, false] {
let pool = ChannelPool::new(
vec!["a:1".into(), "b:1".into(), "c:1".into()],
None,
false,
RetryPolicy::default(),
);
pool.record_success("a:1", 1);
let fresh = make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some("c:1".into()),
leader_epoch: Some(tsoracle_proto::v1::EpochWire { hi: 0, lo: 9 }),
});
let stale = make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some("b:1".into()),
leader_epoch: Some(tsoracle_proto::v1::EpochWire { hi: 0, lo: 4 }),
});
let ordered = if stale_first {
vec![stale, fresh]
} else {
vec![fresh, stale]
};
for status in ordered {
let _ = classify_not_leader_hint(&pool, "a:1", status);
}
assert_eq!(
pool.cached_leader().as_deref(),
Some("c:1"),
"must follow the epoch-9 leader (stale_first={stale_first})",
);
}
}
#[test]
fn classify_stale_epoch_hint_returns_stale_epoch() {
enable_tracing();
let pool = ChannelPool::new(
vec!["a:1".into(), "b:1".into()],
None,
false,
RetryPolicy::default(),
);
pool.record_success("a:1", 10);
let status = make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some("b:1".into()),
leader_epoch: Some(tsoracle_proto::v1::EpochWire { hi: 0, lo: 5 }),
});
match classify_not_leader_hint(&pool, "a:1", status) {
AttemptOutcome::HintUnusable {
status,
reason: HintUnusableReason::StaleEpoch,
} => {
assert_eq!(status.code(), tonic::Code::FailedPrecondition);
}
other => panic!("expected HintUnusable {{ reason: StaleEpoch }}, got {other:?}"),
}
assert_eq!(pool.cached_leader().as_deref(), Some("a:1"));
}
#[test]
fn classify_no_epoch_hint_returns_leader_hint() {
let pool = ChannelPool::new(
vec!["a:1".into(), "b:1".into()],
None,
false,
RetryPolicy::default(),
);
pool.record_success("a:1", 10);
let status = make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some("b:1".into()),
leader_epoch: None,
});
match classify_not_leader_hint(&pool, "a:1", status) {
AttemptOutcome::LeaderHint { endpoint, epoch } => {
assert_eq!(endpoint, "b:1");
assert_eq!(epoch, None);
}
other => panic!("expected LeaderHint, got {other:?}"),
}
}
#[test]
fn classify_plaintext_hint_under_tls_returns_rejected() {
enable_tracing();
let pool = ChannelPool::new(vec!["a:1".into()], None, true, RetryPolicy::default());
let status = make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some("http://attacker:1".into()),
leader_epoch: Some(tsoracle_proto::v1::EpochWire { hi: 0, lo: 7 }),
});
match classify_not_leader_hint(&pool, "a:1", status) {
AttemptOutcome::HintUnusable {
reason: HintUnusableReason::Rejected,
..
} => {}
other => panic!("expected HintUnusable {{ reason: Rejected }}, got {other:?}"),
}
}
}