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

post failed ({status}): {body}

Error message

post failed ({status}): {body}

What it means

Posting via com.atproto.repo.createRecord returned a non-2xx response; the HTTP status and body are embedded. send() resolves the access JWT, builds a PostRecord (with a reply reference when the recipient is in "uri|cid" form), truncates text to 300 chars, and this error means the AT Protocol API rejected the resulting record.

Source

Thrown at crates/zeroclaw-channels/src/bluesky.rs:374

                created_at: now,
                reply,
            },
        };

        let resp = client
            .post(format!("{BSKY_API_BASE}/com.atproto.repo.createRecord"))
            .bearer_auth(&token)
            .json(&request)
            .send()
            .await?;

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

        Ok(())
    }

    async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
        // Initial auth
        self.create_session().await?;

        ::zeroclaw_log::record!(
            INFO,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
            &format!("channel listening as @{}...", self.handle)
        );

        loop {
            tokio::time::sleep(POLL_INTERVAL).await;

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Read the embedded status: 400 = invalid record (usually a stale or malformed reply ref), 401 = auth (refresh the session), 429 = rate limit (back off).
  2. If the parent post was deleted, retry as a plain post by dropping the uri|cid reply suffix from the recipient.
  3. On 401, force a fresh create_session so both JWTs are replaced.
  4. Throttle outbound posts — the channel truncates to 300 chars but does not rate-limit for you.

Example fix

// before: replying to a possibly-deleted parent
ch.send(&SendMessage::new(text, reply_target)).await?;

// after: degrade to a fresh post when the reply ref is rejected
if let Err(e) = ch.send(&SendMessage::new(text, reply_target)).await {
    if e.to_string().contains("post failed (400") {
        return ch.send(&SendMessage::new(text, "")).await;
    }
    return Err(e);
}
Defensive patterns

Strategy: retry

Validate before calling

fn reply_target_is_wellformed(recipient: &str) -> bool {
    match recipient.split_once('|') {
        Some((uri, cid)) => uri.starts_with("at://") && !cid.is_empty(),
        None => true, // plain post, no reply reference
    }
}

Try / catch

match ch.send(&msg).await {
    Ok(()) => {}
    Err(e) if e.to_string().contains("(429") => {
        tokio::time::sleep(Duration::from_secs(60)).await;
        ch.send(&msg).await?;
    }
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: Channel::send on a Bluesky channel when the reply reference is invalid (recipient not a well-formed uri|cid of an existing post, or the parent post was deleted), the access JWT expired or was invalidated (401), the record fails schema validation (400), or the account is rate limited (429).

Common situations: Replying to a deleted or moderated post whose reply_target went stale; bursts of posts hitting API rate limits; tokens invalidated server-side between polls; recipient strings reused from other channel formats.

Related errors


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