zeroclaw-labs/zeroclaw · error · anyhow::Error

Discord send message failed ({status}): {err}

Error message

Discord send message failed ({status}): {err}

What it means

The core Discord message-send wrapper bails whenever POST /channels/{id}/messages answers non-2xx, embedding both the HTTP status and the response body (or a placeholder when the body itself cannot be read). It backs send, send_discord_message_json, and draft finalize, so nearly every outbound message funnels through it.

Source

Thrown at crates/zeroclaw-channels/src/discord/rest.rs:55

    payload: &DiscordOutgoing,
) -> anyhow::Result<String> {
    let url = format!("https://discord.com/api/v10/channels/{recipient}/messages");
    let body = payload.to_rest_json();

    let resp = client
        .post(&url)
        .header("Authorization", format!("Bot {bot_token}"))
        .json(&body)
        .send()
        .await?;

    if !resp.status().is_success() {
        let status = resp.status();
        let err = resp
            .text()
            .await
            .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
        anyhow::bail!("Discord send message failed ({status}): {err}");
    }

    extract_message_id(resp).await
}

pub(crate) async fn send_discord_outgoing(
    client: &reqwest::Client,
    bot_token: &str,
    recipient: &str,
    outgoing: &DiscordOutgoing,
) -> anyhow::Result<String> {
    let url = format!("https://discord.com/api/v10/channels/{recipient}/messages");
    let body = outgoing.to_rest_json();

    let resp = client
        .post(&url)
        .header("Authorization", format!("Bot {bot_token}"))
        .json(&body)

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Read the status and body in the error text first — it names the exact cause (401 token, 403 permissions, 50035 invalid form body)
  2. 401 → fix/rotate the bot token in config and restart the channel
  3. 403 → re-invite the bot or grant Send Messages + View Channel on the target channel
  4. 400 → shorten content below 2000 chars or fix the embed payload shape
  5. 429 → honor the Retry-After header and resend with backoff
Defensive patterns

Strategy: retry

Validate before calling

// Rust — cheap preflight before sending
fn discord_send_precheck(recipient: &str, content: &str) -> Result<(), String> {
    let channel_id = recipient.split(':').next().unwrap_or(recipient);
    if channel_id.parse::<u64>().is_err() {
        return Err(format!("recipient '{recipient}' has no valid channel id"));
    }
    if content.chars().count() > 2000 {
        return Err("content exceeds Discord's 2000-character limit".into());
    }
    Ok(())
}

Try / catch

let mut attempt = 0;
loop {
    match send_discord_message_payload(&client, &url, &payload).await {
        Ok(id) => break Ok(id),
        Err(e) if attempt < 3 && e.to_string().contains("429") => {
            attempt += 1;
            tokio::time::sleep(retry_after_delay(&e)).await; // honor Retry-After
        }
        Err(e) => break Err(e), // 4xx: fix token/permissions/payload, do not blind-retry
    }
}

Prevention

When it happens

Trigger: Any non-success status from the message POST: 401 invalid/expired bot token, 403 missing Send Messages permission or no access to the channel id, 400 invalid form body (content over 2000 chars, malformed embed), 429 rate limit.

Common situations: Bot token regenerated after the channel started; wrong or stale channel id in the recipient; channel permission changes after a role reshuffle; message content exceeding Discord limits; bursting sends into rate limits.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/bc8013b50e0b21cb. Report an issue: GitHub.