fix(desktop): survive degraded networks with rate-limit-aware relay client (#2197)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-21 12:18:20 -04:00
committed by GitHub
parent 7b25950fca
commit 258cc92064
29 changed files with 2430 additions and 96 deletions

View File

@@ -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. `<AppReady key={workspaceKey} />` 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. `<AppReady key={communityKey} />` 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
- `resetMediaCaches()` — proxy port and relay origin caches
- `clearSearchHitEventCache()` — search result event cache
- `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
- `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`)
---

View File

@@ -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.

View File

@@ -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"] }

View File

@@ -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

View File

@@ -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

View File

@@ -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())?;

View File

@@ -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}");

View File

@@ -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;

View File

@@ -231,6 +231,17 @@ pub(crate) async fn parse_json_response<T: DeserializeOwned>(
.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<u64> {
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::<u64>().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::<serde_json::Value>(&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<Vec<nostr::Event>, 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<SubmitEventResponse, String> {
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<SubmitEventResponse, 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 = {
@@ -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]

View File

@@ -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<Option<Instant>> = 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<u64>) {
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 <base64>".
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();
}
}

View File

@@ -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();

View File

@@ -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,
});
}

View File

@@ -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;

View File

@@ -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<PresenceLookup>({
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);

View File

@@ -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[];

View File

@@ -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);
}

View File

@@ -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<unknown> | 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<RelayEvent[]>((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<RelayEvent[]> {
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<RelayEvent>((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 = [];

View File

@@ -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);
}
});

View File

@@ -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";
}

View File

@@ -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);
});

View File

@@ -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;

View File

@@ -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<string, RelaySubscription>,
sendRaw: (payload: unknown[]) => Promise<void>,
closeSubscription: (subId: string) => Promise<void>,
filter: RelaySubscriptionFilter,
historyTimeoutMs: number,
): Promise<RelayEvent[]> {
// Await the gate before issuing REQ; op timeout starts after the wait.
await waitForRateLimit();
return new Promise<RelayEvent[]>((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."),
);
});
});
}

View File

@@ -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);
});

View File

@@ -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<void> | 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<void>((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<void> {
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?.();
}

View File

@@ -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);
});

View File

@@ -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<T>(
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<string, RelaySubscription>;
sendRaw: (payload: unknown[]) => Promise<void>;
requestHistory: (filter: RelaySubscriptionFilter) => Promise<RelayEvent[]>;
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<void>((resolve) =>
setTimeoutFn(resolve, interBatchDelayMs),
);
}
}
await runWithConcurrency(
replayRequests.filter(

View File

@@ -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);
});

View File

@@ -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<T>(
command: string,
args?: Record<string, unknown>,
@@ -287,7 +303,11 @@ export async function invokeTauri<T>(
try {
return await tauriInvoke<T>(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;
}
}

View File

@@ -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),