use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Endpoint {
pub kind: String,
pub url: String,
#[serde(default)]
pub priority: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health_hint_unix: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health: Option<EndpointHealth>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EndpointHealth {
pub status: HealthStatus,
#[serde(default)]
pub consecutive_fails: u8,
#[serde(default)]
pub consecutive_successes: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_probe_unix: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HealthStatus {
Healthy,
Unhealthy,
}
impl EndpointHealth {
pub const FAIL_THRESHOLD: u8 = 3;
pub const SUCCESS_THRESHOLD: u8 = 2;
pub fn fresh_healthy(now_unix: i64) -> Self {
Self {
status: HealthStatus::Healthy,
consecutive_fails: 0,
consecutive_successes: 0,
last_probe_unix: Some(now_unix),
}
}
pub fn on_probe_success(mut self, now_unix: i64) -> Self {
self.last_probe_unix = Some(now_unix);
self.consecutive_fails = 0;
self.consecutive_successes = self.consecutive_successes.saturating_add(1);
if matches!(self.status, HealthStatus::Unhealthy)
&& self.consecutive_successes >= Self::SUCCESS_THRESHOLD
{
self.status = HealthStatus::Healthy;
self.consecutive_successes = 0;
}
if matches!(self.status, HealthStatus::Healthy)
&& self.consecutive_successes > Self::SUCCESS_THRESHOLD
{
self.consecutive_successes = Self::SUCCESS_THRESHOLD;
}
self
}
pub fn on_probe_failure(mut self, now_unix: i64) -> Self {
self.last_probe_unix = Some(now_unix);
self.consecutive_successes = 0;
self.consecutive_fails = self.consecutive_fails.saturating_add(1);
if matches!(self.status, HealthStatus::Healthy)
&& self.consecutive_fails >= Self::FAIL_THRESHOLD
{
self.status = HealthStatus::Unhealthy;
self.consecutive_fails = 0;
}
if matches!(self.status, HealthStatus::Unhealthy)
&& self.consecutive_fails > Self::FAIL_THRESHOLD
{
self.consecutive_fails = Self::FAIL_THRESHOLD;
}
self
}
pub fn is_healthy(&self) -> bool {
matches!(self.status, HealthStatus::Healthy)
}
}
impl Endpoint {
pub const KIND_CLOUDFLARE_QUICK: &'static str = "cloudflare_quick";
pub const KIND_CLOUDFLARE_NAMED: &'static str = "cloudflare_named";
pub const KIND_IROH: &'static str = "iroh";
pub const KIND_TAILSCALE_FUNNEL: &'static str = "tailscale_funnel";
pub const KIND_FRP: &'static str = "frp";
pub const KNOWN_KINDS: &'static [&'static str] = &[
Self::KIND_CLOUDFLARE_QUICK,
Self::KIND_CLOUDFLARE_NAMED,
Self::KIND_IROH,
Self::KIND_TAILSCALE_FUNNEL,
Self::KIND_FRP,
];
pub fn is_known_kind(&self) -> bool {
Self::KNOWN_KINDS.contains(&self.kind.as_str())
}
pub fn cloudflare_quick(url: impl Into<String>) -> Self {
Self {
kind: Self::KIND_CLOUDFLARE_QUICK.into(),
url: url.into(),
priority: 0,
health_hint_unix: None,
health: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cloudflare_quick_builder() {
let e = Endpoint::cloudflare_quick("https://foo.trycloudflare.com");
assert_eq!(e.kind, "cloudflare_quick");
assert_eq!(e.url, "https://foo.trycloudflare.com");
assert_eq!(e.priority, 0);
assert!(e.is_known_kind());
}
#[test]
fn unknown_kind_preserved_and_flagged() {
let e = Endpoint {
kind: "future_transport_v9".into(),
url: "future:alien@mars:443".into(),
priority: 5,
health_hint_unix: None,
health: None,
};
assert!(!e.is_known_kind());
}
#[test]
fn serde_roundtrip_minimal() {
let original = Endpoint::cloudflare_quick("https://x.trycloudflare.com");
let json = serde_json::to_string(&original).unwrap();
assert!(json.contains(r#""kind":"cloudflare_quick""#));
assert!(json.contains(r#""url":"https://x.trycloudflare.com""#));
assert!(!json.contains("health_hint_unix"));
let back: Endpoint = serde_json::from_str(&json).unwrap();
assert_eq!(back, original);
}
#[test]
fn serde_roundtrip_with_health_hint() {
let original = Endpoint {
kind: Endpoint::KIND_IROH.into(),
url: "iroh:abc123@relay.aex.dev:443".into(),
priority: 1,
health_hint_unix: Some(1_700_000_000),
health: None,
};
let json = serde_json::to_string(&original).unwrap();
assert!(json.contains(r#""health_hint_unix":1700000000"#));
let back: Endpoint = serde_json::from_str(&json).unwrap();
assert_eq!(back, original);
}
#[test]
fn endpoint_health_fresh_is_healthy() {
let h = EndpointHealth::fresh_healthy(1_700_000_000);
assert_eq!(h.status, HealthStatus::Healthy);
assert_eq!(h.consecutive_fails, 0);
assert_eq!(h.consecutive_successes, 0);
assert_eq!(h.last_probe_unix, Some(1_700_000_000));
}
#[test]
fn health_flips_to_unhealthy_after_three_fails() {
let mut h = EndpointHealth::fresh_healthy(0);
h = h.on_probe_failure(1);
assert_eq!(h.status, HealthStatus::Healthy, "1 fail: still healthy");
h = h.on_probe_failure(2);
assert_eq!(h.status, HealthStatus::Healthy, "2 fails: still healthy");
h = h.on_probe_failure(3);
assert_eq!(
h.status,
HealthStatus::Unhealthy,
"3rd fail must flip to unhealthy"
);
assert_eq!(h.last_probe_unix, Some(3));
}
#[test]
fn health_stays_unhealthy_after_one_success() {
let mut h = EndpointHealth {
status: HealthStatus::Unhealthy,
consecutive_fails: 0,
consecutive_successes: 0,
last_probe_unix: Some(0),
};
h = h.on_probe_success(1);
assert_eq!(
h.status,
HealthStatus::Unhealthy,
"1 success is not enough to heal"
);
assert_eq!(h.consecutive_successes, 1);
}
#[test]
fn health_heals_after_two_successes() {
let mut h = EndpointHealth {
status: HealthStatus::Unhealthy,
consecutive_fails: 2,
consecutive_successes: 0,
last_probe_unix: Some(0),
};
h = h.on_probe_success(1);
h = h.on_probe_success(2);
assert_eq!(h.status, HealthStatus::Healthy);
assert_eq!(
h.consecutive_fails, 0,
"healing must reset the fail counter"
);
assert_eq!(
h.consecutive_successes, 0,
"counter resets after a flip so the state machine is fresh again"
);
}
#[test]
fn success_resets_fail_counter_without_flipping() {
let mut h = EndpointHealth::fresh_healthy(0);
h = h.on_probe_failure(1);
h = h.on_probe_failure(2);
assert_eq!(h.consecutive_fails, 2);
h = h.on_probe_success(3);
assert_eq!(h.consecutive_fails, 0);
assert_eq!(h.status, HealthStatus::Healthy);
}
#[test]
fn failure_resets_success_counter() {
let mut h = EndpointHealth {
status: HealthStatus::Unhealthy,
consecutive_fails: 0,
consecutive_successes: 1,
last_probe_unix: Some(0),
};
h = h.on_probe_failure(1);
assert_eq!(h.consecutive_successes, 0);
assert_eq!(h.status, HealthStatus::Unhealthy);
}
#[test]
fn counters_are_saturated_not_wrapping() {
let mut h = EndpointHealth::fresh_healthy(0);
for i in 1..=10 {
h = h.on_probe_success(i);
}
assert!(h.consecutive_successes <= EndpointHealth::SUCCESS_THRESHOLD);
assert_eq!(h.status, HealthStatus::Healthy);
}
#[test]
fn health_round_trips_through_json() {
let h = EndpointHealth {
status: HealthStatus::Unhealthy,
consecutive_fails: 3,
consecutive_successes: 0,
last_probe_unix: Some(1_700_000_000),
};
let json = serde_json::to_string(&h).unwrap();
assert!(json.contains(r#""status":"unhealthy""#));
let back: EndpointHealth = serde_json::from_str(&json).unwrap();
assert_eq!(back, h);
}
#[test]
fn deserialize_preserves_unknown_kind() {
let json = r#"{"kind":"unknown_transport","url":"x://y","priority":9}"#;
let e: Endpoint = serde_json::from_str(json).unwrap();
assert_eq!(e.kind, "unknown_transport");
assert!(!e.is_known_kind());
}
#[test]
fn priority_defaults_to_zero_when_missing() {
let json = r#"{"kind":"cloudflare_quick","url":"https://x.trycloudflare.com"}"#;
let e: Endpoint = serde_json::from_str(json).unwrap();
assert_eq!(e.priority, 0);
assert_eq!(e.health_hint_unix, None);
}
#[test]
fn known_kinds_covers_sprint_2_transports() {
for k in [
Endpoint::KIND_CLOUDFLARE_QUICK,
Endpoint::KIND_CLOUDFLARE_NAMED,
Endpoint::KIND_IROH,
Endpoint::KIND_TAILSCALE_FUNNEL,
Endpoint::KIND_FRP,
] {
assert!(Endpoint::KNOWN_KINDS.contains(&k), "kind {k} missing");
}
}
}