zeroclaw-labs/zeroclaw · error · anyhow::Error
attachment download failed ({status}): {body}
Error message
attachment download failed ({status}): {body} What it means
The HTTPS request for a remote WeChat attachment completed at the transport level, but the server answered with a non-2xx status. The message embeds both the status code and the response body (body may be empty when the error response has no payload). This is the generic remote-side failure for the attachment download step of `load_attachment_payload`.
Source
Thrown at crates/zeroclaw-channels/src/wechat.rs:1186
&self,
url: &str,
kind: WeChatAttachmentKind,
) -> anyhow::Result<WeChatMediaPayload> {
if !url.starts_with("https://") {
anyhow::bail!("refusing non-HTTPS attachment URL: {url}");
}
let resp = self
.client
.get(url)
.timeout(API_TIMEOUT)
.send()
.await
.with_context(|| format!("attachment download failed: {url}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("attachment download failed ({status}): {body}");
}
if let Some(len) = resp.content_length()
&& len > WECHAT_MEDIA_MAX_BYTES
{
anyhow::bail!(
"attachment Content-Length ({len} bytes) exceeds {} MB limit",
WECHAT_MEDIA_MAX_BYTES / (1024 * 1024)
);
}
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let bytes = resp.bytes().await?.to_vec();
View on GitHub (pinned to 88bb9c8533)
Solutions
- Verify the URL still works in a browser/curl; if expired, obtain a fresh link or download the file locally and attach it as a workspace file.
- Retry transient statuses (5xx, 429) with backoff — a single `send` attempt does not retry downloads.
- For 403 anti-bot bodies, set an appropriate `User-Agent`/`Referer` on the hosting side or mirror the file to storage you control.
- If you control the origin, check its logs with the embedded status/body to find why it rejected the request.
Example fix
// before: one-shot send with a possibly-stale URL
channel.send(msg_with_attachment("https://cdn.example.com/tmp/a.png")).await?;
// after: retry transient download failures, fall back to local file
let mut attempt = 0;
loop {
match channel.send(msg_with_attachment(&url)).await {
Ok(_) => break,
Err(err) if attempt < 3 && err.to_string().contains("attachment download failed (5") => {
attempt += 1;
tokio::time::sleep(std::time::Duration::from_secs(2u64 * attempt)).await;
}
Err(err) if err.to_string().contains("attachment download failed (") => {
// permanent (4xx): mirror the file into the workspace instead
let local = mirror_to_workspace(&url).await?;
channel.send(msg_with_attachment(&local)).await?;
break;
}
Err(err) => return Err(err),
}
} Defensive patterns
Strategy: retry
Validate before calling
// cheap preflight: is the URL live before wiring it into a message?
async fn url_ok(client: &reqwest::Client, url: &str) -> bool {
matches!(client.head(url).send().await, Ok(resp) if resp.status().is_success())
|| matches!(client.get(url).timeout(std::time::Duration::from_secs(15)).send().await,
Ok(resp) if resp.status().is_success())
} Try / catch
let mut backoff = 1;
loop {
match channel.send(msg_with_attachment(&url)).await {
Ok(_) => break,
Err(err) => {
let msg = err.to_string();
let transient = msg.contains("attachment download failed (5") || msg.contains("attachment download failed (429");
if transient && backoff <= 8 {
tokio::time::sleep(std::time::Duration::from_secs(backoff)).await;
backoff *= 2;
} else if msg.contains("attachment download failed (") {
anyhow::bail!("attachment source rejected the download (4xx): {msg}");
} else {
return Err(err);
}
}
}
} Prevention
- Pre-check attachment URLs with a HEAD request before including them in outbound messages.
- Prefer links you control (object storage with long-lived signed URLs) over third-party CDN links that expire.
- Distinguish 4xx (permanent: refresh the link or mirror the file) from 5xx/429 (transient: retry with backoff) using the embedded status code.
- Log the embedded body on failure; anti-bot HTML bodies tell you the origin blocked the client, not that the file is gone.
When it happens
Trigger: Any `send` with an `https://` attachment target where the origin returns an error status: 403/404 for expired or deleted CDN links (very common with time-limited signed URLs from other chat platforms), 401 for auth-gated URLs missing a token, 410 for gone media, 5xx during origin outages, or 429 rate limiting. Note DNS/TLS/timeout failures raise different errors from `send()` itself, surfaced with the `attachment download failed: {url}` context.
Common situations: Reposting media whose source link has expired (WeChat CDN, S3 presigned URLs past expiry, Slack/Discord media links); hotlinking from sites that block non-browser clients (403 with an HTML anti-bot body — the HTML then shows up in the error message); transient CDN 5xx; misconfigured attachment URLs in test fixtures pointing at stub servers that return 404.
Related errors
- refusing non-HTTPS attachment URL: {url}
- attachment Content-Length ({len} bytes) exceeds {} MB limit
- attachment exceeds {} MB limit
- getUploadUrl failed ({status}): {body}
- WeCom attachment download failed: kind={} msg_id={} url_targ
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/13921e336581667c.
Report an issue: GitHub.