notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit e92d56201c66b2acc64ab91d3d5209de10f38982
parent 70f393af645893dab14475dd623939d063f7f572
Author: kernelkind <kernelkind@gmail.com>
Date:   Sat, 21 Feb 2026 14:01:47 -0500

feat(outbox): improve reconnect backoff handling

Reset reconnect counters on websocket open, add attempt-tracked exponential backoff with relay-scoped jitter, and extend relay state for reconnect attempts.

Signed-off-by: kernelkind <kernelkind@gmail.com>

Diffstat:
Mcrates/enostr/src/relay/coordinator.rs | 2++
Mcrates/enostr/src/relay/outbox/mod.rs | 53++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/enostr/src/relay/websocket.rs | 3+++
3 files changed, 53 insertions(+), 5 deletions(-)

diff --git a/crates/enostr/src/relay/coordinator.rs b/crates/enostr/src/relay/coordinator.rs @@ -265,6 +265,8 @@ impl CoordinationData { let msg = match &event { WsEvent::Opened => { websocket.conn.set_status(RelayStatus::Connected); + websocket.reconnect_attempt = 0; + websocket.retry_connect_after = WebsocketRelay::initial_reconnect_duration(); handle_relay_open( websocket, &mut self.broadcast_cache, diff --git a/crates/enostr/src/relay/outbox/mod.rs b/crates/enostr/src/relay/outbox/mod.rs @@ -1,7 +1,8 @@ use hashbrown::{hash_map::RawEntryMut, HashMap, HashSet}; use nostrdb::{Filter, Note}; use std::{ - collections::BTreeMap, + collections::{hash_map::DefaultHasher, BTreeMap}, + hash::{Hash, Hasher}, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; @@ -23,6 +24,43 @@ pub use handler::OutboxSessionHandler; pub use session::OutboxSession; const KEEPALIVE_PING_RATE: Duration = Duration::from_secs(45); +const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30 * 60); // 30 minutes + +/// Computes the deterministic base delay for a given attempt number. +/// Formula: `5s * 2^attempt`, capped at [`MAX_RECONNECT_DELAY`]. +fn base_reconnect_delay(attempt: u32) -> Duration { + let secs = 5u64.checked_shl(attempt).unwrap_or(u64::MAX); + Duration::from_secs(secs).min(MAX_RECONNECT_DELAY) +} + +fn reconnect_jitter_seed(relay_url: &nostr::RelayUrl, attempt: u32) -> u64 { + let now_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + let mut hasher = DefaultHasher::new(); + relay_url.hash(&mut hasher); + attempt.hash(&mut hasher); + now_nanos.hash(&mut hasher); + hasher.finish() +} + +/// Returns the reconnect delay for the given attempt count. +/// +/// Uses the exponential base delay as the primary component and adds up to 25% +/// additive jitter (via relay/time mixed seed) to spread out simultaneous +/// reconnects without undermining the exponential delay itself. +fn next_reconnect_duration(attempt: u32, jitter_seed: u64) -> Duration { + let base = base_reconnect_delay(attempt); + let jitter_ceiling = base / 4; + let jitter = if jitter_ceiling.is_zero() { + Duration::ZERO + } else { + let jitter_ceiling_nanos = jitter_ceiling.as_nanos() as u64; + Duration::from_nanos(jitter_seed % jitter_ceiling_nanos) + }; + (base + jitter).min(MAX_RECONNECT_DELAY) +} /// OutboxPool owns the active relay coordinators and applies staged subscription /// mutations to them each frame. @@ -270,11 +308,15 @@ impl OutboxPool { websocket.last_connect_attempt + websocket.retry_connect_after; if now > reconnect_at { websocket.last_connect_attempt = now; - let next_duration = Duration::from_millis(3000); + websocket.reconnect_attempt = websocket.reconnect_attempt.saturating_add(1); + let jitter_seed = + reconnect_jitter_seed(&websocket.conn.url, websocket.reconnect_attempt); + let next_duration = + next_reconnect_duration(websocket.reconnect_attempt, jitter_seed); tracing::debug!( - "bumping reconnect duration from {:?} to {:?} and retrying connect", - websocket.retry_connect_after, - next_duration + "reconnect attempt {}, backing off for {:?}", + websocket.reconnect_attempt, + next_duration, ); websocket.retry_connect_after = next_duration; if let Err(err) = websocket.conn.connect(wakeup.clone()) { @@ -283,6 +325,7 @@ impl OutboxPool { } } RelayStatus::Connected => { + websocket.reconnect_attempt = 0; websocket.retry_connect_after = WebsocketRelay::initial_reconnect_duration(); let should_ping = now - websocket.last_ping > KEEPALIVE_PING_RATE; diff --git a/crates/enostr/src/relay/websocket.rs b/crates/enostr/src/relay/websocket.rs @@ -120,6 +120,8 @@ pub struct WebsocketRelay { pub last_ping: Instant, pub last_connect_attempt: Instant, pub retry_connect_after: Duration, + /// Number of consecutive failed reconnect attempts. Reset to 0 on successful connection. + pub reconnect_attempt: u32, } impl WebsocketRelay { @@ -129,6 +131,7 @@ impl WebsocketRelay { last_ping: Instant::now(), last_connect_attempt: Instant::now(), retry_connect_after: Self::initial_reconnect_duration(), + reconnect_attempt: 0, } }