Skip to content

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

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 <uuid> --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 <uuid> --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 <pubkey> 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

async fn fetch_events(
    client: &BuzzClient,
    filter: &serde_json::Value,
) -> Option<Vec<serde_json::Value>> {
    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<String, CliError> (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

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<Vec<serde_json::Value>, 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<Option<Vec<String>>, 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions