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
- Read the embedded status: 400 = invalid record (usually a stale or malformed reply ref), 401 = auth (refresh the session), 429 = rate limit (back off).
- If the parent post was deleted, retry as a plain post by dropping the uri|cid reply suffix from the recipient.
- On 401, force a fresh create_session so both JWTs are replaced.
- 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
- Validate reply targets (at:// URI + CID) before sending
- Drop replies to deleted posts instead of retrying them
- Refresh JWTs on 401 rather than resending the same token
- Throttle outbound posts below AT Protocol rate limits
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
- createSession failed ({status}): {body}
- Embedding API error {status}: {text}
- OpenAI token refresh is in backoff for {remaining}s due to p
- Gemini token refresh is in backoff for {remaining}s due to p
- xAI token refresh is in backoff for {remaining}s due to prev
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/57e8571e88fdd7ae.
Report an issue: GitHub.