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

  1. 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.
  2. Retry transient statuses (5xx, 429) with backoff — a single `send` attempt does not retry downloads.
  3. For 403 anti-bot bodies, set an appropriate `User-Agent`/`Referer` on the hosting side or mirror the file to storage you control.
  4. 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

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


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