mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): scope relay backoff by principal
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -263,7 +263,8 @@ pub async fn get_thread_replies(
|
||||
cursor.as_ref(),
|
||||
);
|
||||
|
||||
let events = query_relay(&state, &[serde_json::Value::Object(filter)]).await?;
|
||||
let events =
|
||||
crate::relay::query_relay_interactive(&state, &[serde_json::Value::Object(filter)]).await?;
|
||||
|
||||
// A full page implies there may be more; hand back the last event's
|
||||
// composite key as the next cursor (the DB returns replies strictly after
|
||||
|
||||
@@ -815,7 +815,8 @@ pub(crate) async fn submit_engram_event(
|
||||
// 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 principal = agent_keys.public_key().to_hex();
|
||||
crate::relay_admission::wait_for_rate_limit_for(&principal).await;
|
||||
let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, url, event_json)?;
|
||||
let mut request = state
|
||||
.http_client
|
||||
@@ -832,7 +833,7 @@ pub(crate) async fn submit_engram_event(
|
||||
.map_err(|e| crate::relay::classify_request_error(&e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let msg = crate::relay::relay_error_message(response).await;
|
||||
let msg = crate::relay::relay_error_message_for(response, &principal).await;
|
||||
return Err(format!("relay rejected engram: {msg}"));
|
||||
}
|
||||
|
||||
|
||||
@@ -910,7 +910,8 @@ pub(crate) async fn submit_engram_event(
|
||||
// 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 principal = agent_keys.public_key().to_hex();
|
||||
crate::relay_admission::wait_for_rate_limit_for(&principal).await;
|
||||
let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, url, event_json)?;
|
||||
let mut request = state
|
||||
.http_client
|
||||
@@ -927,7 +928,7 @@ pub(crate) async fn submit_engram_event(
|
||||
.map_err(|e| crate::relay::classify_request_error(&e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let msg = crate::relay::relay_error_message(response).await;
|
||||
let msg = crate::relay::relay_error_message_for(response, &principal).await;
|
||||
return Err(format!("relay rejected engram: {msg}"));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,25 +4,19 @@ use reqwest::Method;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// nostr 0.36 alias — required for cross-version bridging with buzz-sdk.
|
||||
|
||||
use crate::app_state::AppState;
|
||||
|
||||
const DEFAULT_RELAY_WS_URL: &str = "ws://localhost:3000";
|
||||
|
||||
// A reached-but-malformed 2xx body is NOT a connectivity failure, so this
|
||||
// message must never carry the "relay unreachable:" prefix the frontend
|
||||
// classifier keys on. Extracted to a const so a test can pin that contract.
|
||||
const MALFORMED_RESPONSE_MESSAGE: &str = "relay returned malformed response: not valid JSON";
|
||||
|
||||
fn configured_env_var(name: &str) -> Option<String> {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub fn relay_ws_url() -> String {
|
||||
configured_env_var("BUZZ_RELAY_URL")
|
||||
.or_else(|| option_env!("BUZZ_DESKTOP_BUILD_RELAY_URL").map(str::to_string))
|
||||
@@ -242,10 +236,18 @@ fn extract_retry_in_hint(body: &str) -> Option<u64> {
|
||||
}
|
||||
|
||||
pub async fn relay_error_message(response: reqwest::Response) -> String {
|
||||
relay_error_message_for(response, crate::relay_admission::WORKSPACE_PRINCIPAL).await
|
||||
}
|
||||
|
||||
pub(crate) async fn relay_error_message_for(
|
||||
response: reqwest::Response,
|
||||
principal: &str,
|
||||
) -> String {
|
||||
let status = response.status();
|
||||
|
||||
// Check for intercepted/proxy responses before reading the body.
|
||||
let final_host = response.url().host_str().unwrap_or("").to_string();
|
||||
let endpoint = response.url().path().to_string();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
@@ -274,7 +276,7 @@ pub async fn relay_error_message(response: reqwest::Response) -> String {
|
||||
// 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);
|
||||
crate::relay_admission::activate_rate_limit_for_endpoint(principal, &endpoint, capped_hint);
|
||||
if let Some(secs) = capped_hint {
|
||||
return format!("relay rate-limited: retry in {secs}s");
|
||||
}
|
||||
@@ -318,6 +320,14 @@ pub async fn query_relay_at(
|
||||
filters: &[serde_json::Value],
|
||||
) -> Result<Vec<nostr::Event>, String> {
|
||||
crate::relay_admission::wait_for_rate_limit().await;
|
||||
query_relay_at_after_admission(state, api_base_url, filters).await
|
||||
}
|
||||
|
||||
pub(super) async fn query_relay_at_after_admission(
|
||||
state: &AppState,
|
||||
api_base_url: &str,
|
||||
filters: &[serde_json::Value],
|
||||
) -> Result<Vec<nostr::Event>, String> {
|
||||
let url = format!("{}/query", api_base_url);
|
||||
let body_bytes =
|
||||
serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?;
|
||||
@@ -347,7 +357,8 @@ pub async fn query_relay_at_with_keys(
|
||||
keys: &Keys,
|
||||
auth_tag: Option<&str>,
|
||||
) -> Result<Vec<nostr::Event>, String> {
|
||||
crate::relay_admission::wait_for_rate_limit().await;
|
||||
let principal = keys.public_key().to_hex();
|
||||
crate::relay_admission::wait_for_rate_limit_for(&principal).await;
|
||||
let url = format!("{}/query", api_base_url);
|
||||
let body_bytes =
|
||||
serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?;
|
||||
@@ -366,7 +377,7 @@ pub async fn query_relay_at_with_keys(
|
||||
.await
|
||||
.map_err(|e| classify_request_error(&e))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(relay_error_message(response).await);
|
||||
return Err(relay_error_message_for(response, &principal).await);
|
||||
}
|
||||
parse_json_response(response).await
|
||||
}
|
||||
@@ -445,7 +456,8 @@ 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;
|
||||
let principal = agent_keys.public_key().to_hex();
|
||||
crate::relay_admission::wait_for_rate_limit_for(&principal).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();
|
||||
@@ -470,7 +482,7 @@ pub async fn sync_managed_agent_profile(
|
||||
.map_err(|e| classify_request_error(&e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let msg = relay_error_message(response).await;
|
||||
let msg = relay_error_message_for(response, &principal).await;
|
||||
return Err(format!(
|
||||
"Could not sync the agent's profile metadata: {msg}"
|
||||
));
|
||||
@@ -532,6 +544,8 @@ pub struct AgentProfileInfo {
|
||||
|
||||
// ── Signed-event submission ─────────────────────────────────────────────────
|
||||
|
||||
mod interactive;
|
||||
pub use interactive::query_relay_interactive;
|
||||
mod submit;
|
||||
pub use submit::{
|
||||
submit_event, submit_event_at_with_keys, submit_signed_event_at_with_keys, SubmitEventResponse,
|
||||
@@ -564,7 +578,8 @@ 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 principal = keys.public_key().to_hex();
|
||||
crate::relay_admission::wait_for_rate_limit_for(&principal).await;
|
||||
let url = format!("{}/events", relay_api_base_url_with_override(state));
|
||||
let body_bytes = event.as_json().into_bytes();
|
||||
crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "signed event submit (keys)")?;
|
||||
@@ -586,7 +601,7 @@ pub async fn submit_signed_event_with_keys(
|
||||
.map_err(|e| classify_request_error(&e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(relay_error_message(response).await);
|
||||
return Err(relay_error_message_for(response, &principal).await);
|
||||
}
|
||||
|
||||
let result: SubmitEventResponse = parse_json_response(response).await?;
|
||||
@@ -646,7 +661,7 @@ mod tests {
|
||||
// ── 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
|
||||
// string, not just inside `activate_rate_limit_for()`. This guarantees every
|
||||
// consumer — including the TS gate via `applyTauriRateLimitIfNeeded` —
|
||||
// receives the capped value rather than the raw untrusted relay value.
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
use super::{query_relay_at_after_admission, relay_api_base_url_with_override};
|
||||
use crate::app_state::AppState;
|
||||
|
||||
/// Execute an owner-authenticated relay query for a user-facing surface.
|
||||
/// Unlike background work, this returns after a short admission wait so the UI
|
||||
/// can show relay back-pressure rather than remaining pending for minutes.
|
||||
pub async fn query_relay_interactive(
|
||||
state: &AppState,
|
||||
filters: &[serde_json::Value],
|
||||
) -> Result<Vec<nostr::Event>, String> {
|
||||
crate::relay_admission::wait_for_interactive_rate_limit().await?;
|
||||
query_relay_at_after_admission(state, &relay_api_base_url_with_override(state), filters).await
|
||||
}
|
||||
@@ -22,7 +22,8 @@ pub async fn submit_signed_event_at_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 principal = keys.public_key().to_hex();
|
||||
crate::relay_admission::wait_for_rate_limit_for(&principal).await;
|
||||
let url = format!("{}/events", api_base_url.trim_end_matches('/'));
|
||||
let body_bytes = event.as_json().into_bytes();
|
||||
crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?;
|
||||
@@ -39,7 +40,7 @@ pub async fn submit_signed_event_at_with_keys(
|
||||
.map_err(|e| classify_request_error(&e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(relay_error_message(response).await);
|
||||
return Err(super::relay_error_message_for(response, &principal).await);
|
||||
}
|
||||
|
||||
let result: SubmitEventResponse = parse_json_response(response).await?;
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
//! 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.
|
||||
//! Relay quotas are scoped to the authenticated principal. When the relay
|
||||
//! answers 429, requests authenticated as that same principal must hold new
|
||||
//! sends until its quota window clears — matching the TS-side gate in
|
||||
//! `relayRateLimitGate.ts` that already governs owner 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()`.
|
||||
//! **Coverage:** owner-authenticated entry points use `wait_for_rate_limit()`;
|
||||
//! explicit-key entry points use `wait_for_rate_limit_for(pubkey)`. Every 429
|
||||
//! must arm the gate under the same principal used by its request.
|
||||
//!
|
||||
//! **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.
|
||||
//! **Media upload/download and `/info`** are owner-authenticated and call
|
||||
//! `relay_error_message()` on non-200 responses, so their 429s arm the owner
|
||||
//! gate as conservative back-off. They do not wait themselves because their
|
||||
//! operations are user-initiated file transfers with independent retry logic.
|
||||
//!
|
||||
//! **Community scope:** the gate is reset on every `apply_workspace` call,
|
||||
//! mirroring the TS gate's `resetRateLimitGate()` on community switch in
|
||||
//! **Community scope:** all principal gates are 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.
|
||||
//! Mirrors the TS gate's semantics: overlapping hints never shrink a
|
||||
//! principal's window, and a hint-less 429 arms the same 10-second default.
|
||||
|
||||
use std::sync::Mutex;
|
||||
use tokio::time::{sleep_until, Duration, Instant};
|
||||
use std::{collections::HashMap, sync::Mutex};
|
||||
use tokio::time::{sleep_until, timeout, Duration, Instant};
|
||||
|
||||
/// Interactive reads should surface relay back-pressure instead of leaving a
|
||||
/// panel apparently empty for the relay's full quota window.
|
||||
const INTERACTIVE_WAIT_LIMIT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Minimum gate duration when the relay provides no `retry in Ns` hint.
|
||||
/// Deliberately equal to `DEFAULT_RATE_LIMIT_SECONDS` in `relayRateLimitGate.ts`
|
||||
@@ -40,9 +41,25 @@ const DEFAULT_RATE_LIMIT_SECONDS: u64 = 10;
|
||||
/// `applyTauriRateLimitIfNeeded`) sees the same capped value.
|
||||
pub const MAX_HINT_SECONDS: u64 = 300;
|
||||
|
||||
static GATE_EXPIRY: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
static GATE_EXPIRIES: std::sync::LazyLock<Mutex<HashMap<String, Instant>>> =
|
||||
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
// The gate is process-wide, so every test that can arm it must serialize.
|
||||
fn principal_key(principal: &str) -> String {
|
||||
principal.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
pub(crate) const WORKSPACE_PRINCIPAL: &str = "workspace-owner";
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn activate_rate_limit(retry_in_seconds: Option<u64>) {
|
||||
activate_rate_limit_for(WORKSPACE_PRINCIPAL, retry_in_seconds);
|
||||
}
|
||||
|
||||
pub async fn wait_for_rate_limit() {
|
||||
wait_for_rate_limit_for(WORKSPACE_PRINCIPAL).await;
|
||||
}
|
||||
|
||||
// The principal map is process-wide, so every test that can arm it must serialize.
|
||||
#[cfg(test)]
|
||||
pub(crate) static TEST_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
@@ -53,7 +70,16 @@ pub(crate) static TEST_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::cons
|
||||
/// `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>) {
|
||||
#[cfg(test)]
|
||||
pub fn activate_rate_limit_for(principal: &str, retry_in_seconds: Option<u64>) {
|
||||
activate_rate_limit_for_endpoint(principal, "unknown", retry_in_seconds);
|
||||
}
|
||||
|
||||
pub fn activate_rate_limit_for_endpoint(
|
||||
principal: &str,
|
||||
endpoint: &str,
|
||||
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,
|
||||
@@ -61,24 +87,37 @@ pub fn activate_rate_limit(retry_in_seconds: Option<u64>) {
|
||||
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),
|
||||
let mut guard = GATE_EXPIRIES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let expiry = guard.entry(principal_key(principal)).or_insert(new_expiry);
|
||||
if new_expiry > *expiry {
|
||||
*expiry = new_expiry;
|
||||
}
|
||||
eprintln!(
|
||||
"buzz-desktop: relay rate-limit gate armed: endpoint={endpoint} principal={} retry_in_seconds={secs}",
|
||||
if principal == WORKSPACE_PRINCIPAL {
|
||||
"workspace-owner"
|
||||
} else {
|
||||
"explicit-key"
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Wait until the admission gate is clear.
|
||||
/// Wait until this relay principal's 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() {
|
||||
pub async fn wait_for_rate_limit_for(principal: &str) {
|
||||
let principal = principal_key(principal);
|
||||
loop {
|
||||
let expiry = {
|
||||
let guard = GATE_EXPIRY.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match *guard {
|
||||
let mut guard = GATE_EXPIRIES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match guard.get(&principal).copied() {
|
||||
Some(expiry) if expiry > Instant::now() => Some(expiry),
|
||||
_ => None,
|
||||
Some(_) => {
|
||||
guard.remove(&principal);
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
match expiry {
|
||||
@@ -88,27 +127,58 @@ pub async fn wait_for_rate_limit() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait briefly for the workspace owner's gate, then return a typed error so
|
||||
/// an interactive surface can render back-pressure instead of hanging.
|
||||
pub async fn wait_for_interactive_rate_limit() -> Result<(), String> {
|
||||
match timeout(INTERACTIVE_WAIT_LIMIT, wait_for_rate_limit()).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => {
|
||||
let remaining = remaining_seconds_for(WORKSPACE_PRINCIPAL).unwrap_or_default();
|
||||
Err(format!(
|
||||
"relay rate-limited: retry in {remaining}s; try again shortly"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remaining_seconds_for(principal: &str) -> Option<u64> {
|
||||
let principal = principal_key(principal);
|
||||
let now = Instant::now();
|
||||
let mut guard = GATE_EXPIRIES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match guard.get(&principal).copied() {
|
||||
Some(expiry) if expiry > now => Some((expiry - now).as_secs().max(1)),
|
||||
Some(_) => {
|
||||
guard.remove(&principal);
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
GATE_EXPIRIES
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clear();
|
||||
}
|
||||
|
||||
/// 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;
|
||||
reset_gate_for_workspace_change();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// The gate is a process-wide static shared by every test in this binary,
|
||||
// The principal map is a process-wide static shared by every test in this binary,
|
||||
// so all tests that arm it serialize on one async lock to keep expiries
|
||||
// from bleeding between parallel test threads.
|
||||
|
||||
@@ -125,6 +195,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn interactive_wait_returns_typed_error_after_five_seconds() {
|
||||
let _serial = TEST_SERIAL.lock().await;
|
||||
reset_rate_limit_gate();
|
||||
activate_rate_limit(Some(60));
|
||||
|
||||
let start = Instant::now();
|
||||
let error = wait_for_interactive_rate_limit().await.unwrap_err();
|
||||
|
||||
assert_eq!(Instant::now() - start, INTERACTIVE_WAIT_LIMIT);
|
||||
assert_eq!(error, "relay rate-limited: retry in 55s; try again shortly");
|
||||
reset_rate_limit_gate();
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn interactive_wait_succeeds_when_gate_clears_within_limit() {
|
||||
let _serial = TEST_SERIAL.lock().await;
|
||||
reset_rate_limit_gate();
|
||||
activate_rate_limit(Some(2));
|
||||
|
||||
let start = Instant::now();
|
||||
wait_for_interactive_rate_limit().await.unwrap();
|
||||
|
||||
assert_eq!(Instant::now() - start, Duration::from_secs(2));
|
||||
reset_rate_limit_gate();
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn hintless_429_arms_the_ten_second_default() {
|
||||
let _serial = TEST_SERIAL.lock().await;
|
||||
@@ -257,6 +354,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn agent_gate_does_not_block_workspace_owner_reads() {
|
||||
let _serial = TEST_SERIAL.lock().await;
|
||||
reset_rate_limit_gate();
|
||||
|
||||
activate_rate_limit_for("agent-pubkey", Some(60));
|
||||
let start = Instant::now();
|
||||
wait_for_rate_limit().await;
|
||||
|
||||
assert_eq!(
|
||||
Instant::now(),
|
||||
start,
|
||||
"an agent's HTTP quota window must not park owner-authenticated thread reads"
|
||||
);
|
||||
reset_rate_limit_gate();
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn same_principal_still_waits_for_its_gate() {
|
||||
let _serial = TEST_SERIAL.lock().await;
|
||||
reset_rate_limit_gate();
|
||||
|
||||
activate_rate_limit_for("agent-pubkey", Some(5));
|
||||
let start = Instant::now();
|
||||
wait_for_rate_limit_for("AGENT-PUBKEY").await;
|
||||
|
||||
assert_eq!(Instant::now() - start, Duration::from_secs(5));
|
||||
reset_rate_limit_gate();
|
||||
}
|
||||
|
||||
/// A 429 on one admission-gated path withholds sends on a different path
|
||||
/// until the hinted window expires.
|
||||
///
|
||||
@@ -323,7 +450,7 @@ mod tests {
|
||||
/// 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
|
||||
/// A's expiry; the reset clears `GATE_EXPIRIES` 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.
|
||||
|
||||
@@ -150,7 +150,9 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
threadAllMessages,
|
||||
threadHeadMessage,
|
||||
threadMessages,
|
||||
threadMessagesError = null,
|
||||
threadMessagesPending = false,
|
||||
onRetryThreadMessages,
|
||||
threadPanelWidthPx,
|
||||
threadScrollTargetId,
|
||||
threadTypingPubkeys,
|
||||
@@ -817,7 +819,9 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
videoReviewPresentation={threadVideoReviewPresentation}
|
||||
widthPx={threadPanelWidthPx}
|
||||
threadReplies={threadMessages}
|
||||
threadRepliesError={threadMessagesError}
|
||||
threadRepliesPending={threadMessagesPending}
|
||||
onRetryThreadReplies={onRetryThreadMessages}
|
||||
threadUnreadCount={threadUnreadCounts?.get(
|
||||
threadHeadMessage.id,
|
||||
)}
|
||||
|
||||
@@ -160,7 +160,9 @@ export type ChannelPaneProps = {
|
||||
threadHeadMessage: TimelineMessage | null;
|
||||
threadAllMessages: TimelineMessage[];
|
||||
threadMessages: MainTimelineEntry[];
|
||||
threadMessagesError?: string | null;
|
||||
threadMessagesPending?: boolean;
|
||||
onRetryThreadMessages?: () => void;
|
||||
threadPanelWidthPx: number;
|
||||
threadTypingPubkeys: string[];
|
||||
threadReplyTargetMessage: TimelineMessage | null;
|
||||
|
||||
@@ -944,7 +944,15 @@ export function ChannelScreen({
|
||||
threadAllMessages={displayedThreadAllMessages}
|
||||
threadHeadMessage={displayedThreadHeadMessage}
|
||||
threadMessages={displayedThreadMessages}
|
||||
threadMessagesError={
|
||||
threadRepliesQuery.error instanceof Error
|
||||
? threadRepliesQuery.error.message
|
||||
: null
|
||||
}
|
||||
threadMessagesPending={threadRepliesQuery.isPending}
|
||||
onRetryThreadMessages={() => {
|
||||
void threadRepliesQuery.refetch();
|
||||
}}
|
||||
threadPanelWidthPx={threadPanelWidthPx}
|
||||
threadTypingPubkeys={threadTypingPubkeys}
|
||||
threadReplyTargetMessage={displayedThreadReplyTargetMessage}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
|
||||
import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys";
|
||||
import { HuddleTranscriptIntro } from "@/features/huddle/components/HuddleTranscriptIntro";
|
||||
import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys";
|
||||
@@ -43,6 +42,7 @@ import { Separator } from "@/shared/ui/separator";
|
||||
import { ComposerActivityAccessory } from "./ComposerActivityAccessory";
|
||||
import { ComposerDockBackdrop } from "./ComposerDockBackdrop";
|
||||
import { MessageComposer } from "./MessageComposer";
|
||||
import { ThreadRepliesLoadState } from "./ThreadRepliesLoadState";
|
||||
import { ThreadMessageSkeleton } from "./MessageThreadPanelSkeleton";
|
||||
import { MessageRow, type ThreadDepthGuideAction } from "./MessageRow";
|
||||
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
||||
@@ -52,7 +52,6 @@ import { useComposerHeightPadding } from "./useComposerHeightPadding";
|
||||
import { useStableSendToChannel } from "./useStableSendToChannel";
|
||||
import { useAnchoredScroll } from "./useAnchoredScroll";
|
||||
import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot";
|
||||
|
||||
type MessageThreadPanelProps = ThreadPanelLayoutProps & {
|
||||
channel: Channel | null;
|
||||
channelId: string | null;
|
||||
@@ -109,7 +108,9 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
|
||||
scrollTargetId: string | null;
|
||||
threadHead: TimelineMessage | null;
|
||||
threadReplies: MainTimelineEntry[];
|
||||
threadRepliesError?: string | null;
|
||||
threadRepliesPending?: boolean;
|
||||
onRetryThreadReplies?: () => void;
|
||||
threadUnreadCount?: number;
|
||||
threadReplyUnreadCounts?: ReadonlyMap<string, number>;
|
||||
threadTypingPubkeys: string[];
|
||||
@@ -131,10 +132,8 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
|
||||
/** Called when the thread-composer auto-submit fires so the parent can clear the trigger. */
|
||||
onAutoSubmitComplete?: () => void;
|
||||
};
|
||||
|
||||
const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = [];
|
||||
const THREAD_PANEL_SUMMARY_INDENT_OFFSET_REM = 0;
|
||||
|
||||
function hasLaterVisibleSibling(
|
||||
entries: readonly MainTimelineEntry[],
|
||||
entryIndex: number,
|
||||
@@ -185,7 +184,6 @@ function getActiveContinuationDepths({
|
||||
|
||||
return depths;
|
||||
}
|
||||
|
||||
export function MessageThreadPanel({
|
||||
channel,
|
||||
channelId,
|
||||
@@ -230,7 +228,9 @@ export function MessageThreadPanel({
|
||||
threadHead,
|
||||
videoReviewPresentation,
|
||||
threadReplies,
|
||||
threadRepliesError = null,
|
||||
threadRepliesPending = false,
|
||||
onRetryThreadReplies,
|
||||
threadUnreadCount,
|
||||
threadReplyUnreadCounts,
|
||||
threadTypingPubkeys,
|
||||
@@ -647,7 +647,12 @@ export function MessageThreadPanel({
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-0")}
|
||||
data-testid="message-thread-replies"
|
||||
>
|
||||
{threadRepliesPending && !isHuddleTranscript ? (
|
||||
{threadRepliesError && !isHuddleTranscript ? (
|
||||
<ThreadRepliesLoadState
|
||||
error={threadRepliesError}
|
||||
onRetry={onRetryThreadReplies}
|
||||
/>
|
||||
) : threadRepliesPending && !isHuddleTranscript ? (
|
||||
<div
|
||||
className="space-y-2.5 pt-1"
|
||||
data-testid="message-thread-replies-loading"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
export function ThreadRepliesLoadState({
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
error: string;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm"
|
||||
data-testid="message-thread-replies-error"
|
||||
role="alert"
|
||||
>
|
||||
<p>{error}</p>
|
||||
{onRetry ? (
|
||||
<Button className="mt-2" onClick={onRetry} size="sm" variant="outline">
|
||||
Try again
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user