{"record":{"id":"8767311744ef7def","repo":"zeroclaw-labs/zeroclaw","slug":"download-failed-url","errorCode":null,"errorMessage":"Download failed ({}): {url}","messagePattern":"Download failed \\((.+?)\\): (.+?)","errorType":"http","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-channels/src/qq.rs","lineNumber":1211,"sourceCode":"            .unwrap_or(\"file\");\n        let ext = Path::new(filename)\n            .extension()\n            .and_then(|e| e.to_str())\n            .unwrap_or(\"\");\n        let unique = &Uuid::new_v4().to_string()[..8];\n        let safe_name = if ext.is_empty() {\n            format!(\"{stem}_{unique}\")\n        } else {\n            format!(\"{stem}_{unique}.{ext}\")\n        };\n\n        let dest = dir.join(&safe_name);\n\n        // QQ multimedia URLs carry rkey auth in query params — no Authorization header needed\n        // (consistent with openclaw-qqbot's downloadFile implementation)\n        let resp = self.http_client().get(url).send().await?;\n        if !resp.status().is_success() {\n            anyhow::bail!(\"Download failed ({}): {url}\", resp.status());\n        }\n\n        let bytes = crate::util::read_response_body_limited(resp, QQ_MAX_UPLOAD_BYTES).await?;\n        tokio::fs::write(&dest, &bytes).await?;\n\n        Ok((dest, bytes))\n    }\n\n    async fn try_transcribe_audio_data(&self, audio_data: &[u8], filename: &str) -> Option<String> {\n        let manager = self.transcription_manager.as_deref()?;\n\n        if audio_data.len() as u64 > QQ_MAX_AUDIO_TRANSCRIPTION_BYTES {\n            ::zeroclaw_log::record!(\n                WARN,\n                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)\n                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)\n                    .with_attrs(::serde_json::json!({\n                        \"bytes\": audio_data.len(),","sourceCodeStart":1193,"sourceCodeEnd":1229,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-channels/src/qq.rs#L1193-L1229","documentation":"Raised by QQChannel::download_attachment when the HTTP GET for a QQ multimedia URL returns any non-2xx status. QQ attachment URLs are authenticated solely by an rkey query parameter carried inside the URL itself (no Authorization header is sent, matching openclaw-qqbot's downloadFile), so a non-success status almost always means the URL was rejected: an expired rkey, deleted media, or an unreachable/misrouted CDN. The download is also capped at QQ_MAX_UPLOAD_BYTES (10 MiB) by read_response_body_limited, but that limit produces a different error.","triggerScenarios":"compose_qq_message calls download_attachment with the file URL of an inbound QQ message (image/audio/video/file). Any 401/403 (expired rkey), 404 (media removed), or 5xx from the QQ CDN hits the 'if !resp.status().is_success()' bail before the body is read.","commonSituations":"Replaying or queueing QQ events after a delay, so the rkey embedded in the URL has expired; long-running bots that persist attachment URLs instead of downloading immediately; runtime proxy config for channel.qq routing to a host that cannot reach QQ's multimedia domain; media deleted by the sender before the bot fetched it.","solutions":["Download attachments immediately when the event arrives — the rkey in the URL expires, so persisted or replayed URLs fail later","Retry the GET once or twice with short backoff; transient 5xx from the QQ CDN is common","If the status is 401/403, treat the attachment as unavailable and skip it (compose the message text without the attachment) instead of failing the whole inbound message","If every download fails, check the runtime proxy settings for channel.qq and network egress to QQ multimedia domains"],"exampleFix":"// before — one failed download fails the whole composed message\nlet (path, bytes) = self.download_attachment(&url, &dir, &name).await?;\n\n// after — skip the attachment, keep the message\nlet attachment = match self.download_attachment(&url, &dir, &name).await {\n    Ok(v) => Some(v),\n    Err(e) => {\n        ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Failure), format!(\"attachment download failed: {e:#}\"));\n        None\n    }\n};","handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"match channel.compose_qq_message(&event).await {\n    Err(err) => {\n        let msg = format!(\"{err:#}\");\n        if msg.starts_with(\"Download failed (5\") || msg.starts_with(\"Download failed (429\") {\n            tokio::time::sleep(Duration::from_secs(2)).await;\n            channel.compose_qq_message(&event).await // one retry\n        } else if msg.starts_with(\"Download failed (40\") {\n            Ok(skip_attachment_and_retry_text(&event)) // 401/403/404: rkey or media gone, skip attachment\n        } else {\n            Err(err)\n        }\n    }\n    ok => ok,\n}","preventionTips":["Download QQ attachments immediately when the event arrives; the rkey query parameter expires","Never persist QQ multimedia URLs for later processing","Treat a 401/403 download as 'attachment unavailable' and degrade to text instead of failing the message"],"tags":["qq","attachment-download","http-status","rkey","cdn","channel"],"backgroundTag":"attachment-download-failed","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}