block / block/buzz

buzz messages send inverts `retryable` on an unreachable relay whenever the body contains an `@`

Open
#6,555 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
32.7k
Forks
4.3k
Avg merge
1d 13h
Merged PRs (30d)
253

Description

One character in the message body decides whether an unreachable relay is reported as a retryable network fault or a permanent membership fault. That character is `@`.

Same relay, same identity, same channel, same command. Only the body changes.

## Reproduction

Buzz CLI on Windows 11. Relay pointed at a closed port so every request fails to connect. Throwaway key, so nothing is ever published anywhere.

Without an `@` in the body:

```
$ BUZZ_RELAY_URL=http://127.0.0.1:9 buzz messages send --channel --content "probe without at sign"
{"error":"network_error","message":"network error: error sending request for url (http://127.0.0.1:9/events): client error (Connect): tcp connect error: No connection could be made because the target machine actively refused it. (os error 10061)","retryable":true}
exit 2
```

With an `@` in the body:

```
$ BUZZ_RELAY_URL=http://127.0.0.1:9 buzz messages send --channel --content "probe with an @sign in it"
{"error":"error","message":"could not load channel membership for mention preflight","retryable":false}
exit 4
```

Four probes total, to pin down what actually arms this:

| Body | Extra flag | category | retryable | exit |
| --- | --- | --- | --- | --- |
| `probe without at sign` | none | `network_error` | `true` | 2 |
| `probe with an @sign in it` | none | `error` | `false` | 4 |
| `no at sign here` | `--mention ` | `error` | `false` | 4 |
| ``text with `an @sign` in code`` | none | `network_error` | `true` | 2 |

Row 3 shows the `@` character itself is not the trigger. Mention processing being requested at all is the trigger. Row 4 shows `strip_code_regions` is what decides, because an `@` inside a code region never arms the preflight.

Worth noting which endpoint each row names. The honest error tells you it failed on `/events`. The misleading one never mentions that it died on `/query`.

## Mechanism

Read at `a2d8be5efa126221c7676f7797555dfb2bf5b0e0`.

`fetch_events` throws the error away.

`crates/buzz-cli/src/commands/messages.rs:296`

```rust
async fn fetch_events(
client: &BuzzClient,
filter: &serde_json::Value,
) -> Option> {
let raw = client.query(filter).await.ok()?;
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
parsed.as_array().cloned()
}
```

`client.query` returns `Result` (`crates/buzz-cli/src/client.rs:767`). On a refused connection that error is `CliError::Network`, which `is_retryable_error` reports as true, as row 1 confirms. `.ok()?` deletes it and hands back a bare `None`.

`resolve_content_mentions` then invents a fresh error out of that `None`.

`crates/buzz-cli/src/commands/messages.rs:176`

```rust
let member_pubkeys = fetch_member_pubkeys(client, &members_filter)
.await
.ok_or_else(|| {
CliError::Other("could not load channel membership for mention preflight".into())
})?;
```

`CliError::Other` is category `error`, exit code 4, and never retryable (`crates/buzz-cli/src/error.rs:72`, `:90`, `:106`, `:128`). `CliError::Network` on a connect failure is category `network_error`, exit code 2, retryable true. So the classification does not degrade. It inverts.

The gate sits at `crates/buzz-cli/src/commands/messages.rs:167`, and the preflight runs before the publish at `crates/buzz-cli/src/commands/messages.rs:635`. The command dies in the preflight and never reaches the code that would have described the outage correctly.

## Three causes, one message, and it names the least likely one

`fetch_member_pubkeys` (`crates/buzz-cli/src/commands/messages.rs:306`) returns `None` for three unrelated conditions.

1. The relay was unreachable, or answered 401, 403, 429, 502, or anything else non-2xx. `.ok()?` eats all of it.
2. The relay answered with a body that is not a JSON array.
3. The relay answered perfectly well and the channel has no kind 39002 membership event at all.

All three print `could not load channel membership for mention preflight`. Only the third has anything to do with membership. Cause 1 is the common one in the field and it is the one the message actively misdescribes.

## Why this is worth fixing

`retryable` is not decoration. Anything driving this CLI reads that field to decide whether to retry or to give up. A wrapper doing exactly the right thing with `retryable:false` will drop the message permanently, and it will do that only for messages containing an `@`. Which is to say, precisely the messages that were written to get somebody's attention.

It also burns human hours. The message says membership, so you go and audit membership, roles and stale identities, and the whole time the relay was simply not listening on that port. The honest error was one character away.

## Recommended fix

Stop laundering the error. `fetch_event` at `crates/buzz-cli/src/commands/messages.rs:66` already does the right thing in this very file, with a plain `client.query(&filter).await?`.

1. Change `fetch_events` to return `Result, CliError>` and propagate with `?`. A non-array body becomes its own explicit error instead of a silent `None`.
2. Change `fetch_member_pubkeys` to return `Result>, CliError>`, where `Err` means the lookup genuinely failed and `Ok(None)` means the channel has no 39002 event.
3. At the call site keep `?` for the failure, and map `Ok(None)` to a `NotFound` that names the channel. That is honest, it is actionable, and it stops "the relay is down" from wearing a membership costume.

The profile lookup at `crates/buzz-cli/src/commands/messages.rs:191` has the same shape and should go the same way. One difference there, an empty profile list already comes back as `Some(vec![])` today, so that call only ever hides transport and parse failures, never an empty result.

I am happy to send the PR with tests if this framing looks right. The unreachable-relay case needs no test server at all, since `BuzzClient::new("http://127.0.0.1:1", ...)` is already the pattern used by tests in this file.

## Not a duplicate of

Searched on 2026-08-22, open and closed, for `mention preflight`, `could not load channel membership`, `preflight`, `retryable`, `mention`, `membership`, `exit code retryable send` and `relay unreachable cli`. I cannot diff all 1694 open PRs, so I also searched PRs for the same terms and scanned the 200 most recent for any touch on `messages.rs`, `error.rs` or `client.rs`.

- #2422, buzz-acp drops JSON-RPC `error.data`. Same family of sin, different crate, different surface. That one loses detail inside the ACP adapter. This one inverts `retryable` in the CLI.
- #1743, agent mentions silently fail when the mentioned agent is offline. That is about delivery to an offline agent. Nothing to do with how the sender classifies its own failure.
- #3204, mention picker lists locally-managed agents instead of channel members. Desktop picker, and closed.
- #6032, a non-owner member cannot mention an agent. Permissions and autocomplete, with the relay reachable throughout.
- #5449, #4307 and #5754 all concern real membership state. This report is specifically the case where membership was never read at all.
- Open PRs touching `crates/buzz-cli/src/commands/messages.rs` that turned up (#6493, #6465, #6474, #6452, #6449, #6140) cover empty content, NUL bytes, nonexistent channels and edit folding. None of them go near `fetch_events` or this error path.

Contributor guide

Open the contributing guide

Research direction

Start in crates/buzz-cli/src/commands/messages.rs with fetch_events, fetch_member_pubkeys, resolve_content_mentions, and the publish path around lines 167 and 635; compare their error handling with fetch_event near line 66 and client.query in client.rs:767. Run the existing tests in messages.rs using the unreachable-relay pattern described in the issue. Done means transport and parse failures retain their original classification, while an absent membership event is reported separately.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
cli, networking
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.