Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1351,7 +1351,12 @@ async fn tokio_main() -> Result<()> {
tracing::warn!("failed to set startup watermark: {e}");
}

tracing::info!("connected to relay at {}", config.relay_url);
tracing::info!(
target: "buzz_acp::runtime_lifecycle",
runtime_state = "transport_authenticated",
relay_url = %config.relay_url,
"relay transport authenticated; runtime is not ready until discovery and subscriptions finish"
);

relay
.subscribe_membership_notifications()
Expand Down Expand Up @@ -1426,10 +1431,21 @@ async fn tokio_main() -> Result<()> {
}
}

let channel_info_map = relay
.discover_channels()
.await
.map_err(|e| anyhow::anyhow!("channel discovery error: {e}"))?;
tracing::info!(
target: "buzz_acp::runtime_lifecycle",
runtime_state = "discovering_channels",
"starting canonical channel discovery"
);
let channel_info_map = relay.discover_channels().await.map_err(|e| {
tracing::error!(
target: "buzz_acp::runtime_lifecycle",
runtime_state = "startup_failed",
startup_stage = "channel_discovery",
error = %e,
"runtime did not become ready because channel discovery failed"
);
anyhow::anyhow!("channel discovery error: {e}")
})?;

tracing::info!("discovered {} channel(s)", channel_info_map.len());
let channel_ids: Vec<Uuid> = channel_info_map.keys().copied().collect();
Expand Down Expand Up @@ -1485,6 +1501,14 @@ async fn tokio_main() -> Result<()> {
}
}

tracing::info!(
target: "buzz_acp::runtime_lifecycle",
runtime_state = "subscriptions_enqueued",
discovered_channels = channel_info_map.len(),
subscribed_channels = subscribed_channel_ids.len(),
"canonical subscriptions are enqueued; runtime may now publish presence"
);

if let Some((observer, publisher, keys, agent_pubkey, owner_pubkey, owner)) =
relay_observer_publisher.take()
{
Expand Down
174 changes: 154 additions & 20 deletions crates/buzz-acp/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,15 +240,25 @@ pub struct RestClient {

/// Whether an HTTP status code is retriable (transient server/rate-limit errors).
fn is_retriable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 429 | 502 | 503 | 504)
let status = status.as_u16();
status == 408 || status == 429 || (500..600).contains(&status)
}

/// Base retry delays for transient HTTP failures: 500ms, 1s, 2s.
/// Base retry delays for transient HTTP failures.
///
/// Managed desktops commonly launch several agents at once. Each agent performs
/// two discovery queries, so a relay recovering from startup can remain
/// overloaded for longer than the old 3.5-second window. Seven total attempts
/// spread that burst over roughly 31.5 seconds (plus jitter) without retrying
/// deterministic auth or request errors.
/// Jitter (±20%) is applied at call time via `jittered_duration`.
const REST_RETRY_BASE_DELAYS: [Duration; 3] = [
const REST_RETRY_BASE_DELAYS: [Duration; 6] = [
Duration::from_millis(500),
Duration::from_millis(1000),
Duration::from_millis(2000),
Duration::from_millis(4000),
Duration::from_millis(8000),
Duration::from_millis(16000),
];

fn unix_now_secs() -> u64 {
Expand Down Expand Up @@ -307,8 +317,9 @@ impl RestClient {
Ok(format!("Nostr {}", self.sign_nip98(method, url, body)?))
}

/// Retry helper: executes `build_request` up to 4 times (1 attempt + 3 retries)
/// on transient failures (429, 502, 503, 504, timeout, connect errors).
/// Retry helper: executes `build_request` up to seven times
/// (one attempt plus six retries)
/// on transient failures (408, 429, 5xx, timeout, connect errors).
///
/// NIP-98 auth events are re-signed on each attempt (they have a ±60s window).
async fn request_with_retry<F, Fut>(
Expand All @@ -323,24 +334,53 @@ impl RestClient {
{
let mut last_err = None;

let total_attempts = REST_RETRY_BASE_DELAYS.len() + 1;
for (attempt, delay) in std::iter::once(None)
.chain(REST_RETRY_BASE_DELAYS.iter().map(|d| Some(*d)))
.enumerate()
{
if let Some(base) = delay {
let jittered = jittered_duration(base);
tracing::debug!(
"retrying {method} {path} (attempt {attempt}) in {:.1}s",
jittered.as_secs_f64()
tracing::warn!(
target: "buzz_acp::relay_lifecycle",
relay_state = "http_retry_wait",
method,
path,
next_attempt = attempt + 1,
total_attempts,
delay_seconds = jittered.as_secs_f64(),
"transient relay request failure; waiting before retry"
);
tokio::time::sleep(jittered).await;
}

match build_request().await {
Ok(resp) if resp.status().is_success() => return Ok(resp),
Ok(resp) if resp.status().is_success() => {
if attempt > 0 {
tracing::info!(
target: "buzz_acp::relay_lifecycle",
relay_state = "http_recovered",
method,
path,
attempt = attempt + 1,
total_attempts,
"relay request recovered after transient failures"
);
}
return Ok(resp);
}
Ok(resp) if is_retriable_status(resp.status()) => {
let status = resp.status();
tracing::warn!("{method} {path} returned retriable HTTP {status}");
tracing::warn!(
target: "buzz_acp::relay_lifecycle",
relay_state = "http_transient_failure",
method,
path,
attempt = attempt + 1,
total_attempts,
http_status = status.as_u16(),
"relay request returned a transient HTTP status"
);
last_err = Some(RelayError::Http(format!(
"{method} {path} returned HTTP {status}"
)));
Expand All @@ -353,15 +393,34 @@ impl RestClient {
)));
}
Err(e) if e.is_timeout() || e.is_connect() => {
tracing::warn!("{method} {path} network error: {e}");
tracing::warn!(
target: "buzz_acp::relay_lifecycle",
relay_state = "http_transient_failure",
method,
path,
attempt = attempt + 1,
total_attempts,
error = %e,
"relay request hit a transient network error"
);
last_err = Some(RelayError::Http(e.to_string()));
}
Err(e) => return Err(RelayError::Http(e.to_string())),
}
}

Err(last_err
.unwrap_or_else(|| RelayError::Http(format!("{method} {path} failed after retries"))))
let error = last_err
.unwrap_or_else(|| RelayError::Http(format!("{method} {path} failed after retries")));
tracing::error!(
target: "buzz_acp::relay_lifecycle",
relay_state = "http_retry_exhausted",
method,
path,
total_attempts,
error = %error,
"relay request exhausted its transient retry budget"
);
Err(error)
}

/// POST with NIP-98 auth and retry. Re-signs on each attempt.
Expand Down Expand Up @@ -2917,14 +2976,25 @@ async fn try_autonomous_reconnect(
let mut attempt = 0usize;
while attempt < backoffs.len() {
info!(
"autonomous reconnect attempt {}/{} to {relay_url}…",
attempt + 1,
backoffs.len()
target: "buzz_acp::relay_lifecycle",
relay_state = "reconnecting",
reconnect_mode = "autonomous",
attempt = attempt + 1,
total_attempts = backoffs.len(),
relay_url,
"attempting relay reconnect"
);
match do_connect(relay_url, keys, auth_tag).await {
Ok((new_ws, handshake_buffer)) => {
*ws = new_ws;
info!("autonomous reconnect succeeded (attempt {})", attempt + 1);
info!(
target: "buzz_acp::relay_lifecycle",
relay_state = "transport_reconnected",
reconnect_mode = "autonomous",
attempt = attempt + 1,
relay_url,
"relay transport reconnected; restoring subscriptions"
);
let handshake_ok = process_handshake_buffer(
ws,
handshake_buffer,
Expand All @@ -2949,7 +3019,16 @@ async fn try_autonomous_reconnect(
match resubscribe_after_reconnect(ws, cmd_rx, state, agent_pubkey_hex, true)
.await
{
ResubscribeResult::Ok => return ReconnectOutcome::Ok,
ResubscribeResult::Ok => {
info!(
target: "buzz_acp::relay_lifecycle",
relay_state = "online",
reconnect_mode = "autonomous",
active_subscriptions = state.active_subscriptions.len(),
"relay subscriptions restored; event replay is active"
);
return ReconnectOutcome::Ok;
}
ResubscribeResult::Shutdown => return ReconnectOutcome::Shutdown,
ResubscribeResult::RetryConnection => {
warn!("resubscribe failed after autonomous reconnect — treating as failed attempt");
Expand Down Expand Up @@ -3007,6 +3086,13 @@ async fn try_autonomous_reconnect(
attempt += 1;
}

tracing::error!(
target: "buzz_acp::relay_lifecycle",
relay_state = "reconnect_budget_exhausted",
reconnect_mode = "autonomous",
relay_url,
"bounded autonomous reconnect exhausted; entering persistent reconnect loop"
);
ReconnectOutcome::Failed
}

Expand Down Expand Up @@ -3058,11 +3144,25 @@ async fn wait_for_reconnect(
];
let mut attempt = state.backoff_step;
loop {
info!("attempting relay reconnect to {relay_url}…");
info!(
target: "buzz_acp::relay_lifecycle",
relay_state = "reconnecting",
reconnect_mode = "persistent",
attempt = attempt + 1,
relay_url,
"attempting relay reconnect"
);
match do_connect(relay_url, keys, auth_tag).await {
Ok((new_ws, handshake_buffer)) => {
*ws = new_ws;
info!("relay reconnected to {relay_url}");
info!(
target: "buzz_acp::relay_lifecycle",
relay_state = "transport_reconnected",
reconnect_mode = "persistent",
attempt = attempt + 1,
relay_url,
"relay transport reconnected; restoring subscriptions"
);
let handshake_ok = process_handshake_buffer(
ws,
handshake_buffer,
Expand All @@ -3085,6 +3185,13 @@ async fn wait_for_reconnect(
.await
{
ResubscribeResult::Ok => {
info!(
target: "buzz_acp::relay_lifecycle",
relay_state = "online",
reconnect_mode = "persistent",
active_subscriptions = state.active_subscriptions.len(),
"relay subscriptions restored; event replay is active"
);
// Drain any commands that arrived during do_connect() +
// resubscribe (which don't poll cmd_rx).
return drain_post_reconnect(ws, cmd_rx, state, agent_pubkey_hex).await;
Expand Down Expand Up @@ -3995,6 +4102,33 @@ async fn wait_for_any_ok(
mod tests {
use super::*;

#[test]
fn rest_retry_statuses_cover_transient_relay_failures() {
for status in [408, 429, 500, 501, 502, 503, 504, 599] {
assert!(
is_retriable_status(reqwest::StatusCode::from_u16(status).unwrap()),
"HTTP {status} should be retried"
);
}

for status in [400, 401, 403, 404, 409, 422] {
assert!(
!is_retriable_status(reqwest::StatusCode::from_u16(status).unwrap()),
"HTTP {status} should fail without retry"
);
}
}

#[test]
fn rest_retry_budget_outlasts_short_relay_startup_overload() {
assert_eq!(REST_RETRY_BASE_DELAYS.len() + 1, 7);
assert_eq!(
REST_RETRY_BASE_DELAYS.iter().sum::<Duration>(),
Duration::from_millis(31_500),
"managed-agent discovery must not exit after the old 3.5-second window"
);
}

#[test]
fn relay_ws_to_http_plain() {
assert_eq!(
Expand Down
10 changes: 9 additions & 1 deletion desktop/src/features/messages/ui/useMentionSendFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@ export function useMentionSendFlow({
[...draft.savedSpoileredAttachmentUrls],
);
}
} catch {
} catch (error) {
// Only restore the composer content if the user is still on the
// channel that originated the send.
if (draft.capturedChannelId === channelIdRef.current) {
Expand All @@ -554,6 +554,14 @@ export function useMentionSendFlow({
new Set(draft.savedSpoileredAttachmentUrls),
);
}

// A relay rejection or publish timeout must never look like a
// successful send. Keep a visible, durable-in-view receipt alongside
// the restored draft so the user can retry deliberately.
const message =
error instanceof Error ? error.message : "Failed to send message.";
setNonMemberPromptError(message);
toast.error(message);
}
} finally {
isCompleteSendPendingRef.current = false;
Expand Down
22 changes: 22 additions & 0 deletions desktop/tests/e2e/channels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,28 @@ test("does not reroute an expanded DM after the channel pane unmounts", async ({
.toBeNull();
});

test("shows a relay failure and restores the draft in an existing DM", async ({
page,
}) => {
const sendError = "Mock existing DM publish failed.";
const message = "@Fizz please inspect this";
await installMockBridge(page, {
sendMessageErrors: [sendError],
});
await page.goto("/");
await page.getByTestId("channel-alice-tyler").click();

const input = page.getByTestId("message-input");
await input.fill(message);
await page.getByTestId("send-message").click();

await expect(page.getByText(sendError).first()).toBeVisible();
await expect(input).toContainText(message);
await expect(
page.getByTestId("message-timeline").getByText(message, { exact: true }),
).toHaveCount(0);
});

test("drops an expanded DM after the first message fails", async ({ page }) => {
const retryMessage = "Retry without the agent";
const sendError = "Mock first DM send failed.";
Expand Down
Loading