diff --git a/AGENTS.md b/AGENTS.md
index be1f1cc4a3..c94ba3881e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -462,34 +462,41 @@ new arbitrary text-size literal — px **or** rem/em. Genuinely decorative glyph
(e.g. the `text-[6rem]` avatar emoji) are allowlisted by `path:line` in that
script.
-### Workspace Switching
+### Community Switching
-The desktop app supports multiple workspaces (each backed by a different relay).
-Switching workspaces does **not** reload the page — it uses React key-based
-remounting. ` ` in `App.tsx` forces the entire
-workspace-scoped subtree to unmount and remount with fresh state.
+The desktop app supports multiple communities (each backed by a different relay).
+Switching communities does **not** reload the page — it uses React key-based
+remounting. ` ` in `App.tsx` forces the entire
+community-scoped subtree to unmount and remount with fresh state.
**Module-level singletons must be explicitly reset.** React remounting only
clears React state (useState, useRef, context). Module-level variables (Maps,
-class instances, cached promises) survive across remounts. Every workspace-scoped
-singleton needs a reset function wired into `resetWorkspaceState()` in
-`desktop/src/features/workspaces/useWorkspaceInit.ts`.
+class instances, cached promises) survive across remounts. Every community-scoped
+singleton needs a reset function wired into `resetCommunityState()` in
+`desktop/src/features/communities/useCommunityInit.ts`.
-Current singletons that are reset on workspace switch:
+Current singletons that are reset on community switch:
- `relayClient.disconnect()` — WebSocket teardown + promise rejection
+- `resetRateLimitGate()` — clears any active rate-limit window from the old relay
+- `clearAllDrafts()` — message draft cache
+- `resetAgentObserverStore()` — agent observer relay store
+- `resetActiveAgentTurnsStore()` — active agent turn timers
+- `resetAgentWorkingSignal()` — agent working indicator signal
+- `resetSidebarRelayConnectionCardState()` — sidebar relay card dismiss state
- `resetMediaCaches()` — proxy port and relay origin caches
+- `resetVideoPlayerState()` — video player singleton
+- `resetRenderScopedReactionHydration()` — reaction hydration cache
- `clearSearchHitEventCache()` — search result event cache
-- `clearAllDrafts()` — message draft cache
+- `clearMarkdownNodeCache()` — markdown parse-node cache
**If you add a new module-level cache, Map, or class instance that holds
-workspace-scoped data, you must add its reset to `resetWorkspaceState()`.**
-Failure to do so causes data from the old workspace to leak into the new one.
+community-scoped data, you must add its reset to `resetCommunityState()`.**
+Failure to do so causes data from the old community to leak into the new one.
Key files:
-- `desktop/src/app/App.tsx` — workspace key, init gate, remount boundary
-- `desktop/src/features/workspaces/useWorkspaceInit.ts` — `resetWorkspaceState()`, applies config to Tauri backend
-- `desktop/src/features/workspaces/useWorkspaces.tsx` — `WorkspacesProvider` context (shared state for App + AppShell)
-- `desktop/src/main.tsx` — provider hierarchy (`QueryClientProvider` > `WorkspacesProvider` > `App`)
+- `desktop/src/app/App.tsx` — community key, init gate, remount boundary
+- `desktop/src/features/communities/useCommunityInit.ts` — `resetCommunityState()`, applies config to Tauri backend
+- `desktop/src/main.tsx` — provider hierarchy (`QueryClientProvider` > `App`)
---
diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index 1963acc3bc..845f991623 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -316,7 +316,16 @@ const overrides = new Map([
// migration/materialize.rs; ratchet held at 1110.
["src-tauri/src/migration_tests.rs", 1110],
["src-tauri/src/nostr_convert.rs", 1126],
- ["src/shared/api/relayClientSession.ts", 1022],
+ // degraded-network resilience: relay.rs grew past 1000 with the addition of
+ // relay_error_message hint-capping (oversized-hint test via loopback TCP) and
+ // the relay_admission freshness-verification test. The loopback mock was
+ // hardened (std::net + request-read-before-write) adding ~10 lines.
+ // Queued to split test helpers to relay/tests.rs.
+ ["src-tauri/src/relay.rs", 1047],
+ // degraded-network resilience: visibleChannelId field + getter/setter, NOTICE
+ // handler for relay back-pressure, and rate-limit gate imports add ~74 lines
+ // of load-bearing degraded-network recovery code. Queued to split.
+ ["src/shared/api/relayClientSession.ts", 1096],
// Boot-time event sync (persona/team/agent event reconcile) was split out
// to event_sync.rs, ratcheting this limit 1575 → 1310. Remaining content is
// the pre-identity data migrations; still queued to split further.
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 65be49e60b..8a9feb562a 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -123,3 +123,6 @@ tempfile = "3"
strip-ansi-escapes = "0.2"
[dev-dependencies]
+# `test-util` enables tokio's paused-clock (`start_paused`) so the relay
+# admission gate tests can assert exact wait durations without real sleeps.
+tokio = { version = "1", features = ["test-util"] }
diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs
index 9e2b8b3921..ac5c0eace6 100644
--- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs
+++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs
@@ -640,6 +640,10 @@ async fn submit_engram_event(
use crate::relay::build_nip98_auth_header_for_keys;
use reqwest::Method;
+ // Wait before signing: the relay enforces NIP-98 freshness (±60s) and the
+ // gate may hold for up to MAX_HINT_SECONDS (300s). Building auth before the
+ // wait produces a stale `created_at` that the relay will reject.
+ crate::relay_admission::wait_for_rate_limit().await;
let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, url, event_json)?;
let mut request = state
.http_client
diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs
index dc376ca064..0476be79a9 100644
--- a/desktop/src-tauri/src/commands/team_snapshot.rs
+++ b/desktop/src-tauri/src/commands/team_snapshot.rs
@@ -901,6 +901,10 @@ async fn submit_engram_event(
use crate::relay::build_nip98_auth_header_for_keys;
use reqwest::Method;
+ // Wait before signing: the relay enforces NIP-98 freshness (±60s) and the
+ // gate may hold for up to MAX_HINT_SECONDS (300s). Building auth before the
+ // wait produces a stale `created_at` that the relay will reject.
+ crate::relay_admission::wait_for_rate_limit().await;
let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, url, event_json)?;
let mut request = state
.http_client
diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs
index cb17ad608e..561e901998 100644
--- a/desktop/src-tauri/src/commands/workspace.rs
+++ b/desktop/src-tauri/src/commands/workspace.rs
@@ -143,6 +143,9 @@ pub async fn apply_workspace(
let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?;
*override_guard = Some(relay_url);
}
+ // Reset the Rust-side admission gate when switching workspace/community,
+ // matching `resetRateLimitGate()` on the TS side (useCommunityInit.ts:38).
+ crate::relay_admission::reset_gate_for_workspace_change();
if let Some(keys) = parsed_keys {
let mut keys_guard = state.keys.lock().map_err(|e| e.to_string())?;
diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs
index bc9a1abd41..ceccedd8b6 100644
--- a/desktop/src-tauri/src/huddle/pipeline.rs
+++ b/desktop/src-tauri/src/huddle/pipeline.rs
@@ -305,6 +305,11 @@ pub(crate) fn spawn_transcription_task(
continue;
}
};
+ // Wait before signing: the relay enforces NIP-98 freshness (±60s)
+ // and the gate may hold for up to MAX_HINT_SECONDS (300s). Sign
+ // the kind event and build NIP-98 auth after the wait so both
+ // timestamps are fresh — single clean order: wait → sign → auth → send.
+ crate::relay_admission::wait_for_rate_limit().await;
let event = match builder.sign_with_keys(&keys) {
Ok(e) => e,
Err(e) => {
@@ -327,21 +332,23 @@ pub(crate) fn spawn_transcription_task(
}
};
- let response = http_client
- .post(&url)
- .header("Authorization", auth_header)
- .header("Content-Type", "application/json")
- .body(body_bytes)
- .send()
- .await;
+ let response = {
+ http_client
+ .post(&url)
+ .header("Authorization", auth_header)
+ .header("Content-Type", "application/json")
+ .body(body_bytes)
+ .send()
+ .await
+ };
match response {
Ok(resp) if resp.status().is_success() => {}
Ok(resp) => {
- eprintln!(
- "buzz-desktop: STT kind:9 post failed: HTTP {}",
- resp.status()
- );
+ // Route through relay_error_message so a 429 arms the
+ // admission gate for subsequent relay sends.
+ let msg = crate::relay::relay_error_message(resp).await;
+ eprintln!("buzz-desktop: STT kind:9 post failed: {msg}");
}
Err(e) => {
eprintln!("buzz-desktop: STT kind:9 post failed: {e}");
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 9d6424aca2..c22de0205f 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -23,6 +23,7 @@ pub mod nostr_convert;
mod prevent_sleep;
mod ptt_shortcut;
mod relay;
+mod relay_admission;
mod reset;
mod secret_store;
mod shutdown;
diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs
index 0c253ed0ae..99ad702c1c 100644
--- a/desktop/src-tauri/src/relay.rs
+++ b/desktop/src-tauri/src/relay.rs
@@ -231,6 +231,17 @@ pub(crate) async fn parse_json_response(
.map_err(|_| MALFORMED_RESPONSE_MESSAGE.to_string())
}
+/// Extract the `retry in Ns` hint from a rate-limit error string.
+///
+/// Matches the canonical format emitted by the relay in both HTTP 429 bodies
+/// and CLOSED/NOTICE messages: `quota exceeded; retry in 4s`.
+fn extract_retry_in_hint(body: &str) -> Option {
+ let re_match = body.find("retry in ")?;
+ let after = &body[re_match + "retry in ".len()..];
+ let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
+ digits.parse::().ok()
+}
+
pub async fn relay_error_message(response: reqwest::Response) -> String {
let status = response.status();
@@ -250,6 +261,27 @@ pub async fn relay_error_message(response: reqwest::Response) -> String {
// Real relay error: extract the structured message field if available.
let body = response.text().await.unwrap_or_default();
+ // 429 Too Many Requests → typed `relay rate-limited:` prefix so the TS
+ // client can activate the rate-limit gate without confusing it with a
+ // connectivity failure (`relay unreachable:`). Also arm the Rust-side
+ // admission gate here — the one place every relay HTTP error funnels
+ // through — so the next relay-backed command waits out the quota window
+ // instead of burning it (see `relay_admission`).
+ if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
+ let hint = extract_retry_in_hint(&body);
+ // Clamp the hint to MAX_HINT_SECONDS before arming the Rust gate AND
+ // before embedding it in the returned string. Every consumer (Rust gate
+ // via `activate_rate_limit` and TS gate via `applyTauriRateLimitIfNeeded`)
+ // must see the same capped value — a single policy point prevents the TS
+ // gate from receiving an uncapped hint from an untrusted relay.
+ let capped_hint = hint.map(|s| s.min(crate::relay_admission::MAX_HINT_SECONDS));
+ crate::relay_admission::activate_rate_limit(capped_hint);
+ if let Some(secs) = capped_hint {
+ return format!("relay rate-limited: retry in {secs}s");
+ }
+ return "relay rate-limited: quota exceeded".to_string();
+ }
+
if let Ok(value) = serde_json::from_str::(&body) {
if let Some(message) = value.get("message").and_then(serde_json::Value::as_str) {
return format!("relay returned {status}: {message}");
@@ -286,6 +318,7 @@ pub async fn query_relay_at(
api_base_url: &str,
filters: &[serde_json::Value],
) -> Result, String> {
+ crate::relay_admission::wait_for_rate_limit().await;
let url = format!("{}/query", api_base_url);
let body_bytes =
serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?;
@@ -382,6 +415,7 @@ pub async fn sync_managed_agent_profile(
avatar_url: Option<&str>,
auth_tag: Option<&str>, // NIP-OA auth tag JSON
) -> Result<(), String> {
+ crate::relay_admission::wait_for_rate_limit().await;
// Build a signed kind:0 profile event (with optional NIP-OA auth tag).
let event = build_profile_event(agent_keys, display_name, avatar_url, auth_tag)?;
let event_json = event.as_json();
@@ -482,6 +516,7 @@ pub async fn submit_event(
builder: nostr::EventBuilder,
state: &AppState,
) -> Result {
+ crate::relay_admission::wait_for_rate_limit().await;
// All synchronous work (signing) must complete before any .await
// so the MutexGuard is dropped and the future remains Send.
let url = format!("{}/events", relay_api_base_url_with_override(state));
@@ -529,6 +564,7 @@ pub async fn submit_signed_event(
event: &nostr::Event,
state: &AppState,
) -> Result {
+ crate::relay_admission::wait_for_rate_limit().await;
let url = format!("{}/events", relay_api_base_url_with_override(state));
let body_bytes = event.as_json().into_bytes();
let auth_header = {
@@ -586,6 +622,7 @@ pub async fn submit_signed_event_with_keys(
if event.pubkey != keys.public_key() {
return Err("signed event does not match the publishing identity".to_string());
}
+ crate::relay_admission::wait_for_rate_limit().await;
let url = format!("{}/events", relay_api_base_url_with_override(state));
let body_bytes = event.as_json().into_bytes();
let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?;
@@ -624,10 +661,106 @@ pub async fn submit_signed_event_with_keys(
mod tests {
use super::{
build_profile_event, classify_intercepted_response, effective_agent_relay_url,
- parse_command_response, relay_http_base_url, MALFORMED_RESPONSE_MESSAGE,
+ extract_retry_in_hint, parse_command_response, relay_http_base_url,
+ MALFORMED_RESPONSE_MESSAGE,
};
use serde::Deserialize;
+ // ── extract_retry_in_hint ────────────────────────────────────────────────
+
+ #[test]
+ fn extracts_hint_from_429_body() {
+ assert_eq!(
+ extract_retry_in_hint(r#"{"error":"rate-limited: quota exceeded; retry in 4s"}"#),
+ Some(4)
+ );
+ }
+
+ #[test]
+ fn extracts_hint_when_no_json_wrapper() {
+ assert_eq!(extract_retry_in_hint("retry in 30s"), Some(30));
+ }
+
+ #[test]
+ fn returns_none_when_no_hint_present() {
+ assert_eq!(
+ extract_retry_in_hint(r#"{"error":"rate-limited: quota exceeded"}"#),
+ None
+ );
+ assert_eq!(extract_retry_in_hint(""), None);
+ }
+
+ #[test]
+ fn overlong_digit_string_returns_none() {
+ // A digit sequence that exceeds u64::MAX cannot be parsed; the function
+ // must return None (→ caller uses the default) rather than panicking.
+ assert_eq!(
+ extract_retry_in_hint("retry in 99999999999999999999999s"),
+ None
+ );
+ }
+
+ // ── relay_error_message: hint capping ────────────────────────────────────
+ //
+ // Verify that an oversized relay hint is capped in the returned message
+ // string, not just inside `activate_rate_limit()`. This guarantees every
+ // consumer — including the TS gate via `applyTauriRateLimitIfNeeded` —
+ // receives the capped value rather than the raw untrusted relay value.
+
+ #[tokio::test]
+ async fn oversized_hint_is_capped_in_relay_error_message_string() {
+ use crate::relay_admission::MAX_HINT_SECONDS;
+ use std::io::{Read as _, Write as _};
+
+ // Use a std::net listener on a std::thread — the same pattern as the
+ // relay_admission loopback tests. This avoids two races that cause CI
+ // failures with tokio::net + into_std():
+ // 1. No request read: the client is still sending when the response
+ // arrives → hyper `UnexpectedMessage`/`Canceled` under load.
+ // 2. into_std() leaves the socket in nonblocking mode → write_all
+ // may return WouldBlock and silently drop the response.
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+ let addr = listener.local_addr().unwrap();
+
+ // Serve a 429 with a hint far exceeding MAX_HINT_SECONDS (300).
+ let oversized = 1_000_000u64;
+ let body = format!(r#"{{"error":"rate-limited: quota exceeded; retry in {oversized}s"}}"#);
+ let body_len = body.len();
+ std::thread::spawn(move || {
+ if let Ok((mut stream, _)) = listener.accept() {
+ // Read the request first so the client finishes sending before
+ // we write the response — mirrors relay_admission.rs pattern.
+ let mut buf = [0u8; 4096];
+ let _ = stream.read(&mut buf);
+ let response = format!(
+ "HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/json\r\nContent-Length: {body_len}\r\nConnection: close\r\n\r\n{body}"
+ );
+ let _ = stream.write_all(response.as_bytes());
+ let _ = stream.flush();
+ }
+ });
+
+ let client = reqwest::Client::new();
+ let response = client
+ .get(format!("http://{addr}/"))
+ .send()
+ .await
+ .expect("request must succeed");
+
+ let msg = super::relay_error_message(response).await;
+
+ // The message must embed the CAPPED hint, not the raw 1 000 000.
+ assert_eq!(
+ msg,
+ format!("relay rate-limited: retry in {MAX_HINT_SECONDS}s"),
+ "relay_error_message must embed the capped hint, not the raw untrusted value"
+ );
+ assert!(
+ !msg.contains(&oversized.to_string()),
+ "raw oversized hint must not appear in the message string"
+ );
+ }
+
// ── effective_agent_relay_url: per-agent override precedence ─────────────
#[test]
diff --git a/desktop/src-tauri/src/relay_admission.rs b/desktop/src-tauri/src/relay_admission.rs
new file mode 100644
index 0000000000..15222f8590
--- /dev/null
+++ b/desktop/src-tauri/src/relay_admission.rs
@@ -0,0 +1,504 @@
+//! Admission gate for relay HTTP bridge requests.
+//!
+//! When the relay answers 429, every relay-backed HTTP request must hold new
+//! sends until the quota window clears — matching the TS-side gate in
+//! `relayRateLimitGate.ts` that already governs WebSocket operations.
+//!
+//! **Coverage:** all entry points in `relay.rs` (`query_relay_at`,
+//! `submit_event`, `submit_signed_event`, `submit_signed_event_with_keys`,
+//! `sync_managed_agent_profile`) and the three previously-direct senders
+//! (`submit_engram_event` in snapshot import + team_snapshot, huddle STT)
+//! all call `wait_for_rate_limit()` before `.send()`.
+//!
+//! **Media upload/download and `/info`** call `relay_error_message()` on
+//! non-200 responses, so their 429s arm the shared gate as conservative
+//! back-off (any relay overload signal is worth honouring across domains).
+//! They do not call `wait_for_rate_limit()` themselves — their operations
+//! are driven by user-initiated file transfers rather than bridge event flow,
+//! and they have independent retry logic.
+//!
+//! **Community scope:** the gate is reset on every `apply_workspace` call,
+//! mirroring the TS gate's `resetRateLimitGate()` on community switch in
+//! `useCommunityInit.ts`. A 429 from community A cannot stall community B.
+//!
+//! Mirrors the TS gate's semantics: overlapping hints never shrink the window,
+//! and a hint-less 429 arms the same 10-second default.
+
+use std::sync::Mutex;
+use tokio::time::{sleep_until, Duration, Instant};
+
+/// Minimum gate duration when the relay provides no `retry in Ns` hint.
+/// Deliberately equal to `DEFAULT_RATE_LIMIT_SECONDS` in `relayRateLimitGate.ts`
+/// so both halves of the client back off for the same window.
+const DEFAULT_RATE_LIMIT_SECONDS: u64 = 10;
+
+/// Maximum hint the gate will honour from a relay 429 response.
+/// Prevents an untrusted relay from pinning traffic for an unreasonable window
+/// or overflowing `Instant` arithmetic.
+/// Exposed `pub` so `relay.rs` can clamp the hint before embedding it in the
+/// returned error string — ensuring every consumer (Rust gate and TS gate via
+/// `applyTauriRateLimitIfNeeded`) sees the same capped value.
+pub const MAX_HINT_SECONDS: u64 = 300;
+
+static GATE_EXPIRY: Mutex> = Mutex::new(None);
+
+/// Arm (or extend) the admission gate from a relay 429.
+///
+/// `retry_in_seconds` is the parsed `retry in Ns` hint, if the relay provided
+/// one. Hints are capped at `MAX_HINT_SECONDS`; values of zero or `None` use
+/// `DEFAULT_RATE_LIMIT_SECONDS`. The expiry only ever moves forward: a shorter
+/// hint arriving under a longer active window is ignored, so overlapping 429s
+/// never schedule a premature retry.
+pub fn activate_rate_limit(retry_in_seconds: Option) {
+ let secs = match retry_in_seconds {
+ Some(s) if s > 0 => s.min(MAX_HINT_SECONDS),
+ _ => DEFAULT_RATE_LIMIT_SECONDS,
+ };
+ let new_expiry = Instant::now()
+ .checked_add(Duration::from_secs(secs))
+ .unwrap_or_else(|| Instant::now() + Duration::from_secs(DEFAULT_RATE_LIMIT_SECONDS));
+ let mut guard = GATE_EXPIRY.lock().unwrap_or_else(|e| e.into_inner());
+ match *guard {
+ Some(current) if new_expiry <= current => {}
+ _ => *guard = Some(new_expiry),
+ }
+}
+
+/// Wait until the admission gate is clear.
+///
+/// Returns immediately when no gate is active. Loops after sleeping because a
+/// concurrent 429 may extend the expiry while this caller is parked.
+pub async fn wait_for_rate_limit() {
+ loop {
+ let expiry = {
+ let guard = GATE_EXPIRY.lock().unwrap_or_else(|e| e.into_inner());
+ match *guard {
+ Some(expiry) if expiry > Instant::now() => Some(expiry),
+ _ => None,
+ }
+ };
+ match expiry {
+ Some(expiry) => sleep_until(expiry).await,
+ None => return,
+ }
+ }
+}
+
+/// Reset the gate on a workspace/community change.
+///
+/// Called by `apply_workspace` to ensure a 429 from community A does not stall
+/// requests to community B. Mirrors `resetRateLimitGate()` in
+/// `useCommunityInit.ts`.
+pub fn reset_gate_for_workspace_change() {
+ *GATE_EXPIRY.lock().unwrap_or_else(|e| e.into_inner()) = None;
+}
+
+/// Reset the gate. Test-only: production never clears an armed window early
+/// except via `reset_gate_for_workspace_change`.
+#[cfg(test)]
+pub fn reset_rate_limit_gate() {
+ *GATE_EXPIRY.lock().unwrap_or_else(|e| e.into_inner()) = None;
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // The gate is a process-wide static shared by every test in this binary,
+ // so all gate tests serialize on one async lock to keep armed expiries
+ // from bleeding between parallel test threads.
+ pub(crate) static TEST_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
+
+ #[tokio::test(start_paused = true)]
+ async fn wait_returns_immediately_when_gate_is_inactive() {
+ let _serial = TEST_SERIAL.lock().await;
+ reset_rate_limit_gate();
+ let start = Instant::now();
+ wait_for_rate_limit().await;
+ assert_eq!(
+ Instant::now(),
+ start,
+ "inactive gate must not consume any (paused) time"
+ );
+ }
+
+ #[tokio::test(start_paused = true)]
+ async fn hintless_429_arms_the_ten_second_default() {
+ let _serial = TEST_SERIAL.lock().await;
+ reset_rate_limit_gate();
+ activate_rate_limit(None);
+ let start = Instant::now();
+ wait_for_rate_limit().await;
+ assert_eq!(Instant::now() - start, Duration::from_secs(10));
+ reset_rate_limit_gate();
+ }
+
+ #[tokio::test(start_paused = true)]
+ async fn shorter_hint_never_shrinks_an_active_window() {
+ let _serial = TEST_SERIAL.lock().await;
+ reset_rate_limit_gate();
+ activate_rate_limit(Some(8));
+ activate_rate_limit(Some(1));
+ let start = Instant::now();
+ wait_for_rate_limit().await;
+ assert_eq!(
+ Instant::now() - start,
+ Duration::from_secs(8),
+ "the 1s hint must not shorten the active 8s window"
+ );
+ reset_rate_limit_gate();
+ }
+
+ #[tokio::test(start_paused = true)]
+ async fn concurrent_429_extends_the_window_for_parked_waiters() {
+ let _serial = TEST_SERIAL.lock().await;
+ reset_rate_limit_gate();
+ activate_rate_limit(Some(2));
+ let start = Instant::now();
+ let waiter = tokio::spawn(async {
+ wait_for_rate_limit().await;
+ });
+ // Extend while the waiter is parked on the first expiry.
+ tokio::time::sleep(Duration::from_secs(1)).await;
+ activate_rate_limit(Some(4));
+ waiter.await.unwrap();
+ assert_eq!(
+ Instant::now() - start,
+ Duration::from_secs(5),
+ "waiter must respect the extension armed mid-sleep (1s + 4s)"
+ );
+ reset_rate_limit_gate();
+ }
+
+ // ── hint capping and overflow safety ─────────────────────────────────────
+
+ #[tokio::test(start_paused = true)]
+ async fn hint_zero_uses_default() {
+ let _serial = TEST_SERIAL.lock().await;
+ reset_rate_limit_gate();
+ activate_rate_limit(Some(0));
+ let start = Instant::now();
+ wait_for_rate_limit().await;
+ assert_eq!(
+ Instant::now() - start,
+ Duration::from_secs(DEFAULT_RATE_LIMIT_SECONDS),
+ "hint=0 must use the default"
+ );
+ reset_rate_limit_gate();
+ }
+
+ #[tokio::test(start_paused = true)]
+ async fn hint_at_max_is_honoured() {
+ let _serial = TEST_SERIAL.lock().await;
+ reset_rate_limit_gate();
+ activate_rate_limit(Some(MAX_HINT_SECONDS));
+ let start = Instant::now();
+ wait_for_rate_limit().await;
+ assert_eq!(
+ Instant::now() - start,
+ Duration::from_secs(MAX_HINT_SECONDS),
+ "hint at the cap must be honoured in full"
+ );
+ reset_rate_limit_gate();
+ }
+
+ #[tokio::test(start_paused = true)]
+ async fn oversize_hint_is_clamped_to_max() {
+ let _serial = TEST_SERIAL.lock().await;
+ reset_rate_limit_gate();
+ // An oversize hint (including u64::MAX) must clamp rather than panic.
+ activate_rate_limit(Some(u64::MAX));
+ let start = Instant::now();
+ wait_for_rate_limit().await;
+ assert_eq!(
+ Instant::now() - start,
+ Duration::from_secs(MAX_HINT_SECONDS),
+ "u64::MAX hint must clamp to MAX_HINT_SECONDS"
+ );
+ reset_rate_limit_gate();
+ }
+
+ // ── community / workspace boundary ───────────────────────────────────────
+
+ #[tokio::test(start_paused = true)]
+ async fn workspace_change_clears_armed_gate() {
+ let _serial = TEST_SERIAL.lock().await;
+ reset_rate_limit_gate();
+ activate_rate_limit(Some(60));
+ // Switch workspace — gate for community A must not stall community B.
+ reset_gate_for_workspace_change();
+ let start = Instant::now();
+ wait_for_rate_limit().await;
+ assert_eq!(
+ Instant::now(),
+ start,
+ "gate must be clear immediately after workspace change"
+ );
+ }
+
+ #[tokio::test(start_paused = true)]
+ async fn community_a_gate_does_not_block_community_b() {
+ let _serial = TEST_SERIAL.lock().await;
+ reset_rate_limit_gate();
+ // Community A gets a 429 with a 30s window.
+ activate_rate_limit(Some(30));
+ // Community switch.
+ reset_gate_for_workspace_change();
+ // Community B's first request must not wait.
+ let start = Instant::now();
+ wait_for_rate_limit().await;
+ assert_eq!(
+ Instant::now(),
+ start,
+ "community A's armed gate must not delay community B"
+ );
+ }
+
+ /// A 429 on one admission-gated path withholds sends on a different path
+ /// until the hinted window expires.
+ ///
+ /// Arms the gate directly (as `relay_error_message` would on a 429 response),
+ /// then drives a second distinct gated send through the loopback acceptance
+ /// server and asserts it waited out the window before succeeding.
+ #[tokio::test]
+ async fn gate_armed_by_one_path_withholds_another_path() {
+ use std::io::{Read, Write};
+
+ let _serial = TEST_SERIAL.lock().await;
+ reset_rate_limit_gate();
+
+ // The loopback server answers every request with 200 [].
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+ let addr = listener.local_addr().unwrap();
+ let server = std::thread::spawn(move || loop {
+ let Ok((mut stream, _)) = listener.accept() else {
+ break;
+ };
+ let mut buf = [0u8; 4096];
+ let _ = stream.read(&mut buf);
+ let _ = stream.write_all(
+ b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]",
+ );
+ let _ = stream.flush();
+ });
+
+ // Capture t0 before arming so elapsed is measured from before the gate
+ // expiry is set (expiry = now + 1s), guaranteeing elapsed ≥ 1s.
+ let t0 = std::time::Instant::now();
+
+ // Arm the gate for 1s — simulates what relay_error_message does on any
+ // gated path that receives a 429, e.g. submit_engram_event.
+ activate_rate_limit(Some(1));
+
+ let state = crate::app_state::build_app_state();
+ *state.relay_url_override.lock().unwrap() = Some(format!("http://{addr}"));
+ let filters = [serde_json::json!({ "kinds": [1], "limit": 1 })];
+
+ // query_relay is a different gated path — it must wait out the 1s window
+ // even though it was not the source of the 429.
+ let events = crate::relay::query_relay(&state, &filters)
+ .await
+ .expect("query must succeed after admission wait");
+ let elapsed = t0.elapsed();
+
+ assert!(
+ events.is_empty(),
+ "server returns empty; unexpected events: {events:?}"
+ );
+ assert!(
+ elapsed >= Duration::from_secs(1),
+ "cross-path send ran {}ms — it must wait out the 1s window armed by another path",
+ elapsed.as_millis()
+ );
+
+ drop(server);
+ reset_rate_limit_gate();
+ }
+
+ /// A waiter parked on community A's gate must NOT wake early when the gate
+ /// is reset by a workspace change — it sleeps until the original expiry,
+ /// then rechecks, finds the gate clear, and proceeds.
+ ///
+ /// This documents the contract: `sleep_until` is already scheduled against
+ /// A's expiry; the reset clears `GATE_EXPIRY` but cannot cancel an in-flight
+ /// sleep. The recheck loop in `wait_for_rate_limit` then sees `None` and
+ /// returns. Net effect: the waiter observes at most one full window, which
+ /// is the same bound as if the workspace had not changed.
+ #[tokio::test(start_paused = true)]
+ async fn parked_waiter_does_not_wake_early_after_workspace_reset() {
+ let _serial = TEST_SERIAL.lock().await;
+ reset_rate_limit_gate();
+
+ // Arm a 5s gate for community A.
+ activate_rate_limit(Some(5));
+
+ let start = Instant::now();
+
+ // Spawn a waiter that parks on the gate.
+ let waiter = tokio::spawn(async {
+ wait_for_rate_limit().await;
+ Instant::now()
+ });
+
+ // After 2s (well before the 5s expiry), simulate a workspace change
+ // that resets the gate.
+ tokio::time::sleep(Duration::from_secs(2)).await;
+ reset_gate_for_workspace_change();
+
+ let woke_at = waiter.await.unwrap();
+ let elapsed = woke_at - start;
+
+ // The waiter must have waited at least until A's original expiry (5s).
+ // It cannot be woken early by the reset — only the recheck loop exit
+ // can terminate it, and the recheck fires after sleep_until(expiry).
+ assert!(
+ elapsed >= Duration::from_secs(5),
+ "waiter woke at {}ms — must not wake before A's 5s expiry even after reset",
+ elapsed.as_millis()
+ );
+
+ reset_rate_limit_gate();
+ }
+
+ /// Wait-then-sign ensures NIP-98 auth is fresh after an admission wait.
+ ///
+ /// The relay enforces NIP-98 freshness within ±60s (`TIMESTAMP_TOLERANCE_SECS`
+ /// in `buzz-auth`), while the gate honours hints up to `MAX_HINT_SECONDS`
+ /// (300s). Signing BEFORE the wait produces a stale `created_at` that would
+ /// be rejected on any active window >60s.
+ ///
+ /// This test arms a 1s gate, records the wall-clock time immediately AFTER
+ /// the wait returns, then builds the NIP-98 header and asserts its
+ /// `created_at` is ≥ the wake time — proving the sign happened post-wait.
+ #[tokio::test]
+ async fn nip98_auth_created_at_is_fresh_after_admission_wait() {
+ use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
+ use serde::Deserialize;
+
+ let _serial = TEST_SERIAL.lock().await;
+ reset_rate_limit_gate();
+
+ // Arm the gate for 1s (real time — NIP-98 uses SystemTime, not Tokio clock).
+ activate_rate_limit(Some(1));
+
+ // Wait out the gate — this is the critical ordering: wait THEN sign.
+ wait_for_rate_limit().await;
+
+ // Capture the wake time as a Unix timestamp AFTER the wait returns.
+ let wake_unix: u64 = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs();
+
+ // Build NIP-98 auth header (contains a signed event whose `created_at`
+ // is set at sign time — i.e., now, after the wait).
+ let keys = nostr::Keys::generate();
+ let header = crate::relay::build_nip98_auth_header_for_keys(
+ &keys,
+ &reqwest::Method::POST,
+ "https://relay.example.com/events",
+ b"{}",
+ )
+ .expect("header build must succeed");
+
+ // Decode the base64-encoded Nostr event from "Nostr ".
+ let b64 = header
+ .strip_prefix("Nostr ")
+ .expect("header must start with 'Nostr '");
+ let json_bytes = BASE64.decode(b64).expect("valid base64");
+
+ #[derive(Deserialize)]
+ struct EventShell {
+ created_at: u64,
+ }
+ let shell: EventShell = serde_json::from_slice(&json_bytes).expect("valid event JSON");
+
+ assert!(
+ shell.created_at >= wake_unix,
+ "NIP-98 created_at ({}) must be >= wake time ({}); \
+ signing before the wait produces stale auth",
+ shell.created_at,
+ wake_unix
+ );
+
+ reset_rate_limit_gate();
+ }
+
+ /// Acceptance: a 429 from one relay-backed command withholds the next
+ /// relay-backed command until the hinted window expires, then it resumes.
+ ///
+ /// Drives the production `query_relay` path end-to-end against a loopback
+ /// HTTP server — NIP-98 signing, `relay_error_message` classification and
+ /// gate arming, and the admission wait all execute for real. Real time is
+ /// required (the request crosses actual TCP), so the hint is kept at 1s.
+ #[tokio::test]
+ async fn http_429_withholds_next_relay_command_until_expiry_then_resumes() {
+ use std::io::{Read, Write};
+
+ let _serial = TEST_SERIAL.lock().await;
+ reset_rate_limit_gate();
+
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+ let addr = listener.local_addr().unwrap();
+
+ // First request → 429 with a 1s retry hint; every later request → 200 [].
+ let server = std::thread::spawn(move || {
+ let responses = [
+ "HTTP/1.1 429 Too Many Requests\r\n\
+ Content-Type: application/json\r\n\
+ Content-Length: 53\r\n\
+ Connection: close\r\n\r\n\
+ {\"error\":\"rate-limited: quota exceeded; retry in 1s\"}",
+ "HTTP/1.1 200 OK\r\n\
+ Content-Type: application/json\r\n\
+ Content-Length: 2\r\n\
+ Connection: close\r\n\r\n\
+ []",
+ ];
+ for i in 0..2 {
+ let Ok((mut stream, _)) = listener.accept() else {
+ return;
+ };
+ let mut buf = [0u8; 4096];
+ let _ = stream.read(&mut buf);
+ let _ = stream.write_all(responses[i.min(1)].as_bytes());
+ let _ = stream.flush();
+ }
+ });
+
+ let state = crate::app_state::build_app_state();
+ *state.relay_url_override.lock().unwrap() = Some(format!("http://{addr}"));
+ let filters = [serde_json::json!({ "kinds": [1], "limit": 1 })];
+
+ // Command 1: the relay answers 429 — the caller sees the typed error
+ // and the admission gate arms for the hinted 1s window.
+ let err = crate::relay::query_relay(&state, &filters)
+ .await
+ .expect_err("first command must surface the 429");
+ assert!(
+ err.starts_with("relay rate-limited: retry in 1s"),
+ "429 must map to the typed rate-limited error, got: {err}"
+ );
+
+ // Measure from after command 1 returns so the timer only covers the
+ // admission wait (not command 1's own network time).
+ let after_first_429 = std::time::Instant::now();
+
+ // Command 2: must be withheld until the window expires, then resume
+ // and succeed against the now-healthy relay.
+ let events = crate::relay::query_relay(&state, &filters)
+ .await
+ .expect("second command must resume and succeed after expiry");
+ assert!(events.is_empty());
+
+ let wait_elapsed = after_first_429.elapsed();
+ assert!(
+ wait_elapsed >= Duration::from_secs(1),
+ "second command ran {}ms after the 429 — it must wait out the full 1s window",
+ wait_elapsed.as_millis()
+ );
+
+ server.join().unwrap();
+ reset_rate_limit_gate();
+ }
+}
diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts
index 06ba8ff4f1..a50a8665c7 100644
--- a/desktop/src/features/communities/useCommunityInit.ts
+++ b/desktop/src/features/communities/useCommunityInit.ts
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { relayClient } from "@/shared/api/relayClient";
+import { resetRateLimitGate } from "@/shared/api/relayRateLimitGate";
import { applyCommunity, getDefaultRelayUrl } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { getOverrides } from "@/shared/features";
@@ -34,6 +35,7 @@ import type { Community } from "./types";
*/
function resetCommunityState(): void {
relayClient.disconnect();
+ resetRateLimitGate();
clearAllDrafts();
resetAgentObserverStore();
resetActiveAgentTurnsStore();
diff --git a/desktop/src/features/home/hooks.ts b/desktop/src/features/home/hooks.ts
index 33d1bca204..7d18fb80d8 100644
--- a/desktop/src/features/home/hooks.ts
+++ b/desktop/src/features/home/hooks.ts
@@ -1,8 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { getHomeFeed } from "@/shared/api/tauri";
+import { useRelayConnection } from "@/shared/api/useRelayConnection";
export function useHomeFeedQuery() {
+ const connectionState = useRelayConnection();
+ const connected = connectionState === "connected";
+
return useQuery({
queryKey: ["home-feed"],
queryFn: () =>
@@ -12,6 +16,9 @@ export function useHomeFeedQuery() {
}),
staleTime: 15_000,
gcTime: 5 * 60 * 1_000,
- refetchInterval: 30_000,
+ // Pause background polling on degraded/stalled/disconnected connections.
+ // The relay can't serve the request anyway, and the spurious failures
+ // consume quota that the recovery path needs.
+ refetchInterval: connected ? 30_000 : false,
});
}
diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts
index 3d34d07d1f..062b0ee40b 100644
--- a/desktop/src/features/messages/hooks.ts
+++ b/desktop/src/features/messages/hooks.ts
@@ -31,7 +31,7 @@ import {
clearTimeoutState,
recordTimeoutFromRejection,
} from "@/features/moderation/lib/timeoutStore";
-import { relayClient } from "@/shared/api/relayClient";
+import { relayClient, setVisibleChannel } from "@/shared/api/relayClient";
import { customEmojiQueryKey } from "@/features/custom-emoji/hooks";
import { channelsQueryKey } from "@/features/channels/hooks";
import { reactionEmojiUrl } from "@/shared/api/customEmoji";
@@ -330,6 +330,17 @@ export function useChannelSubscription(channel: Channel | null) {
}
});
+ // Notify the relay client which channel is currently visible so its live
+ // subscriptions are replayed first on reconnect, reducing latency on
+ // degraded networks.
+ useEffect(() => {
+ if (!channelId || channelType === "forum") return;
+ setVisibleChannel(channelId);
+ return () => {
+ setVisibleChannel(null);
+ };
+ }, [channelId, channelType]);
+
useEffect(() => {
if (!channelId || channelType === "forum") {
return;
diff --git a/desktop/src/features/presence/hooks.ts b/desktop/src/features/presence/hooks.ts
index 2778c6d910..5ba8683693 100644
--- a/desktop/src/features/presence/hooks.ts
+++ b/desktop/src/features/presence/hooks.ts
@@ -2,6 +2,8 @@ import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { relayClient } from "@/shared/api/relayClient";
+import { isRateLimited } from "@/shared/api/relayRateLimitGate";
+import { useRelayConnection } from "@/shared/api/useRelayConnection";
import { getOsIdleSeconds } from "@/shared/api/osIdle";
import { getPresence } from "@/shared/api/tauri";
import { normalizePubkey } from "@/shared/lib/pubkey";
@@ -79,6 +81,8 @@ export function usePresenceQuery(
) {
const normalizedPubkeys = normalizePubkeys(pubkeys);
const enabled = (options?.enabled ?? true) && normalizedPubkeys.length > 0;
+ const connectionState = useRelayConnection();
+ const connected = connectionState === "connected";
return useQuery({
enabled,
@@ -86,8 +90,9 @@ export function usePresenceQuery(
queryFn: () => getPresence(normalizedPubkeys),
staleTime: 30_000,
// Backstop poll: catches REST-only writers (ACP agents) and TTL expiry
- // (crashed clients). WS events handle the fast path.
- refetchInterval: 60_000,
+ // (crashed clients). WS events handle the fast path. Pause on degraded
+ // connections — HTTP presence calls fail anyway and consume relay quota.
+ refetchInterval: connected ? 60_000 : false,
});
}
@@ -370,6 +375,11 @@ export function usePresenceSession(pubkey?: string) {
}
const intervalId = window.setInterval(() => {
+ // Skip heartbeat ticks while the relay is unavailable or rate-limited —
+ // the publish would fail anyway and consumes quota the recovery needs.
+ if (relayClient.getConnectionState() !== "connected" || isRateLimited()) {
+ return;
+ }
syncPresence(currentStatus);
}, PRESENCE_HEARTBEAT_INTERVAL_MS);
diff --git a/desktop/src/shared/api/readOnlyRelayClient.ts b/desktop/src/shared/api/readOnlyRelayClient.ts
index 1c24acc8fc..c7446f4e70 100644
--- a/desktop/src/shared/api/readOnlyRelayClient.ts
+++ b/desktop/src/shared/api/readOnlyRelayClient.ts
@@ -8,10 +8,11 @@ import {
type RelaySubscriptionFilter,
} from "@/shared/api/relayClientShared";
import { closeWebSocket } from "@/shared/api/relayWebSocketClose";
-
-const AUTH_TIMEOUT_MS = 8_000;
-const HISTORY_TIMEOUT_MS = 8_000;
-const PUBLISH_TIMEOUT_MS = 8_000;
+import {
+ AUTH_TIMEOUT_MS,
+ HISTORY_TIMEOUT_MS,
+ PUBLISH_TIMEOUT_MS,
+} from "@/shared/api/relayClientSession";
type PendingHistory = {
events: RelayEvent[];
diff --git a/desktop/src/shared/api/relayClient.ts b/desktop/src/shared/api/relayClient.ts
index a558f93f50..9cf4c13447 100644
--- a/desktop/src/shared/api/relayClient.ts
+++ b/desktop/src/shared/api/relayClient.ts
@@ -1,3 +1,16 @@
import { RelayClient } from "@/shared/api/relayClientSession";
export const relayClient = new RelayClient();
+
+/**
+ * Notify the relay client which channel is currently visible in the UI.
+ *
+ * On reconnect, subscriptions for the visible channel are sent in the first
+ * replay batch so the user sees their active channel recover before others
+ * on degraded networks.
+ *
+ * Call with `null` when the user navigates away from a channel view.
+ */
+export function setVisibleChannel(id: string | null): void {
+ relayClient.setVisibleChannelId(id);
+}
diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts
index 7a151e5d28..6dcc857156 100644
--- a/desktop/src/shared/api/relayClientSession.ts
+++ b/desktop/src/shared/api/relayClientSession.ts
@@ -35,6 +35,12 @@ import {
prepareSubscriptionEvent,
} from "@/shared/api/relayClosedRecovery";
import { replayLiveSubscriptions } from "@/shared/api/relayReconnectReplay";
+import {
+ activateRateLimit,
+ parseRateLimitHint,
+ waitForRateLimit,
+} from "@/shared/api/relayRateLimitGate";
+import { requestHistoryGated } from "@/shared/api/relayGateBoundary";
import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEmitter";
import {
shouldRefuseConnect,
@@ -48,6 +54,22 @@ const RECONNECT_BASE_DELAY_MS = 1_000,
EVENT_BATCH_MS = 16,
AUX_BACKFILL_CONCURRENCY = 4;
+/**
+ * Op-level timeout constants. Raised from 8 s to 25 s to survive degraded
+ * networks where TLS handshakes and DNS resolution can take 3–10 s.
+ */
+export const AUTH_TIMEOUT_MS = 25_000;
+export const HISTORY_TIMEOUT_MS = 25_000;
+export const PUBLISH_TIMEOUT_MS = 25_000;
+
+/**
+ * The connection must remain stable for this long after a successful AUTH
+ * before the reconnect backoff delay resets to its base value. Stability-
+ * gated reset prevents repeated fast reconnects (flapping) from erasing the
+ * backoff that throttles them.
+ */
+export const BACKOFF_RESET_STABLE_MS = 60_000;
+
/**
* Passive liveness check. The relay sends heartbeat pings every 30s; if no
* inbound frame arrives for two heartbeat windows, treat the socket as stalled.
@@ -77,6 +99,8 @@ export class RelayClient {
private notifyReconnectListeners = false;
private onMessageChannel: Channel | null = null;
private connectionGeneration = 0;
+ private stabilityTimer: number | null = null;
+ private visibleChannelId: string | null = null;
/**
* Sticky terminal flag. Set when `resetConnection` is called with
@@ -100,6 +124,16 @@ export class RelayClient {
},
});
+ /**
+ * Track which channel the user is currently viewing so its subscriptions
+ * are sent first during reconnect replay — reducing visible latency on
+ * degraded networks where the relay REQ storm would otherwise delay all
+ * channels equally.
+ */
+ setVisibleChannelId(id: string | null) {
+ this.visibleChannelId = id;
+ }
+
/**
* Cleanly tear down the connection without scheduling a reconnect.
* Used during community switches to reset the singleton before the
@@ -112,6 +146,10 @@ export class RelayClient {
window.clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
+ if (this.stabilityTimer !== null) {
+ window.clearTimeout(this.stabilityTimer);
+ this.stabilityTimer = null;
+ }
this.stallWatchdog.stop();
this.connectionGeneration++;
this.keepAliveRequested = false;
@@ -119,6 +157,7 @@ export class RelayClient {
this.hasConnectedOnce = false;
this.notifyReconnectListeners = false;
this.terminal = false;
+ this.visibleChannelId = null;
this.connectionStateEmitter.set("idle");
if (this.wsId !== null) {
@@ -238,33 +277,16 @@ export class RelayClient {
return this.requestHistory(filter);
}
- private requestHistory(filter: RelaySubscriptionFilter) {
- return new Promise((resolve, reject) => {
- const subId = `history-${crypto.randomUUID()}`;
- const timeout = window.setTimeout(() => {
- this.subscriptions.delete(subId);
- void this.closeSubscription(subId);
- reject(new Error("Timed out while loading channel history."));
- }, 8_000);
-
- this.subscriptions.set(subId, {
- mode: "history",
- events: [],
- resolve,
- reject,
- timeout,
- });
-
- void this.sendRaw(["REQ", subId, filter]).catch((error) => {
- window.clearTimeout(timeout);
- this.subscriptions.delete(subId);
- reject(
- error instanceof Error
- ? error
- : new Error("Failed to request channel history."),
- );
- });
- });
+ private requestHistory(
+ filter: RelaySubscriptionFilter,
+ ): Promise {
+ return requestHistoryGated(
+ this.subscriptions,
+ (payload) => this.sendRaw(payload),
+ (subId) => this.closeSubscription(subId),
+ filter,
+ HISTORY_TIMEOUT_MS,
+ );
}
async sendMessage(
@@ -512,6 +534,13 @@ export class RelayClient {
}
private async connect() {
+ // Clear any pending stability timer from a previous connection — a new
+ // connect attempt resets the clock and must re-arm the timer on success.
+ if (this.stabilityTimer !== null) {
+ window.clearTimeout(this.stabilityTimer);
+ this.stabilityTimer = null;
+ }
+
this.connectionStateEmitter.set(
this.hasConnectedOnce ? "reconnecting" : "connecting",
);
@@ -538,7 +567,7 @@ export class RelayClient {
new Error("Timed out while waiting for relay authentication."),
);
reject(new Error("Timed out while waiting for relay authentication."));
- }, 8_000);
+ }, AUTH_TIMEOUT_MS);
this.authRequest = {
pendingEventId: "",
@@ -548,7 +577,15 @@ export class RelayClient {
};
});
- this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
+ // Start a stability timer instead of resetting backoff immediately.
+ // The backoff resets to its base value only after BACKOFF_RESET_STABLE_MS
+ // of uninterrupted uptime, preventing fast reconnect loops from erasing
+ // the exponential backoff that throttles them.
+ this.stabilityTimer = window.setTimeout(() => {
+ this.stabilityTimer = null;
+ this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
+ }, BACKOFF_RESET_STABLE_MS);
+
await this.replayLiveSubscriptions();
this.connectionStateEmitter.set("connected");
this.stallWatchdog.start();
@@ -665,16 +702,19 @@ export class RelayClient {
await this.sendRaw(["CLOSE", subId]);
}
- publishEvent(
+ async publishEvent(
event: RelayEvent,
timeoutMessage: string,
sendErrorMessage: string,
) {
+ // Await the gate before sending EVENT; op timeout starts after the wait.
+ await waitForRateLimit();
+
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => {
this.pendingEvents.delete(event.id);
reject(new Error(timeoutMessage));
- }, 8_000);
+ }, PUBLISH_TIMEOUT_MS);
this.pendingEvents.set(event.id, {
event,
@@ -789,6 +829,16 @@ export class RelayClient {
"Failed to restore relay subscription after CLOSED.",
),
});
+ return;
+ }
+
+ if (type === "NOTICE" && typeof rest[0] === "string") {
+ const notice: string = rest[0];
+ // Relay back-pressure signal — activate the gate so pending operations
+ // back off until the window expires.
+ if (notice.startsWith("rate-limited:")) {
+ activateRateLimit(parseRateLimitHint(notice));
+ }
}
}
@@ -889,11 +939,14 @@ export class RelayClient {
}
private async replayLiveSubscriptions() {
+ const generation = this.connectionGeneration;
try {
await replayLiveSubscriptions({
subscriptions: this.subscriptions,
sendRaw: (payload) => this.sendRaw(payload),
requestHistory: (filter) => this.requestHistory(filter),
+ visibleChannelId: this.visibleChannelId,
+ isActive: () => this.connectionGeneration === generation,
});
} catch (error) {
const reconnectError =
@@ -918,7 +971,11 @@ export class RelayClient {
return;
}
- const delay = this.reconnectDelayMs;
+ // Apply ±25% jitter so a fleet of clients reconnecting simultaneously
+ // spreads their AUTH storms across a 50% window instead of all hitting
+ // the relay at the same instant.
+ const jitter = this.reconnectDelayMs * (0.75 + Math.random() * 0.5);
+ const delay = Math.min(jitter, RECONNECT_MAX_DELAY_MS);
this.reconnectDelayMs = Math.min(
this.reconnectDelayMs * 2,
RECONNECT_MAX_DELAY_MS,
@@ -961,6 +1018,10 @@ export class RelayClient {
this.onMessageChannel = null;
this.stallWatchdog.stop();
this.connectionGeneration++;
+ if (this.stabilityTimer !== null) {
+ window.clearTimeout(this.stabilityTimer);
+ this.stabilityTimer = null;
+ }
if (this.flushTimeout !== null) window.clearTimeout(this.flushTimeout);
this.flushTimeout = null;
this.eventBuffer = [];
diff --git a/desktop/src/shared/api/relayClosedPolicy.test.mjs b/desktop/src/shared/api/relayClosedPolicy.test.mjs
index e75cf1861e..715df77f6a 100644
--- a/desktop/src/shared/api/relayClosedPolicy.test.mjs
+++ b/desktop/src/shared/api/relayClosedPolicy.test.mjs
@@ -1,20 +1,21 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { isRetryableRelayClosed } from "./relayClosedPolicy.ts";
+import { classifyRelayClosed } from "./relayClosedPolicy.ts";
-test("retries transient CLOSED responses", () => {
+// ── classifyRelayClosed ───────────────────────────────────────────────────────
+
+test("classifyRelayClosed: rate-limited messages return rate-limited", () => {
for (const message of [
+ "rate-limited: quota exceeded; retry in 4s",
"rate-limited: slow down",
- "error: database error",
- "server shutting down",
- "",
+ "rate-limited:",
]) {
- assert.equal(isRetryableRelayClosed(message), true, message);
+ assert.equal(classifyRelayClosed(message), "rate-limited", message);
}
});
-test("does not retry permanent CLOSED responses", () => {
+test("classifyRelayClosed: terminal messages return terminal", () => {
for (const message of [
"restricted: not a channel member",
"restricted: channel access revoked",
@@ -27,6 +28,46 @@ test("does not retry permanent CLOSED responses", () => {
"error: mixed search and non-search filters not supported",
"error: too many subscriptions",
]) {
- assert.equal(isRetryableRelayClosed(message), false, message);
+ assert.equal(classifyRelayClosed(message), "terminal", message);
+ }
+});
+
+test("classifyRelayClosed: transient errors return retryable", () => {
+ for (const message of ["error: database error", "server shutting down", ""]) {
+ assert.equal(classifyRelayClosed(message), "retryable", message);
+ }
+});
+
+// ── Subscription-survival semantics ──────────────────────────────────────────
+// These replace the removed isRetryableRelayClosed wrapper tests.
+// rate-limited must not delete the subscription; terminal must.
+
+test("classifyRelayClosed: rate-limited class survives (subscription must not be deleted)", () => {
+ // Subscription deletion is gated on === "terminal"; rate-limited must survive.
+ assert.notEqual(
+ classifyRelayClosed("rate-limited: quota exceeded; retry in 4s"),
+ "terminal",
+ );
+});
+
+test("classifyRelayClosed: retryable class survives (subscription must not be deleted)", () => {
+ for (const message of ["error: database error", "server shutting down", ""]) {
+ assert.notEqual(classifyRelayClosed(message), "terminal", message);
+ }
+});
+
+test("classifyRelayClosed: terminal class triggers deletion (no retry)", () => {
+ for (const message of [
+ "restricted: not a channel member",
+ "auth-required: not authenticated",
+ "blocked: banned",
+ "invalid: malformed filter",
+ "pow: difficulty too low",
+ "duplicate: subscription exists",
+ "unsupported: filter",
+ "error: mixed search and non-search filters not supported",
+ "error: too many subscriptions",
+ ]) {
+ assert.equal(classifyRelayClosed(message), "terminal", message);
}
});
diff --git a/desktop/src/shared/api/relayClosedPolicy.ts b/desktop/src/shared/api/relayClosedPolicy.ts
index f4257ed7a5..8a39e8a4d5 100644
--- a/desktop/src/shared/api/relayClosedPolicy.ts
+++ b/desktop/src/shared/api/relayClosedPolicy.ts
@@ -3,9 +3,23 @@
* changing the request; authorization and malformed-filter failures require a
* caller/state change and would otherwise loop forever.
*/
-export function isRetryableRelayClosed(message: string) {
+
+/**
+ * Three-way classification of a relay CLOSED message.
+ *
+ * - `"retryable"` — transient failure; re-send the REQ after a backoff.
+ * - `"rate-limited"` — relay back-pressure; activate the rate-limit gate and
+ * retry when the gate expires (subscription survives).
+ * - `"terminal"` — auth, access, or filter error; delete the subscription.
+ */
+export type RelayClosedClass = "retryable" | "rate-limited" | "terminal";
+
+export function classifyRelayClosed(message: string): RelayClosedClass {
const normalized = message.trim().toLowerCase();
- return !(
+ if (normalized.startsWith("rate-limited:")) {
+ return "rate-limited";
+ }
+ if (
normalized.startsWith("restricted:") ||
normalized.startsWith("auth-required:") ||
normalized.startsWith("blocked:") ||
@@ -15,5 +29,8 @@ export function isRetryableRelayClosed(message: string) {
normalized.startsWith("unsupported:") ||
normalized.startsWith("error: mixed search") ||
normalized.startsWith("error: too many subscriptions")
- );
+ ) {
+ return "terminal";
+ }
+ return "retryable";
}
diff --git a/desktop/src/shared/api/relayClosedRecovery.test.mjs b/desktop/src/shared/api/relayClosedRecovery.test.mjs
index 9c9b59440f..90acd6cb2f 100644
--- a/desktop/src/shared/api/relayClosedRecovery.test.mjs
+++ b/desktop/src/shared/api/relayClosedRecovery.test.mjs
@@ -2,11 +2,65 @@ import assert from "node:assert/strict";
import test from "node:test";
import { handleRelayClosed } from "./relayClosedRecovery.ts";
+import { requestHistoryGated } from "./relayGateBoundary.ts";
+
+// ── Fake-timer setup ──────────────────────────────────────────────────────────
+// The rate-limit gate and closed-retry logic use window.setTimeout/clearTimeout.
+
+let fakeNow = 0;
+const pendingTimers = new Map();
+let nextTimerId = 1;
+
+function fakeSetTimeout(fn, ms) {
+ const id = nextTimerId++;
+ pendingTimers.set(id, { fn, fireAt: fakeNow + ms });
+ return id;
+}
+
+function fakeClearTimeout(id) {
+ pendingTimers.delete(id);
+}
+
+function tickTo(ms) {
+ fakeNow = ms;
+ for (const [id, { fn, fireAt }] of Array.from(pendingTimers.entries())) {
+ if (fireAt <= fakeNow) {
+ pendingTimers.delete(id);
+ fn();
+ }
+ }
+}
+
+const origDateNow = Date.now;
+function setFakeNow(ms) {
+ fakeNow = ms;
+ Date.now = () => fakeNow;
+}
+
+globalThis.window = {
+ setTimeout: fakeSetTimeout,
+ clearTimeout: fakeClearTimeout,
+};
+
+// Import gate after window shim is installed.
+const { activateRateLimit, isRateLimited, resetRateLimitGate } = await import(
+ "./relayRateLimitGate.ts"
+);
+
+function resetAll(startMs = 0) {
+ pendingTimers.clear();
+ nextTimerId = 1;
+ setFakeNow(startMs);
+ resetRateLimitGate();
+}
test("production CLOSED handler rejects history once and clears its timeout", () => {
const originalWindow = globalThis.window;
const clearedTimeouts = [];
globalThis.window = {
+ // Provide both setTimeout (needed by activateRateLimit in the F1 fix) and
+ // clearTimeout (the existing assertion target).
+ setTimeout: (_fn, _ms) => 0,
clearTimeout: (timeout) => clearedTimeouts.push(timeout),
};
try {
@@ -45,6 +99,134 @@ test("production CLOSED handler rejects history once and clears its timeout", ()
}
});
+test("rate-limited history CLOSED arms the shared gate for concurrent ops", () => {
+ resetAll(0);
+ const subscriptions = new Map([
+ [
+ "history-1",
+ {
+ mode: "history",
+ events: [],
+ resolve: () => {},
+ reject: () => {},
+ timeout: 0,
+ },
+ ],
+ ]);
+ handleRelayClosed({
+ subscriptions,
+ subId: "history-1",
+ message: "rate-limited: quota exceeded; retry in 5s",
+ sendReq: () => Promise.resolve(),
+ });
+ assert.equal(
+ isRateLimited(),
+ true,
+ "gate must be active after rate-limited history CLOSED",
+ );
+ // Gate expires after the hint duration.
+ tickTo(5_001);
+ assert.equal(isRateLimited(), false, "gate must clear after hint duration");
+});
+
+test("non-rate-limited history CLOSED does not arm the gate", () => {
+ resetAll(0);
+ const subscriptions = new Map([
+ [
+ "history-2",
+ {
+ mode: "history",
+ events: [],
+ resolve: () => {},
+ reject: () => {},
+ timeout: 0,
+ },
+ ],
+ ]);
+ handleRelayClosed({
+ subscriptions,
+ subId: "history-2",
+ message: "error: database unavailable",
+ sendReq: () => Promise.resolve(),
+ });
+ assert.equal(
+ isRateLimited(),
+ false,
+ "gate must remain inactive for non-rate-limited history CLOSED",
+ );
+});
+
+test("gate armed by rate-limited history CLOSED defers the next REQ until expiry then resumes", async () => {
+ // Simulate: rate-limited CLOSED arrives on a history sub → gate arms for 5s.
+ // A concurrent requestHistoryGated call must not issue the REQ before 5s,
+ // and must issue it (and resolve) once the gate clears.
+ resetAll(0);
+
+ const subscriptions = new Map([
+ [
+ "history-gate",
+ {
+ mode: "history",
+ events: [],
+ resolve: () => {},
+ reject: () => {},
+ timeout: 0,
+ },
+ ],
+ ]);
+
+ // Arm the gate via a rate-limited history CLOSED.
+ handleRelayClosed({
+ subscriptions,
+ subId: "history-gate",
+ message: "rate-limited: quota exceeded; retry in 5s",
+ sendReq: () => Promise.resolve(),
+ });
+
+ assert.equal(isRateLimited(), true, "gate must be armed before the test");
+
+ const sentAt = [];
+ const reqSubscriptions = new Map();
+
+ // requestHistoryGated will await the gate, so the REQ must not fire at t=0.
+ const historyPromise = requestHistoryGated(
+ reqSubscriptions,
+ async (payload) => {
+ // Record when the REQ fires. The test harness sets up the EOSE path by
+ // adding a history subscription to reqSubscriptions immediately after
+ // the REQ is recorded, then resolving it.
+ sentAt.push(fakeNow);
+ // Resolve the returned promise by completing the sub synchronously.
+ const subId = payload[1];
+ const sub = reqSubscriptions.get(subId);
+ if (sub) {
+ window.clearTimeout(sub.timeout);
+ reqSubscriptions.delete(subId);
+ sub.resolve([]);
+ }
+ },
+ async () => {},
+ { kinds: [9], "#h": ["ch-test"], limit: 50 },
+ 25_000,
+ );
+
+ // REQ must not have fired yet — gate is still active at t=0.
+ await Promise.resolve();
+ assert.equal(sentAt.length, 0, "REQ must not fire while gate is active");
+
+ // Expire the gate — the deferred REQ should fire.
+ tickTo(5_001);
+
+ await historyPromise;
+
+ assert.equal(
+ sentAt.length,
+ 1,
+ "REQ must fire exactly once after gate clears",
+ );
+ assert.ok(sentAt[0] >= 5_001, "REQ must fire only after gate expiry");
+});
+
test("production CLOSED handler removes terminal live subscriptions", () => {
let readyCalls = 0;
const subscriptions = new Map([
@@ -69,3 +251,173 @@ test("production CLOSED handler removes terminal live subscriptions", () => {
assert.equal(subscriptions.has("live-1"), false);
assert.equal(readyCalls, 1);
});
+
+// ── Rate-limited CLOSED core behaviour (F5) ───────────────────────────────────
+
+test("rate-limited CLOSED keeps live subscription in the map", () => {
+ resetAll(0);
+ const subscriptions = new Map([
+ [
+ "live-1",
+ {
+ mode: "live",
+ filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
+ onEvent: () => {},
+ resolveReady: () => {},
+ },
+ ],
+ ]);
+ handleRelayClosed({
+ subscriptions,
+ subId: "live-1",
+ message: "rate-limited: quota exceeded; retry in 5s",
+ sendReq: () => Promise.resolve(),
+ });
+ assert.equal(
+ subscriptions.has("live-1"),
+ true,
+ "subscription must survive rate-limited CLOSED",
+ );
+});
+
+test("rate-limited CLOSED activates the rate-limit gate with the parsed hint", () => {
+ resetAll(0);
+ const subscriptions = new Map([
+ [
+ "live-1",
+ {
+ mode: "live",
+ filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
+ onEvent: () => {},
+ resolveReady: () => {},
+ },
+ ],
+ ]);
+ handleRelayClosed({
+ subscriptions,
+ subId: "live-1",
+ message: "rate-limited: quota exceeded; retry in 5s",
+ sendReq: () => Promise.resolve(),
+ });
+ assert.equal(
+ isRateLimited(),
+ true,
+ "gate must be active after rate-limited CLOSED",
+ );
+ // Gate should expire at 5s.
+ tickTo(5_001);
+ assert.equal(isRateLimited(), false);
+});
+
+test("rate-limited CLOSED retry delay is max(backoff, gate remaining), not just hint", () => {
+ resetAll(0);
+ // Activate a long gate first (20s), then send a shorter-hint CLOSED (5s).
+ // The retry delay must use the gate remaining time (20s), not the hint (5s).
+ activateRateLimit(20); // gate expires at 20_000 ms
+
+ const firedAt = [];
+ const subscriptions = new Map([
+ [
+ "live-1",
+ {
+ mode: "live",
+ filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
+ onEvent: () => {},
+ resolveReady: () => {},
+ },
+ ],
+ ]);
+ handleRelayClosed({
+ subscriptions,
+ subId: "live-1",
+ message: "rate-limited: quota exceeded; retry in 5s",
+ sendReq: () => {
+ firedAt.push(fakeNow);
+ return Promise.resolve();
+ },
+ });
+
+ // Retry should NOT fire at 5s (hint) — the gate remaining is 20s.
+ tickTo(5_001);
+ assert.equal(
+ firedAt.length,
+ 0,
+ "retry must not fire before gate remaining time",
+ );
+
+ // Should fire at 20s.
+ tickTo(20_001);
+ assert.equal(firedAt.length, 1, "retry must fire after gate remaining time");
+});
+
+test("non-rate-limited retryable CLOSED still schedules a retry", () => {
+ resetAll(0);
+ const firedAt = [];
+ const subscriptions = new Map([
+ [
+ "live-1",
+ {
+ mode: "live",
+ filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
+ onEvent: () => {},
+ resolveReady: () => {},
+ },
+ ],
+ ]);
+ handleRelayClosed({
+ subscriptions,
+ subId: "live-1",
+ message: "error: database error",
+ sendReq: () => {
+ firedAt.push(fakeNow);
+ return Promise.resolve();
+ },
+ });
+ // Base delay is 1s for first attempt.
+ tickTo(1_001);
+ assert.equal(firedAt.length, 1, "retryable CLOSED must schedule a retry");
+ assert.equal(
+ subscriptions.has("live-1"),
+ true,
+ "subscription must survive retryable CLOSED",
+ );
+});
+
+test("terminal CLOSED deletes subscription and does not retry", () => {
+ resetAll(0);
+ const firedAt = [];
+ const subscriptions = new Map([
+ [
+ "live-1",
+ {
+ mode: "live",
+ filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
+ onEvent: () => {},
+ resolveReady: () => {},
+ },
+ ],
+ ]);
+ handleRelayClosed({
+ subscriptions,
+ subId: "live-1",
+ message: "restricted: not a member",
+ sendReq: () => {
+ firedAt.push(fakeNow);
+ return Promise.resolve();
+ },
+ });
+ assert.equal(
+ subscriptions.has("live-1"),
+ false,
+ "terminal CLOSED must delete subscription",
+ );
+ tickTo(10_000);
+ assert.equal(firedAt.length, 0, "terminal CLOSED must not retry");
+});
+
+// ── Teardown ──────────────────────────────────────────────────────────────────
+
+test("teardown — restore Date.now", () => {
+ Date.now = origDateNow;
+ assert.ok(true);
+});
diff --git a/desktop/src/shared/api/relayClosedRecovery.ts b/desktop/src/shared/api/relayClosedRecovery.ts
index e8038bb71c..7206014eef 100644
--- a/desktop/src/shared/api/relayClosedRecovery.ts
+++ b/desktop/src/shared/api/relayClosedRecovery.ts
@@ -1,4 +1,9 @@
-import { isRetryableRelayClosed } from "@/shared/api/relayClosedPolicy";
+import { classifyRelayClosed } from "@/shared/api/relayClosedPolicy";
+import {
+ activateRateLimit,
+ parseRateLimitHint,
+ rateLimitRemainingMs,
+} from "@/shared/api/relayRateLimitGate";
import {
sortEvents,
type RelaySubscription,
@@ -31,6 +36,14 @@ export function handleRelayClosed({
const subscription = subscriptions.get(subId);
if (!subscription) return;
if (subscription.mode === "history") {
+ // Classify before rejecting so a `rate-limited:` history CLOSED arms the
+ // gate for concurrent ops. A history sub can't be retried (the caller holds
+ // the promise), so we still reject immediately after arming.
+ const closedClass = classifyRelayClosed(message);
+ if (closedClass === "rate-limited") {
+ const hintSeconds = parseRateLimitHint(message);
+ activateRateLimit(hintSeconds);
+ }
window.clearTimeout(subscription.timeout);
subscriptions.delete(subId);
subscription.reject(
@@ -62,17 +75,38 @@ function recoverLiveSubscriptionFromClosed({
}) {
subscription.resolveReady?.();
subscription.resolveReady = undefined;
- if (!isRetryableRelayClosed(message)) {
+
+ const closedClass = classifyRelayClosed(message);
+
+ if (closedClass === "terminal") {
+ // Auth/access/filter failure — permanently remove the subscription so it
+ // doesn't silently loop.
subscriptions.delete(subId);
return;
}
+
if (subscription.closedRetryTimeout !== undefined) return;
const attempt = subscription.closedRetryAttempt ?? 0;
- const delayMs = Math.min(
+ const backoffMs = Math.min(
RETRY_BASE_DELAY_MS * 2 ** attempt,
RETRY_MAX_DELAY_MS,
);
+
+ let delayMs = backoffMs;
+
+ if (closedClass === "rate-limited") {
+ // Activate the gate so concurrent operations back off too.
+ const hintSeconds = parseRateLimitHint(message);
+ activateRateLimit(hintSeconds);
+ // Use the gate's actual remaining time so a shorter hint arriving under a
+ // longer active gate does not schedule a premature retry that just gets
+ // another CLOSED. The fallback covers the gate-inactive edge case
+ // (hint * 1000, or 10s default when no hint).
+ const fallbackMs = (hintSeconds ?? 10) * 1_000;
+ delayMs = Math.max(backoffMs, rateLimitRemainingMs() || fallbackMs);
+ }
+
subscription.closedRetryAttempt = attempt + 1;
subscription.closedRetryTimeout = window.setTimeout(() => {
subscription.closedRetryTimeout = undefined;
diff --git a/desktop/src/shared/api/relayGateBoundary.ts b/desktop/src/shared/api/relayGateBoundary.ts
new file mode 100644
index 0000000000..99782358f2
--- /dev/null
+++ b/desktop/src/shared/api/relayGateBoundary.ts
@@ -0,0 +1,56 @@
+/**
+ * Gate-aware relay operation boundaries.
+ *
+ * History REQ operations are issued here rather than inline in RelayClient so
+ * the gate-await + op-timeout pattern lives in one place and the op timeout
+ * budget starts only after the rate-limit window has cleared.
+ */
+import { waitForRateLimit } from "@/shared/api/relayRateLimitGate";
+import type {
+ RelaySubscription,
+ RelaySubscriptionFilter,
+} from "@/shared/api/relayClientShared";
+import type { RelayEvent } from "@/shared/api/types";
+
+/**
+ * Issue a history REQ on `filter`, waiting for any active rate-limit gate
+ * before starting the subscription so the op timeout begins only after
+ * back-pressure has cleared.
+ */
+export async function requestHistoryGated(
+ subscriptions: Map,
+ sendRaw: (payload: unknown[]) => Promise,
+ closeSubscription: (subId: string) => Promise,
+ filter: RelaySubscriptionFilter,
+ historyTimeoutMs: number,
+): Promise {
+ // Await the gate before issuing REQ; op timeout starts after the wait.
+ await waitForRateLimit();
+
+ return new Promise((resolve, reject) => {
+ const subId = `history-${crypto.randomUUID()}`;
+ const timeout = window.setTimeout(() => {
+ subscriptions.delete(subId);
+ void closeSubscription(subId);
+ reject(new Error("Timed out while loading channel history."));
+ }, historyTimeoutMs);
+
+ subscriptions.set(subId, {
+ mode: "history",
+ events: [],
+ resolve,
+ reject,
+ timeout,
+ });
+
+ void sendRaw(["REQ", subId, filter]).catch((error) => {
+ window.clearTimeout(timeout);
+ subscriptions.delete(subId);
+ reject(
+ error instanceof Error
+ ? error
+ : new Error("Failed to request channel history."),
+ );
+ });
+ });
+}
diff --git a/desktop/src/shared/api/relayRateLimitGate.test.mjs b/desktop/src/shared/api/relayRateLimitGate.test.mjs
new file mode 100644
index 0000000000..a458e87b48
--- /dev/null
+++ b/desktop/src/shared/api/relayRateLimitGate.test.mjs
@@ -0,0 +1,351 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+// ── Fake-timer setup ──────────────────────────────────────────────────────────
+
+// The gate uses window.setTimeout/clearTimeout. We need a controllable fake
+// before the module is loaded. Set up globalThis.window with a fake timer
+// implementation and then dynamically import the module under test.
+
+let fakeNow = 0;
+const pendingTimers = new Map(); // id → { fn, fireAt }
+let nextTimerId = 1;
+
+function fakeSetTimeout(fn, ms) {
+ const id = nextTimerId++;
+ pendingTimers.set(id, { fn, fireAt: fakeNow + ms });
+ return id;
+}
+
+function fakeClearTimeout(id) {
+ pendingTimers.delete(id);
+}
+
+function tickTo(ms) {
+ fakeNow = ms;
+ for (const [id, { fn, fireAt }] of Array.from(pendingTimers.entries())) {
+ if (fireAt <= fakeNow) {
+ pendingTimers.delete(id);
+ fn();
+ }
+ }
+}
+
+// Install window shim before any imports use it.
+globalThis.window = {
+ setTimeout: fakeSetTimeout,
+ clearTimeout: fakeClearTimeout,
+};
+
+// Patch Date.now for the gate.
+const origDateNow = Date.now;
+function setFakeNow(ms) {
+ fakeNow = ms;
+ Date.now = () => fakeNow;
+}
+
+// Import after window is set up.
+const {
+ activateRateLimit,
+ isRateLimited,
+ waitForRateLimit,
+ resetRateLimitGate,
+ rateLimitRemainingMs,
+ parseRateLimitHint,
+ MAX_HINT_SECONDS,
+} = await import("./relayRateLimitGate.ts");
+
+// Helper to reset between tests.
+function reset(startMs = 0) {
+ pendingTimers.clear();
+ nextTimerId = 1;
+ setFakeNow(startMs);
+ resetRateLimitGate();
+}
+
+// ── parseRateLimitHint ────────────────────────────────────────────────────────
+
+test("parseRateLimitHint extracts seconds from CLOSED message", () => {
+ assert.equal(
+ parseRateLimitHint("rate-limited: quota exceeded; retry in 4s"),
+ 4,
+ );
+});
+
+test("parseRateLimitHint extracts seconds from HTTP 429 prefix", () => {
+ assert.equal(parseRateLimitHint("relay rate-limited: retry in 30s"), 30);
+});
+
+test("parseRateLimitHint returns null when no hint present", () => {
+ assert.equal(parseRateLimitHint("rate-limited: quota exceeded"), null);
+ assert.equal(parseRateLimitHint(""), null);
+ assert.equal(parseRateLimitHint("some other message"), null);
+});
+
+// ── isRateLimited / activate ──────────────────────────────────────────────────
+
+test("not rate-limited before any activation", () => {
+ reset();
+ assert.equal(isRateLimited(), false);
+});
+
+test("rate-limited immediately after activation", () => {
+ reset(0);
+ activateRateLimit(10);
+ assert.equal(isRateLimited(), true);
+});
+
+test("rate-limit expires when timer fires", () => {
+ reset(0);
+ activateRateLimit(10);
+ tickTo(10_001);
+ assert.equal(isRateLimited(), false);
+});
+
+test("activation extends expiry when new hint is longer than existing", () => {
+ reset(0);
+ activateRateLimit(5); // expires at 5000
+ activateRateLimit(20); // should extend to 20000
+ tickTo(5_001);
+ // Gate should still be active because the longer window was applied.
+ assert.equal(isRateLimited(), true);
+ tickTo(20_001);
+ assert.equal(isRateLimited(), false);
+});
+
+test("shorter hint does not shrink an existing longer window", () => {
+ reset(0);
+ activateRateLimit(20); // expires at 20000
+ activateRateLimit(5); // shorter — should NOT shrink
+ tickTo(5_001);
+ assert.equal(isRateLimited(), true);
+ tickTo(20_001);
+ assert.equal(isRateLimited(), false);
+});
+
+test("null hint uses 10s default", () => {
+ reset(0);
+ activateRateLimit(null);
+ tickTo(9_999);
+ assert.equal(isRateLimited(), true);
+ tickTo(10_001);
+ assert.equal(isRateLimited(), false);
+});
+
+test("zero hint uses 10s default (0s gate would be swallowed)", () => {
+ reset(0);
+ activateRateLimit(0);
+ tickTo(9_999);
+ assert.equal(isRateLimited(), true);
+ tickTo(10_001);
+ assert.equal(isRateLimited(), false);
+});
+
+test("negative hint uses 10s default", () => {
+ reset(0);
+ activateRateLimit(-5);
+ tickTo(9_999);
+ assert.equal(isRateLimited(), true);
+ tickTo(10_001);
+ assert.equal(isRateLimited(), false);
+});
+
+// ── MAX_HINT_SECONDS cap ──────────────────────────────────────────────────────
+
+test("oversized hint is clamped to MAX_HINT_SECONDS", () => {
+ reset(0);
+ // A relay sending 1 000 000s must not pin the gate beyond 300s.
+ activateRateLimit(1_000_000);
+ // Gate must still be active at MAX_HINT_SECONDS - 1 ms.
+ tickTo(MAX_HINT_SECONDS * 1_000 - 1);
+ assert.equal(
+ isRateLimited(),
+ true,
+ "gate must be active just before cap expiry",
+ );
+ // Gate must expire at MAX_HINT_SECONDS.
+ tickTo(MAX_HINT_SECONDS * 1_000 + 1);
+ assert.equal(isRateLimited(), false, "gate must expire at MAX_HINT_SECONDS");
+});
+
+test("hint exactly at MAX_HINT_SECONDS is honoured without clamping", () => {
+ reset(0);
+ activateRateLimit(MAX_HINT_SECONDS);
+ tickTo(MAX_HINT_SECONDS * 1_000 - 1);
+ assert.equal(isRateLimited(), true);
+ tickTo(MAX_HINT_SECONDS * 1_000 + 1);
+ assert.equal(isRateLimited(), false);
+});
+
+test("applyTauriRateLimitIfNeeded with oversized hint clamps to MAX_HINT_SECONDS", async () => {
+ // This test imports applyTauriRateLimitIfNeeded separately and verifies that
+ // the TS cap applies even when the message string contains a large hint value
+ // (the Rust layer clamps in practice, but TS must be independently safe).
+ reset(0);
+ const { applyTauriRateLimitIfNeeded } = await import("./tauri.ts");
+ // Simulate a message that somehow escaped the Rust cap (defence-in-depth).
+ applyTauriRateLimitIfNeeded("relay rate-limited: retry in 1000000s");
+ // Gate should cap at MAX_HINT_SECONDS * 1000 ms.
+ tickTo(MAX_HINT_SECONDS * 1_000 - 1);
+ assert.equal(
+ isRateLimited(),
+ true,
+ "gate must still be active just before cap",
+ );
+ tickTo(MAX_HINT_SECONDS * 1_000 + 1);
+ assert.equal(
+ isRateLimited(),
+ false,
+ "gate must expire at MAX_HINT_SECONDS, not 1 000 000s",
+ );
+});
+
+// ── waitForRateLimit ──────────────────────────────────────────────────────────
+
+test("waitForRateLimit resolves immediately when not rate-limited", async () => {
+ reset();
+ let resolved = false;
+ const p = waitForRateLimit().then(() => {
+ resolved = true;
+ });
+ await p;
+ assert.equal(resolved, true);
+});
+
+test("waitForRateLimit resolves after timer fires", async () => {
+ reset(0);
+ activateRateLimit(5);
+
+ let resolved = false;
+ const p = waitForRateLimit().then(() => {
+ resolved = true;
+ });
+
+ // Should not have resolved yet.
+ await Promise.resolve();
+ assert.equal(resolved, false);
+
+ // Fire the timer.
+ tickTo(5_001);
+
+ await p;
+ assert.equal(resolved, true);
+});
+
+test("multiple waiters all resolve when gate expires", async () => {
+ reset(0);
+ activateRateLimit(5);
+
+ const results = [];
+ const promises = [1, 2, 3].map((id) =>
+ waitForRateLimit().then(() => results.push(id)),
+ );
+
+ await Promise.resolve();
+ assert.equal(results.length, 0);
+
+ tickTo(5_001);
+ await Promise.all(promises);
+ assert.deepEqual(results, [1, 2, 3]);
+});
+
+// ── resetRateLimitGate ────────────────────────────────────────────────────────
+
+test("resetRateLimitGate clears an active gate immediately", () => {
+ reset(0);
+ activateRateLimit(30);
+ assert.equal(isRateLimited(), true);
+
+ resetRateLimitGate();
+ assert.equal(isRateLimited(), false);
+});
+
+test("resetRateLimitGate clears the timer so it does not fire later", () => {
+ reset(0);
+ activateRateLimit(10);
+ const timerCountBefore = pendingTimers.size;
+ assert.equal(timerCountBefore, 1);
+
+ resetRateLimitGate();
+ assert.equal(pendingTimers.size, 0);
+});
+
+test("in-flight waitForRateLimit resolves when resetRateLimitGate is called", async () => {
+ reset(0);
+ activateRateLimit(30);
+
+ let inFlightResolved = false;
+ const inFlight = waitForRateLimit().then(() => {
+ inFlightResolved = true;
+ });
+
+ // Not yet resolved before reset.
+ await Promise.resolve();
+ assert.equal(inFlightResolved, false);
+
+ // reset resolves the in-flight awaiter immediately.
+ resetRateLimitGate();
+ await inFlight;
+ assert.equal(inFlightResolved, true);
+
+ // Gate is now clear.
+ assert.equal(isRateLimited(), false);
+
+ // A new wait should also resolve immediately.
+ await waitForRateLimit();
+});
+
+// ── rateLimitRemainingMs ──────────────────────────────────────────────────────
+
+test("rateLimitRemainingMs returns 0 when gate is inactive", () => {
+ reset(0);
+ assert.equal(rateLimitRemainingMs(), 0);
+});
+
+test("rateLimitRemainingMs returns remaining ms when gate is active", () => {
+ reset(0);
+ activateRateLimit(10); // expires at 10_000 ms
+ setFakeNow(3_000);
+ assert.equal(rateLimitRemainingMs(), 7_000);
+});
+
+test("rateLimitRemainingMs returns 0 after gate expires", () => {
+ reset(0);
+ activateRateLimit(5);
+ tickTo(5_001);
+ assert.equal(rateLimitRemainingMs(), 0);
+});
+
+// ── NOTICE → gate integration ─────────────────────────────────────────────────
+// Verifies that the session NOTICE handler logic — a case-sensitive
+// `startsWith("rate-limited:")` check followed by activateRateLimit — correctly
+// arms the gate. The relay always emits lowercase prefixes.
+
+test("rate-limited: NOTICE prefix (case-sensitive) activates the gate", () => {
+ reset(0);
+ const notice = "rate-limited: quota exceeded; retry in 8s";
+ if (notice.startsWith("rate-limited:")) {
+ activateRateLimit(parseRateLimitHint(notice));
+ }
+ assert.equal(isRateLimited(), true);
+ tickTo(8_001);
+ assert.equal(isRateLimited(), false);
+});
+
+test("Rate-limited: NOTICE with uppercase prefix does NOT activate the gate (relay always emits lowercase)", () => {
+ reset(0);
+ const notice = "Rate-limited: quota exceeded; retry in 8s";
+ if (notice.startsWith("rate-limited:")) {
+ activateRateLimit(parseRateLimitHint(notice));
+ }
+ // Uppercase is not emitted by the relay — gate should remain inactive.
+ assert.equal(isRateLimited(), false);
+});
+
+// ── Cleanup ───────────────────────────────────────────────────────────────────
+
+// Restore Date.now after all tests to avoid polluting subsequent test files.
+test("teardown — restore Date.now", () => {
+ Date.now = origDateNow;
+ assert.ok(true);
+});
diff --git a/desktop/src/shared/api/relayRateLimitGate.ts b/desktop/src/shared/api/relayRateLimitGate.ts
new file mode 100644
index 0000000000..0af3eed7d9
--- /dev/null
+++ b/desktop/src/shared/api/relayRateLimitGate.ts
@@ -0,0 +1,137 @@
+/**
+ * Module-level rate-limit gate for the relay WebSocket and HTTP bridge.
+ *
+ * When the relay signals back-pressure via a CLOSED `rate-limited:` message or
+ * an HTTP 429 response, callers activate the gate. Operations that must not run
+ * while rate-limited call `isRateLimited()` or await `waitForRateLimit()`.
+ *
+ * The gate is a singleton: one shared expiry covers all concurrent callers so
+ * overlapping hints (multiple CLOSED frames) extend to the latest expiry without
+ * stacking. This module must be reset on community switch via
+ * `resetRateLimitGate()`.
+ */
+
+/** Minimum gate duration when the relay provides no `retry in Ns` hint. */
+const DEFAULT_RATE_LIMIT_SECONDS = 10;
+
+/**
+ * Maximum hint the TS gate will honour from a relay 429 response.
+ *
+ * Mirrors `MAX_HINT_SECONDS` in `relay_admission.rs` (Rust). The Rust relay
+ * layer clamps the hint before embedding it in the error string, so in practice
+ * this TS cap is a defence-in-depth guard against any future Rust path that
+ * forgets to clamp, keeping both gates on the same documented bound.
+ */
+export const MAX_HINT_SECONDS = 300;
+
+let expiresAt: number | null = null;
+let gateTimer: number | null = null;
+let gateResolve: (() => void) | null = null;
+let gatePromise: Promise | null = null;
+
+/**
+ * Parse `retry in Ns` from a relay message string.
+ *
+ * Matches the relay's canonical hint format embedded in both CLOSED messages
+ * (`rate-limited: quota exceeded; retry in 4s`) and HTTP 429 bodies
+ * (`relay rate-limited: retry in 4s`).
+ */
+export function parseRateLimitHint(msg: string): number | null {
+ const match = /retry in (\d+)s/i.exec(msg);
+ return match ? parseInt(match[1], 10) : null;
+}
+
+/**
+ * Activate (or extend) the rate-limit gate.
+ *
+ * If the gate is already active, the expiry is pushed forward to the maximum of
+ * the existing expiry and the new hint — overlapping hints never shrink the
+ * window. Non-positive or absent hints use the 10-second default; a 0s gate
+ * would resolve immediately and swallow the signal.
+ *
+ * Note: buzz-acp uses a 5s no-hint default; desktop deliberately uses 10s here
+ * for a wider back-off window on degraded connections.
+ */
+export function activateRateLimit(retryInSeconds: number | null): void {
+ const durationMs =
+ (retryInSeconds != null && retryInSeconds > 0
+ ? Math.min(retryInSeconds, MAX_HINT_SECONDS)
+ : DEFAULT_RATE_LIMIT_SECONDS) * 1_000;
+ const newExpiry = Date.now() + durationMs;
+
+ if (expiresAt !== null && newExpiry <= expiresAt) {
+ // Existing window already covers this hint — nothing to do.
+ return;
+ }
+
+ expiresAt = newExpiry;
+
+ if (gateTimer !== null) {
+ window.clearTimeout(gateTimer);
+ }
+
+ if (gatePromise === null) {
+ // First activation: create the shared promise that waiters will await.
+ gatePromise = new Promise((resolve) => {
+ gateResolve = resolve;
+ });
+ }
+
+ gateTimer = window.setTimeout(() => {
+ gateTimer = null;
+ expiresAt = null;
+ const resolve = gateResolve;
+ gateResolve = null;
+ gatePromise = null;
+ resolve?.();
+ }, durationMs);
+}
+
+/** Returns `true` when the relay has signalled back-pressure and the gate is active. */
+export function isRateLimited(): boolean {
+ return expiresAt !== null && Date.now() < expiresAt;
+}
+
+/**
+ * Resolves when the rate-limit gate expires.
+ *
+ * Resolves immediately if the gate is not active. Callers that need to wait
+ * before issuing a new relay request should await this before proceeding.
+ */
+export function waitForRateLimit(): Promise {
+ if (!isRateLimited() || gatePromise === null) {
+ return Promise.resolve();
+ }
+ return gatePromise;
+}
+
+/**
+ * Returns the milliseconds remaining on the active gate, or 0 when inactive.
+ *
+ * Use this instead of re-deriving the hint from the message so that a shorter
+ * relay hint arriving under a longer active gate never schedules a premature
+ * retry.
+ */
+export function rateLimitRemainingMs(): number {
+ if (expiresAt === null) return 0;
+ return Math.max(0, expiresAt - Date.now());
+}
+
+/**
+ * Reset all gate state. Must be called on community switch so a rate-limit hint
+ * from the old relay does not bleed into the new community's session.
+ *
+ * Any in-flight `waitForRateLimit()` awaiters are resolved immediately so they
+ * do not leak into the new session.
+ */
+export function resetRateLimitGate(): void {
+ if (gateTimer !== null) {
+ window.clearTimeout(gateTimer);
+ gateTimer = null;
+ }
+ expiresAt = null;
+ const resolve = gateResolve;
+ gateResolve = null;
+ gatePromise = null;
+ resolve?.();
+}
diff --git a/desktop/src/shared/api/relayReconnectReplay.test.mjs b/desktop/src/shared/api/relayReconnectReplay.test.mjs
index 63f6402cda..54254e89de 100644
--- a/desktop/src/shared/api/relayReconnectReplay.test.mjs
+++ b/desktop/src/shared/api/relayReconnectReplay.test.mjs
@@ -4,13 +4,61 @@ import test from "node:test";
import {
buildReconnectReplayFilter,
replayLiveSubscriptions,
+ REPLAY_BATCH_SIZE,
} from "./relayReconnectReplay.ts";
import { buildChannelFilter } from "./relayChannelFilters.ts";
-function replayFilter(filter, since, until) {
- return buildReconnectReplayFilter(filter, since, until);
+// ── Fake-timer + Date.now setup for gate tests ────────────────────────────────
+
+let fakeNow = 0;
+const pendingTimers = new Map();
+let nextTimerId = 1;
+
+function fakeSetTimeout(fn, ms) {
+ const id = nextTimerId++;
+ pendingTimers.set(id, { fn, fireAt: fakeNow + ms });
+ return id;
+}
+
+function fakeClearTimeout(id) {
+ pendingTimers.delete(id);
+}
+
+function tickTo(ms) {
+ fakeNow = ms;
+ for (const [id, { fn, fireAt }] of Array.from(pendingTimers.entries())) {
+ if (fireAt <= fakeNow) {
+ pendingTimers.delete(id);
+ fn();
+ }
+ }
}
+globalThis.window = {
+ setTimeout: fakeSetTimeout,
+ clearTimeout: fakeClearTimeout,
+};
+
+const origDateNow = Date.now;
+function setFakeNow(ms) {
+ fakeNow = ms;
+ Date.now = () => fakeNow;
+}
+
+// Import gate module AFTER window shim so it picks up the fake timers.
+const { activateRateLimit, resetRateLimitGate } = await import(
+ "./relayRateLimitGate.ts"
+);
+
+function resetGate(startMs = 0) {
+ pendingTimers.clear();
+ nextTimerId = 1;
+ setFakeNow(startMs);
+ resetRateLimitGate();
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
function event(id, createdAt) {
return {
id,
@@ -29,6 +77,12 @@ function eventRange(prefix, start, count) {
);
}
+function replayFilter(filter, since, until) {
+ return buildReconnectReplayFilter(filter, since, until);
+}
+
+// ── buildReconnectReplayFilter ────────────────────────────────────────────────
+
test("reconnect replay preserves small steady-state limits when adding since", () => {
const filter = {
kinds: [9, 40002],
@@ -102,7 +156,211 @@ test("initial subscription replay preserves the original filter", () => {
assert.equal(replayFilter(filter, undefined), filter);
});
+// ── Batching: REPLAY_BATCH_SIZE cap ──────────────────────────────────────────
+
+test("replay sends all subs in one batch when count equals REPLAY_BATCH_SIZE", async () => {
+ resetGate();
+ let delayCount = 0;
+ const sentIds = [];
+
+ const subscriptions = new Map(
+ Array.from({ length: REPLAY_BATCH_SIZE }, (_, i) => [
+ `sub-${i}`,
+ {
+ mode: "live",
+ filter: { kinds: [9], "#h": [`ch-${i}`], limit: 50 },
+ onEvent: () => {},
+ lastSeenCreatedAt: undefined,
+ },
+ ]),
+ );
+
+ await replayLiveSubscriptions({
+ subscriptions,
+ sendRaw: async (payload) => {
+ sentIds.push(payload[1]);
+ },
+ requestHistory: async () => [],
+ setTimeoutFn: (fn, _ms) => {
+ delayCount++;
+ fn();
+ return 0;
+ },
+ });
+
+ assert.equal(sentIds.length, REPLAY_BATCH_SIZE);
+ assert.equal(delayCount, 0, "no inter-batch delay for exactly one batch");
+});
+
+test("replay splits subscriptions into batches of REPLAY_BATCH_SIZE", async () => {
+ resetGate();
+ let delayCount = 0;
+ const sentIds = [];
+ const batchBreakpoints = []; // indices where a delay fired
+
+ const subCount = REPLAY_BATCH_SIZE + 3;
+ const subscriptions = new Map(
+ Array.from({ length: subCount }, (_, i) => [
+ `sub-${i}`,
+ {
+ mode: "live",
+ filter: { kinds: [9], "#h": [`ch-${i}`], limit: 50 },
+ onEvent: () => {},
+ lastSeenCreatedAt: undefined,
+ },
+ ]),
+ );
+
+ await replayLiveSubscriptions({
+ subscriptions,
+ sendRaw: async (payload) => {
+ sentIds.push(payload[1]);
+ },
+ requestHistory: async () => [],
+ setTimeoutFn: (fn, _ms) => {
+ delayCount++;
+ batchBreakpoints.push(sentIds.length);
+ fn();
+ return 0;
+ },
+ });
+
+ assert.equal(delayCount, 1, "one inter-batch delay between two batches");
+ assert.equal(sentIds.length, subCount, "all subs sent");
+ // The delay fired after the first batch (REPLAY_BATCH_SIZE subs sent).
+ assert.equal(batchBreakpoints[0], REPLAY_BATCH_SIZE);
+});
+
+// ── Visible-channel priority ──────────────────────────────────────────────────
+
+test("visible channel subscription is sent first", async () => {
+ resetGate();
+ const sentOrder = [];
+
+ const subscriptions = new Map([
+ [
+ "other-1",
+ {
+ mode: "live",
+ filter: { kinds: [9], "#h": ["ch-other"], limit: 50 },
+ onEvent: () => {},
+ lastSeenCreatedAt: undefined,
+ },
+ ],
+ [
+ "visible-sub",
+ {
+ mode: "live",
+ filter: { kinds: [9], "#h": ["ch-visible"], limit: 50 },
+ onEvent: () => {},
+ lastSeenCreatedAt: undefined,
+ },
+ ],
+ [
+ "other-2",
+ {
+ mode: "live",
+ filter: { kinds: [9], "#h": ["ch-other2"], limit: 50 },
+ onEvent: () => {},
+ lastSeenCreatedAt: undefined,
+ },
+ ],
+ ]);
+
+ await replayLiveSubscriptions({
+ subscriptions,
+ sendRaw: async (payload) => {
+ sentOrder.push(payload[1]);
+ },
+ requestHistory: async () => [],
+ visibleChannelId: "ch-visible",
+ });
+
+ assert.equal(sentOrder[0], "visible-sub", "visible sub sent first");
+ assert.equal(sentOrder.length, 3);
+});
+
+// ── Rate-limit gate deferral ──────────────────────────────────────────────────
+
+test("replay waits for rate-limit gate before sending REQs", async () => {
+ resetGate(0);
+ activateRateLimit(5); // gate active for 5 seconds
+
+ const sentIds = [];
+
+ const replayPromise = replayLiveSubscriptions({
+ subscriptions: new Map([
+ [
+ "sub-1",
+ {
+ mode: "live",
+ filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
+ onEvent: () => {},
+ lastSeenCreatedAt: undefined,
+ },
+ ],
+ ]),
+ sendRaw: async (payload) => {
+ sentIds.push(payload[1]);
+ },
+ requestHistory: async () => [],
+ setTimeoutFn: (fn, _ms) => {
+ fn();
+ return 0;
+ },
+ });
+
+ // Gate expires — replay should proceed now.
+ tickTo(5_001);
+
+ await replayPromise;
+
+ assert.equal(sentIds.length, 1, "REQ sent after gate expired");
+});
+
+// ── Connection-generation guard ───────────────────────────────────────────────
+
+test("stale replay sends no REQs when generation advances while gate was active", async () => {
+ resetGate(0);
+ activateRateLimit(5); // gate active for 5 seconds
+
+ let generationActive = true; // true = current, false = stale
+ const sentIds = [];
+
+ const replayPromise = replayLiveSubscriptions({
+ subscriptions: new Map([
+ [
+ "sub-1",
+ {
+ mode: "live",
+ filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
+ onEvent: () => {},
+ lastSeenCreatedAt: undefined,
+ },
+ ],
+ ]),
+ sendRaw: async (payload) => {
+ sentIds.push(payload[1]);
+ },
+ requestHistory: async () => [],
+ isActive: () => generationActive,
+ });
+
+ // Advance the generation (simulate new connection) before the gate resolves.
+ generationActive = false;
+
+ // Fire the gate timer.
+ tickTo(5_001);
+
+ await replayPromise;
+
+ assert.equal(sentIds.length, 0, "no REQs sent for a stale replay");
+});
+
+// ── Paged replay (existing behaviour) ────────────────────────────────────────
+
test("channel reconnect replay pages the missed window until a short page", async () => {
+ resetGate();
const delivered = [];
const historyFilters = [];
const sentPayloads = [];
@@ -174,6 +432,7 @@ test("channel reconnect replay pages the missed window until a short page", asyn
});
test("reconnect replay starts live REQs in parallel and preserves per-sub page order", async () => {
+ resetGate();
const sentPayloads = [];
const sendResolvers = [];
const historyFiltersByChannel = {
@@ -250,3 +509,74 @@ test("reconnect replay starts live REQs in parallel and preserves per-sub page o
"channel-2": [2000, 1701],
});
});
+
+// ── Per-batch gate re-check (F2 fix) ─────────────────────────────────────────
+
+test("batch-1 arms gate mid-replay: batch-2 is withheld until gate expires", async () => {
+ // Gate is inactive at the start of replay. Batch 1 fires and (simulating the
+ // relay's admission control) activates the gate. Batch 2 must wait until the
+ // gate clears before its REQs are sent.
+ resetGate(0);
+
+ const BATCH = REPLAY_BATCH_SIZE;
+ const sentAtMs = []; // record the fakeNow when each REQ fires
+
+ // Build BATCH+1 subscriptions so there are exactly two batches.
+ const subscriptions = new Map(
+ Array.from({ length: BATCH + 1 }, (_, i) => [
+ `sub-${i}`,
+ {
+ mode: "live",
+ filter: { kinds: [9], "#h": [`ch-${i}`], limit: 50 },
+ onEvent: () => {},
+ lastSeenCreatedAt: undefined,
+ },
+ ]),
+ );
+
+ let _batchCount = 0;
+ const replayPromise = replayLiveSubscriptions({
+ subscriptions,
+ sendRaw: async (payload) => {
+ sentAtMs.push({ id: payload[1], ms: fakeNow });
+ // After the first full batch is sent, arm the gate for 5 s.
+ // This simulates the relay responding to batch-1 traffic with back-pressure.
+ if (sentAtMs.length === BATCH) {
+ _batchCount += 1;
+ activateRateLimit(5);
+ }
+ },
+ requestHistory: async () => [],
+ setTimeoutFn: (fn, _ms) => {
+ fn();
+ return 0;
+ },
+ });
+
+ // Advance time to expire the gate while the replay is suspended in the
+ // per-batch gate await. This unblocks the second batch.
+ tickTo(5_001);
+
+ await replayPromise;
+
+ const batch1Ids = sentAtMs.filter((r) => r.ms < 5_001).map((r) => r.id);
+ const batch2Ids = sentAtMs.filter((r) => r.ms >= 5_001).map((r) => r.id);
+
+ assert.equal(
+ batch1Ids.length,
+ BATCH,
+ "batch 1 must send exactly REPLAY_BATCH_SIZE REQs",
+ );
+ assert.equal(
+ batch2Ids.length,
+ 1,
+ "batch 2 must send the remaining sub after the gate expires",
+ );
+});
+
+// ── Teardown ──────────────────────────────────────────────────────────────────
+
+test("teardown — restore Date.now", () => {
+ Date.now = origDateNow;
+ assert.ok(true);
+});
diff --git a/desktop/src/shared/api/relayReconnectReplay.ts b/desktop/src/shared/api/relayReconnectReplay.ts
index 65709d351a..74b752f666 100644
--- a/desktop/src/shared/api/relayReconnectReplay.ts
+++ b/desktop/src/shared/api/relayReconnectReplay.ts
@@ -4,11 +4,31 @@ import type {
RelaySubscriptionFilter,
} from "@/shared/api/relayClientShared";
import type { RelayEvent } from "@/shared/api/types";
+import {
+ isRateLimited,
+ waitForRateLimit,
+} from "@/shared/api/relayRateLimitGate";
const RECONNECT_REPLAY_SKEW_SECS = 5;
export const RECONNECT_REPLAY_PAGE_LIMIT = 500;
export const RECONNECT_REPLAY_PAGE_CONCURRENCY = 4;
+/**
+ * Maximum live subscriptions sent per relay REQ burst during reconnect.
+ *
+ * Capping the initial blast prevents admission-control bursts on degraded
+ * networks where the relay is already near its per-pubkey quota.
+ */
+export const REPLAY_BATCH_SIZE = 8;
+
+/**
+ * Delay between consecutive replay batches (milliseconds).
+ *
+ * Spreads the REQ storm across time so the relay's sliding quota window
+ * can absorb each batch without triggering rate-limiting on the next.
+ */
+export const REPLAY_INTER_BATCH_DELAY_MS = 50;
+
async function runWithConcurrency(
items: T[],
concurrency: number,
@@ -104,13 +124,41 @@ export async function replayLiveSubscriptions({
requestHistory,
now = Math.floor(Date.now() / 1_000),
pageReplayConcurrency = RECONNECT_REPLAY_PAGE_CONCURRENCY,
+ visibleChannelId = null,
+ replayBatchSize = REPLAY_BATCH_SIZE,
+ interBatchDelayMs = REPLAY_INTER_BATCH_DELAY_MS,
+ setTimeoutFn = (fn: () => void, ms: number) =>
+ window.setTimeout(fn, ms) as unknown as number,
+ isActive = () => true,
}: {
subscriptions: Map;
sendRaw: (payload: unknown[]) => Promise;
requestHistory: (filter: RelaySubscriptionFilter) => Promise;
now?: number;
pageReplayConcurrency?: number;
+ /** Channel currently visible in the UI — its subscriptions go in the first batch. */
+ visibleChannelId?: string | null;
+ /** Max subscriptions per REQ burst (injectable for tests). */
+ replayBatchSize?: number;
+ /** Milliseconds between bursts (injectable for tests). */
+ interBatchDelayMs?: number;
+ /** setTimeout implementation (injectable for tests). */
+ setTimeoutFn?: (fn: () => void, ms: number) => number;
+ /**
+ * Returns false when the connection that initiated this replay has been
+ * superseded by a newer one. After the gate await resumes, a stale replay
+ * must not double-send REQs on the live socket.
+ */
+ isActive?: () => boolean;
}) {
+ // If the relay has signalled back-pressure, wait for the gate to clear
+ // before blasting a full set of REQs that would immediately be rate-limited.
+ if (isRateLimited()) await waitForRateLimit();
+
+ // A newer connection may have replayed while this one was suspended at the
+ // gate — abort silently to avoid double-sending every REQ on the live socket.
+ if (!isActive()) return;
+
const replayRequests = Array.from(subscriptions.entries())
.filter(
(
@@ -133,9 +181,35 @@ export async function replayLiveSubscriptions({
return { subId, subscription, replaySince, shouldPageReplay };
});
- await Promise.all(
- replayRequests.map(
- ({ subId, subscription, replaySince, shouldPageReplay }) =>
+ // Sort the visible channel's subscriptions first so the user sees their
+ // active channel recover before others on degraded networks.
+ if (visibleChannelId !== null) {
+ replayRequests.sort((a, b) => {
+ const aVis =
+ (a.subscription.filter["#h"] as string[] | undefined)?.includes(
+ visibleChannelId,
+ ) ?? false;
+ const bVis =
+ (b.subscription.filter["#h"] as string[] | undefined)?.includes(
+ visibleChannelId,
+ ) ?? false;
+ if (aVis === bVis) return 0;
+ return aVis ? -1 : 1;
+ });
+ }
+
+ // Send live REQs in capped batches with inter-batch delays to avoid
+ // triggering per-pubkey admission control on degraded/recovering connections.
+ for (let i = 0; i < replayRequests.length; i += replayBatchSize) {
+ // Re-check the gate before every batch: a previous batch may have triggered
+ // admission control and armed the gate mid-replay. Wait for it to clear,
+ // then verify the connection is still current — a newer connection may have
+ // replayed while we were suspended.
+ if (isRateLimited()) await waitForRateLimit();
+ if (!isActive()) return;
+ const batch = replayRequests.slice(i, i + replayBatchSize);
+ await Promise.all(
+ batch.map(({ subId, subscription, replaySince, shouldPageReplay }) =>
sendRaw([
"REQ",
subId,
@@ -143,8 +217,14 @@ export async function replayLiveSubscriptions({
? subscription.filter
: buildReconnectReplayFilter(subscription.filter, replaySince),
]),
- ),
- );
+ ),
+ );
+ if (i + replayBatchSize < replayRequests.length) {
+ await new Promise((resolve) =>
+ setTimeoutFn(resolve, interBatchDelayMs),
+ );
+ }
+ }
await runWithConcurrency(
replayRequests.filter(
diff --git a/desktop/src/shared/api/tauri.test.mjs b/desktop/src/shared/api/tauri.test.mjs
new file mode 100644
index 0000000000..68205d7298
--- /dev/null
+++ b/desktop/src/shared/api/tauri.test.mjs
@@ -0,0 +1,120 @@
+/**
+ * Unit tests for tauri.ts — focused on `applyTauriRateLimitIfNeeded`, the
+ * extracted `relay rate-limited:` classifier that activates the shared
+ * rate-limit gate when Rust emits an HTTP 429 error prefix.
+ *
+ * Testing the exported production function (not a local copy) ensures any
+ * change to the classifier logic is immediately covered here.
+ */
+import assert from "node:assert/strict";
+import test from "node:test";
+
+// ── Fake-timer + gate setup ───────────────────────────────────────────────────
+
+let fakeNow = 0;
+const pendingTimers = new Map();
+let nextTimerId = 1;
+
+function fakeSetTimeout(fn, ms) {
+ const id = nextTimerId++;
+ pendingTimers.set(id, { fn, fireAt: fakeNow + ms });
+ return id;
+}
+
+function fakeClearTimeout(id) {
+ pendingTimers.delete(id);
+}
+
+function tickTo(ms) {
+ fakeNow = ms;
+ for (const [id, { fn, fireAt }] of Array.from(pendingTimers.entries())) {
+ if (fireAt <= fakeNow) {
+ pendingTimers.delete(id);
+ fn();
+ }
+ }
+}
+
+const origDateNow = Date.now;
+function setFakeNow(ms) {
+ fakeNow = ms;
+ Date.now = () => fakeNow;
+}
+
+globalThis.window = {
+ setTimeout: fakeSetTimeout,
+ clearTimeout: fakeClearTimeout,
+};
+
+setFakeNow(0);
+
+const { isRateLimited, resetRateLimitGate } = await import(
+ "./relayRateLimitGate.ts"
+);
+
+// Import the production classifier from tauri.ts — tests must exercise the
+// real function, not a local copy, so a logic change is always caught here.
+const { applyTauriRateLimitIfNeeded } = await import("./tauri.ts");
+
+function resetGate(startMs = 0) {
+ pendingTimers.clear();
+ nextTimerId = 1;
+ setFakeNow(startMs);
+ resetRateLimitGate();
+}
+
+// ── applyTauriRateLimitIfNeeded: relay rate-limited: prefix ───────────────────
+
+test("relay rate-limited: prefix activates the rate-limit gate", () => {
+ resetGate(0);
+ applyTauriRateLimitIfNeeded("relay rate-limited: retry in 10s");
+ assert.equal(isRateLimited(), true, "gate must be active after 429 error");
+});
+
+test("relay rate-limited: prefix parses the retry hint and arms the gate duration", () => {
+ resetGate(0);
+ applyTauriRateLimitIfNeeded("relay rate-limited: retry in 7s");
+ // Gate should be active at 6s.
+ setFakeNow(6_000);
+ assert.equal(isRateLimited(), true);
+ // Gate should expire after 7s.
+ tickTo(7_001);
+ assert.equal(isRateLimited(), false);
+});
+
+test("relay rate-limited: with no hint uses the 10s default", () => {
+ resetGate(0);
+ applyTauriRateLimitIfNeeded("relay rate-limited: quota exceeded");
+ tickTo(9_999);
+ assert.equal(isRateLimited(), true);
+ tickTo(10_001);
+ assert.equal(isRateLimited(), false);
+});
+
+test("non-rate-limited error does not activate the gate", () => {
+ resetGate(0);
+ applyTauriRateLimitIfNeeded("relay returned 404 Not Found");
+ assert.equal(
+ isRateLimited(),
+ false,
+ "gate must remain inactive for unrelated errors",
+ );
+});
+
+test("relay rate-limited: prefix check is case-sensitive (Rust always emits lowercase)", () => {
+ resetGate(0);
+ // The prefix from Rust is always lowercase; mixed-case must not trigger it.
+ applyTauriRateLimitIfNeeded("Relay rate-limited: retry in 5s");
+ assert.equal(
+ isRateLimited(),
+ false,
+ "uppercase prefix must not activate gate (relay emits lowercase only)",
+ );
+});
+
+// ── Teardown ──────────────────────────────────────────────────────────────────
+
+test("teardown — restore Date.now", () => {
+ Date.now = origDateNow;
+ assert.ok(true);
+});
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts
index 46819bb906..add41deb24 100644
--- a/desktop/src/shared/api/tauri.ts
+++ b/desktop/src/shared/api/tauri.ts
@@ -1,4 +1,8 @@
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
+import {
+ activateRateLimit,
+ parseRateLimitHint,
+} from "@/shared/api/relayRateLimitGate";
import type {
AddChannelMembersInput,
AddChannelMembersResult,
@@ -280,6 +284,18 @@ function toTauriError(error: unknown): Error {
}
}
+/**
+ * Inspect a Tauri error message and activate the shared rate-limit gate when
+ * the Rust relay layer emitted an HTTP 429 response (`relay rate-limited:` prefix).
+ *
+ * Extracted so it can be unit-tested without mocking the Tauri invoke bridge.
+ */
+export function applyTauriRateLimitIfNeeded(message: string): void {
+ if (message.startsWith("relay rate-limited:")) {
+ activateRateLimit(parseRateLimitHint(message));
+ }
+}
+
export async function invokeTauri(
command: string,
args?: Record,
@@ -287,7 +303,11 @@ export async function invokeTauri(
try {
return await tauriInvoke(command, args);
} catch (error) {
- throw toTauriError(error);
+ const err = toTauriError(error);
+ // Rust emits `relay rate-limited:` for HTTP 429 responses. Activate the
+ // shared gate so the TS relay client backs off for the same window.
+ applyTauriRateLimitIfNeeded(err.message);
+ throw err;
}
}
diff --git a/desktop/src/shared/api/useRelayAutoHeal.ts b/desktop/src/shared/api/useRelayAutoHeal.ts
index af0cbe55a7..11909228b8 100644
--- a/desktop/src/shared/api/useRelayAutoHeal.ts
+++ b/desktop/src/shared/api/useRelayAutoHeal.ts
@@ -8,6 +8,10 @@ import {
isRelayConnectionDegraded,
useRelayConnection,
} from "@/shared/api/useRelayConnection";
+import {
+ isRateLimited,
+ waitForRateLimit,
+} from "@/shared/api/relayRateLimitGate";
export const AUTO_HEAL_MIN_INTERVAL_MS = 15_000;
@@ -102,10 +106,22 @@ export function useRelayAutoHeal(): void {
if (schedulerRef.current === null) {
schedulerRef.current = new RelayAutoHealScheduler(
- () =>
- void queryClient.invalidateQueries({
- predicate: isRelayDependentQuery,
- }),
+ () => {
+ if (isRateLimited()) {
+ // Connection recovered but the relay is still under back-pressure.
+ // Defer the invalidate until the rate-limit window clears so queries
+ // don't immediately refetch and generate another burst.
+ void waitForRateLimit().then(() => {
+ void queryClient.invalidateQueries({
+ predicate: isRelayDependentQuery,
+ });
+ });
+ } else {
+ void queryClient.invalidateQueries({
+ predicate: isRelayDependentQuery,
+ });
+ }
+ },
AUTO_HEAL_MIN_INTERVAL_MS,
window.setTimeout.bind(window),
window.clearTimeout.bind(window),