use std::{
io,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
time::{Duration, Instant},
};
const RETRY_MIN: Duration = Duration::from_millis(100);
const RETRY_MAX: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Failure {
Connection,
Exhausted,
Unknown,
}
impl Failure {
pub const ALL: &'static [Failure] = &[Failure::Connection, Failure::Exhausted, Failure::Unknown];
pub fn classify(err: &io::Error) -> Self {
match err.raw_os_error() {
Some(code) if exhausted(code) => Self::Exhausted,
Some(code) if per_connection(code) => Self::Connection,
_ => Self::Unknown,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Connection => "connection",
Self::Exhausted => "exhausted",
Self::Unknown => "unknown",
}
}
}
impl std::fmt::Display for Failure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(unix)]
fn exhausted(code: i32) -> bool {
[libc::EMFILE, libc::ENFILE, libc::ENOBUFS, libc::ENOMEM].contains(&code)
}
#[cfg(unix)]
fn per_connection(code: i32) -> bool {
let common = [
libc::ECONNABORTED,
libc::ECONNRESET,
libc::ETIMEDOUT,
libc::EPERM,
libc::EPROTO,
libc::ENOPROTOOPT,
libc::EOPNOTSUPP,
libc::EHOSTDOWN,
libc::EHOSTUNREACH,
libc::ENETDOWN,
libc::ENETUNREACH,
libc::EINTR,
]
.contains(&code);
#[cfg(any(target_os = "linux", target_os = "android"))]
let common = common || code == libc::ENONET;
common
}
#[cfg(windows)]
fn exhausted(code: i32) -> bool {
[
10024, 10055, ]
.contains(&code)
}
#[cfg(windows)]
fn per_connection(code: i32) -> bool {
[
10053, 10054, ]
.contains(&code)
}
#[cfg(not(any(unix, windows)))]
fn exhausted(_code: i32) -> bool {
false
}
#[cfg(not(any(unix, windows)))]
fn per_connection(_code: i32) -> bool {
false
}
#[derive(Clone)]
pub struct Health(Arc<Inner>);
struct Inner {
listener: &'static str,
connection: AtomicU64,
exhausted: AtomicU64,
unknown: AtomicU64,
state: parking_lot::Mutex<State>,
}
struct State {
stall: Option<Instant>,
consecutive: u64,
delay: Duration,
}
impl Health {
pub fn new(listener: &'static str) -> Self {
Self(Arc::new(Inner {
listener,
connection: AtomicU64::new(0),
exhausted: AtomicU64::new(0),
unknown: AtomicU64::new(0),
state: parking_lot::Mutex::new(State {
stall: None,
consecutive: 0,
delay: RETRY_MIN,
}),
}))
}
pub fn listener(&self) -> &'static str {
self.0.listener
}
pub fn accepted(&self) {
let mut state = self.0.state.lock();
if state.stall.take().is_some() {
tracing::info!(listener = self.0.listener, "listener is accepting again");
}
state.consecutive = 0;
state.delay = RETRY_MIN;
}
#[must_use = "an accept failure that is not one connection's fault must be paced, or the loop spins"]
pub fn failed(&self, err: &io::Error) -> Option<Duration> {
let failure = Failure::classify(err);
self.counter(failure).fetch_add(1, Ordering::Relaxed);
if failure == Failure::Connection {
tracing::debug!(listener = self.0.listener, %err, "dropped a connection before accepting it");
return None;
}
let mut state = self.0.state.lock();
state.consecutive += 1;
let delay = jitter(state.delay);
state.delay = (state.delay * 2).min(RETRY_MAX);
let stalled = match failure {
Failure::Exhausted => Some(state.stall.get_or_insert_with(Instant::now).elapsed()),
_ => None,
};
tracing::warn!(
listener = self.0.listener,
%err,
class = failure.as_str(),
consecutive = state.consecutive,
stalled_secs = stalled.map(|stalled| stalled.as_secs()),
retry_in_ms = delay.as_millis(),
"accept failed; the listener is not serving new connections"
);
Some(delay)
}
pub fn failures(&self, failure: Failure) -> u64 {
self.counter(failure).load(Ordering::Relaxed)
}
pub fn stalled(&self) -> Option<Duration> {
self.0.state.lock().stall.map(|since| since.elapsed())
}
fn counter(&self, failure: Failure) -> &AtomicU64 {
match failure {
Failure::Connection => &self.0.connection,
Failure::Exhausted => &self.0.exhausted,
Failure::Unknown => &self.0.unknown,
}
}
}
impl std::fmt::Debug for Health {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Health")
.field("listener", &self.0.listener)
.field("connection", &self.failures(Failure::Connection))
.field("exhausted", &self.failures(Failure::Exhausted))
.field("unknown", &self.failures(Failure::Unknown))
.field("stalled", &self.stalled())
.finish()
}
}
fn jitter(delay: Duration) -> Duration {
use rand::RngExt as _;
delay.mul_f64(0.5 + rand::rng().random::<f64>() / 2.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn io(code: i32) -> io::Error {
io::Error::from_raw_os_error(code)
}
#[cfg(unix)]
#[test]
fn classifies_exhaustion_apart_from_dead_connections() {
for code in [
libc::ECONNABORTED,
libc::ECONNRESET,
libc::EPERM,
libc::EPROTO,
libc::ETIMEDOUT,
libc::EHOSTUNREACH,
libc::ENETDOWN,
libc::EINTR,
] {
assert_eq!(Failure::classify(&io(code)), Failure::Connection, "errno {code}");
}
for code in [libc::EMFILE, libc::ENFILE, libc::ENOBUFS, libc::ENOMEM] {
assert_eq!(Failure::classify(&io(code)), Failure::Exhausted, "errno {code}");
}
assert_eq!(Failure::classify(&io(libc::EINVAL)), Failure::Unknown);
assert_eq!(Failure::classify(&io::Error::other("never seen")), Failure::Unknown);
}
#[cfg(any(target_os = "linux", target_os = "android"))]
#[test]
fn linux_pending_network_errors_are_per_connection() {
assert_eq!(Failure::classify(&io(libc::ENONET)), Failure::Connection);
}
#[cfg(windows)]
#[test]
fn windows_subsystem_failure_is_not_per_connection() {
assert_eq!(Failure::classify(&io(10050)), Failure::Unknown);
assert_eq!(Failure::classify(&io(10054)), Failure::Connection);
assert_eq!(Failure::classify(&io(10024)), Failure::Exhausted);
}
#[cfg(unix)]
#[test]
fn a_dead_connection_never_pauses_the_listener() {
let health = Health::new("test");
assert_eq!(health.failed(&io(libc::ECONNABORTED)), None);
assert_eq!(health.failures(Failure::Connection), 1);
assert_eq!(health.stalled(), None, "a dead connection is not a stall");
}
#[cfg(unix)]
#[test]
fn exhaustion_escalates_and_caps() {
let health = Health::new("test");
for expected in [RETRY_MIN, RETRY_MIN * 2, RETRY_MIN * 4] {
let delay = health.failed(&io(libc::EMFILE)).expect("exhaustion must pace");
assert!(
delay >= expected / 2 && delay <= expected,
"{delay:?} outside {expected:?}"
);
}
for _ in 0..20 {
let delay = health.failed(&io(libc::EMFILE)).expect("exhaustion must pace");
assert!(delay <= RETRY_MAX);
}
assert!(health.stalled().is_some(), "exhaustion is a sustained condition");
assert_eq!(health.failures(Failure::Exhausted), 23);
assert_eq!(health.failures(Failure::Connection), 0);
}
#[cfg(unix)]
#[test]
fn a_successful_accept_ends_the_stall_and_the_backoff() {
let health = Health::new("test");
for _ in 0..5 {
let _ = health.failed(&io(libc::EMFILE));
}
assert!(health.stalled().is_some());
health.accepted();
assert_eq!(health.stalled(), None);
let delay = health.failed(&io(libc::EMFILE)).expect("exhaustion must pace");
assert!(delay <= RETRY_MIN);
assert_eq!(health.failures(Failure::Exhausted), 6);
}
}