use std::fmt;
use std::hash::{BuildHasher, Hasher, RandomState};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use tracing::{debug, info, warn};
use crate::readiness::ReadinessGate;
const DEFAULT_MIN_BACKOFF: Duration = Duration::from_millis(250);
const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(10);
const DEFAULT_CONNECT_DEADLINE: Duration = Duration::from_secs(60);
const DEFAULT_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(5);
const PING_INTERVAL: Duration = Duration::from_secs(10);
const MAX_BACKOFF_SHIFT: u32 = 16;
enum Credentials {
File(PathBuf),
Inline(String),
}
#[derive(Debug)]
pub enum ConnectorError {
CredentialsUnreadable {
path: PathBuf,
source: std::io::Error,
},
CredentialsInvalid {
source: std::io::Error,
},
Unreachable {
url: String,
attempts: u32,
elapsed: Duration,
source: Option<async_nats::ConnectError>,
},
}
impl fmt::Display for ConnectorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CredentialsUnreadable { path, source } => {
write!(
f,
"cannot read NATS credentials at {}: {source}",
path.display()
)
}
Self::CredentialsInvalid { source } => {
write!(f, "invalid NATS credentials: {source}")
}
Self::Unreachable {
url,
attempts,
elapsed,
source,
} => {
write!(
f,
"NATS at {url} unreachable after {attempts} attempts over {elapsed:.1?}"
)?;
match source {
Some(source) => write!(f, ": {source}"),
None => write!(f, ": every attempt timed out"),
}
}
}
}
}
impl std::error::Error for ConnectorError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::CredentialsUnreadable { source, .. } => Some(source),
Self::CredentialsInvalid { source } => Some(source),
Self::Unreachable { source, .. } => source
.as_ref()
.map(|source| source as &dyn std::error::Error),
}
}
}
pub struct Connector {
url: String,
credentials: Option<Credentials>,
client_name: Option<String>,
min_backoff: Duration,
max_backoff: Duration,
deadline: Duration,
attempt_timeout: Duration,
inbox_prefix: Option<String>,
readiness: Option<ReadinessGate>,
}
impl Connector {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
credentials: None,
client_name: None,
min_backoff: DEFAULT_MIN_BACKOFF,
max_backoff: DEFAULT_MAX_BACKOFF,
deadline: DEFAULT_CONNECT_DEADLINE,
attempt_timeout: DEFAULT_ATTEMPT_TIMEOUT,
inbox_prefix: None,
readiness: None,
}
}
pub fn credentials(mut self, creds: impl Into<String>) -> Self {
self.credentials = Some(Credentials::Inline(creds.into()));
self
}
pub fn credentials_file(mut self, path: impl Into<PathBuf>) -> Self {
self.credentials = Some(Credentials::File(path.into()));
self
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.client_name = Some(name.into());
self
}
pub fn inbox_prefix(mut self, prefix: impl Into<String>) -> Self {
self.inbox_prefix = Some(prefix.into());
self
}
pub fn readiness(mut self, gate: ReadinessGate) -> Self {
self.readiness = Some(gate);
self
}
pub fn backoff(mut self, min: Duration, max: Duration) -> Self {
self.min_backoff = min;
self.max_backoff = max.max(min);
self
}
pub fn connect_deadline(mut self, deadline: Duration) -> Self {
self.deadline = deadline;
self
}
pub fn attempt_timeout(mut self, timeout: Duration) -> Self {
self.attempt_timeout = timeout;
self
}
pub async fn connect_with_retry(self) -> Result<async_nats::Client, ConnectorError> {
let credentials = self.read_credentials()?;
let started = Instant::now();
let mut attempts: u32 = 0;
let mut last_error = None;
loop {
let remaining = self.deadline.saturating_sub(started.elapsed());
if remaining.is_zero() {
return Err(self.unreachable(attempts, started.elapsed(), last_error));
}
let options = self.options(credentials.as_deref())?;
attempts += 1;
let attempt = tokio::time::timeout(
remaining.min(self.attempt_timeout),
options.connect(&self.url),
)
.await;
let source = match attempt {
Ok(Ok(client)) => {
self.mark_connected(attempts);
return Ok(client);
}
Ok(Err(source)) => Some(source),
Err(_) => None,
};
let elapsed = started.elapsed();
let delay = backoff(self.min_backoff, self.max_backoff, attempts);
if elapsed + delay >= self.deadline {
return Err(self.unreachable(attempts, elapsed, source.or(last_error)));
}
match &source {
Some(error) => warn!(
url = %self.url,
attempts,
?delay,
%error,
"NATS connect failed, retrying"
),
None => warn!(
url = %self.url,
attempts,
?delay,
timeout = ?self.attempt_timeout,
"NATS connect attempt timed out, retrying"
),
}
last_error = source.or(last_error);
tokio::time::sleep(delay).await;
}
}
fn mark_connected(&self, attempts: u32) {
if let Some(gate) = &self.readiness {
gate.set_ready(true);
}
info!(url = %self.url, attempts, "connected to NATS");
}
fn unreachable(
&self,
attempts: u32,
elapsed: Duration,
source: Option<async_nats::ConnectError>,
) -> ConnectorError {
ConnectorError::Unreachable {
url: self.url.clone(),
attempts,
elapsed,
source,
}
}
fn read_credentials(&self) -> Result<Option<String>, ConnectorError> {
match &self.credentials {
None => Ok(None),
Some(Credentials::Inline(creds)) => Ok(Some(creds.clone())),
Some(Credentials::File(path)) => read_credentials_file(path).map(Some),
}
}
fn options(
&self,
credentials: Option<&str>,
) -> Result<async_nats::ConnectOptions, ConnectorError> {
let mut options = async_nats::ConnectOptions::new();
if let Some(creds) = credentials {
options = options
.credentials(creds)
.map_err(|source| ConnectorError::CredentialsInvalid { source })?;
}
if let Some(name) = &self.client_name {
options = options.name(name);
}
if let Some(prefix) = &self.inbox_prefix {
options = options.custom_inbox_prefix(prefix);
}
let (min, max) = (self.min_backoff, self.max_backoff);
options = options
.ping_interval(PING_INTERVAL)
.reconnect_delay_callback(move |attempts| match attempts {
0 | 1 => Duration::ZERO,
retry => backoff(min, max, u32::try_from(retry - 1).unwrap_or(u32::MAX)),
});
let gate = self.readiness.clone();
Ok(options.event_callback(move |event| {
let gate = gate.clone();
async move { on_event(&event, gate.as_ref()) }
}))
}
}
fn read_credentials_file(path: &Path) -> Result<String, ConnectorError> {
std::fs::read_to_string(path).map_err(|source| ConnectorError::CredentialsUnreadable {
path: path.to_path_buf(),
source,
})
}
fn on_event(event: &async_nats::Event, gate: Option<&ReadinessGate>) {
match event {
async_nats::Event::Connected => {
info!(%event, "NATS connection established");
if let Some(gate) = gate {
gate.set_ready(true);
}
}
async_nats::Event::Disconnected
| async_nats::Event::Draining
| async_nats::Event::Closed => {
warn!(%event, "NATS connection unusable");
if let Some(gate) = gate {
gate.set_ready(false);
}
}
other => debug!(event = %other, "NATS client event"),
}
}
fn backoff(min: Duration, max: Duration, attempt: u32) -> Duration {
let step = attempt.saturating_sub(1).min(MAX_BACKOFF_SHIFT);
let capped = min.saturating_mul(1u32 << step).min(max);
let half = capped / 2;
half + spread(half)
}
fn spread(span: Duration) -> Duration {
let nanos = u64::try_from(span.as_nanos()).unwrap_or(u64::MAX);
if nanos == 0 {
return Duration::ZERO;
}
Duration::from_nanos(entropy() % nanos)
}
fn entropy() -> u64 {
RandomState::new().build_hasher().finish()
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use super::{backoff, Connector, ConnectorError};
use crate::readiness::Readiness;
const MIN: Duration = Duration::from_millis(100);
const MAX: Duration = Duration::from_millis(800);
#[test]
fn backoff_stays_within_the_configured_bounds() {
for attempt in 1..=32 {
let delay = backoff(MIN, MAX, attempt);
assert!(delay >= MIN / 2, "attempt {attempt} waited {delay:?}");
assert!(delay <= MAX, "attempt {attempt} waited {delay:?}");
}
}
#[test]
fn backoff_ramps_up_to_the_ceiling() {
assert!(backoff(MIN, MAX, 1) < MIN);
assert!(backoff(MIN, MAX, 4) >= MAX / 2);
assert!(backoff(MIN, MAX, 12) >= MAX / 2);
}
#[test]
fn a_zero_span_yields_no_delay() {
assert_eq!(backoff(Duration::ZERO, Duration::ZERO, 3), Duration::ZERO);
}
#[tokio::test]
async fn an_unreachable_broker_reports_the_attempts_it_made() {
let readiness = Readiness::new();
let gate = readiness.gate("nats");
let error = Connector::new("127.0.0.1:1")
.backoff(Duration::from_millis(10), Duration::from_millis(20))
.connect_deadline(Duration::from_millis(200))
.readiness(gate)
.connect_with_retry()
.await
.expect_err("nothing listens on port 1");
let ConnectorError::Unreachable { attempts, url, .. } = error else {
panic!("expected an unreachable error, got {error:?}");
};
assert_eq!(url, "127.0.0.1:1");
assert!(attempts >= 1, "expected at least one attempt");
assert!(
!readiness.is_ready(),
"a failed connect must not open the gate"
);
}
#[tokio::test]
async fn the_connect_deadline_bounds_the_wait() {
let budget = Duration::from_millis(300);
let started = Instant::now();
Connector::new("127.0.0.1:1")
.backoff(Duration::from_millis(10), Duration::from_millis(20))
.connect_deadline(budget)
.connect_with_retry()
.await
.expect_err("nothing listens on port 1");
assert!(
started.elapsed() < budget * 4,
"overran the budget: {:?}",
started.elapsed()
);
}
#[tokio::test]
async fn a_hung_attempt_is_abandoned_for_another() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind a silent port");
let addr = listener.local_addr().expect("read the silent port");
let silent = tokio::spawn(async move {
let mut held = Vec::new();
while let Ok((socket, _)) = listener.accept().await {
held.push(socket);
}
});
let error = Connector::new(addr.to_string())
.backoff(Duration::from_millis(10), Duration::from_millis(20))
.attempt_timeout(Duration::from_millis(100))
.connect_deadline(Duration::from_millis(600))
.connect_with_retry()
.await
.expect_err("the listener never speaks NATS");
silent.abort();
let ConnectorError::Unreachable {
attempts, source, ..
} = error
else {
panic!("expected an unreachable error, got {error:?}");
};
assert!(attempts >= 2, "made only {attempts} attempt(s)");
assert!(source.is_none(), "no attempt should have finished");
}
const UNSPENT_BUDGET: Duration = Duration::from_secs(30);
#[tokio::test]
async fn a_missing_credentials_file_fails_without_retrying() {
let started = Instant::now();
let error = Connector::new("127.0.0.1:1")
.credentials_file("/nonexistent/flatbed/user.creds")
.connect_deadline(UNSPENT_BUDGET)
.connect_with_retry()
.await
.expect_err("the credentials file does not exist");
assert!(
matches!(error, ConnectorError::CredentialsUnreadable { .. }),
"expected an unreadable-credentials error, got {error:?}"
);
assert!(started.elapsed() < UNSPENT_BUDGET / 2);
}
#[tokio::test]
async fn malformed_credentials_fail_without_retrying() {
let started = Instant::now();
let error = Connector::new("127.0.0.1:1")
.credentials("not a nats credentials file")
.connect_deadline(UNSPENT_BUDGET)
.connect_with_retry()
.await
.expect_err("the credentials do not parse");
assert!(
matches!(error, ConnectorError::CredentialsInvalid { .. }),
"expected an invalid-credentials error, got {error:?}"
);
assert!(started.elapsed() < UNSPENT_BUDGET / 2);
}
}