{"record":{"id":"6cf25fc7d5f5737d","repo":"farion1231/cc-switch","slug":"download-failed","errorCode":"DOWNLOAD_FAILED","errorMessage":"{\"code\":\"DOWNLOAD_FAILED\",\"context\":{\"status\":\"{status}\"},\"suggestion\":\"http403|http404|http429|checkNetwork\"}","messagePattern":"(.+?)\"\\},\"suggestion\":\"http403\\|http404\\|http429\\|checkNetwork\"\\}","errorType":"error_code","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"src-tauri/src/services/skill.rs","lineNumber":3128,"sourceCode":"                    // 否则 N 个候选分支等于 N 倍的落盘量堆在同一个目录里。\n                    let _ = fs::remove_dir_all(&temp_path);\n                    let _ = fs::create_dir_all(&temp_path);\n                    last_error = Some(e);\n                    continue;\n                }\n            }\n        }\n\n        Err(last_error.unwrap_or_else(|| anyhow::anyhow!(\"所有分支下载失败\")))\n    }\n\n    /// 下载并解压 ZIP\n    async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {\n        let client = crate::proxy::http_client::get();\n        let response = client.get(url).send().await?;\n        if !response.status().is_success() {\n            let status = response.status().as_u16().to_string();\n            return Err(anyhow::anyhow!(format_skill_error(\n                \"DOWNLOAD_FAILED\",\n                &[(\"status\", &status)],\n                match status.as_str() {\n                    \"403\" => Some(\"http403\"),\n                    \"404\" => Some(\"http404\"),\n                    \"429\" => Some(\"http429\"),\n                    _ => Some(\"checkNetwork\"),\n                },\n            )));\n        }\n\n        // 逐块读并卡住压缩体大小：`response.bytes()` 会先把攻击者控制的整个归档\n        // 收进内存，之后才轮到 ZipArchive 和解压预算——那时候堆已经被吃光了。\n        // 不能只信 Content-Length（可以撒谎或缺失），必须按实际收到的字节数算。\n        let mut response = response;\n        let mut body: Vec<u8> = Vec::new();\n        while let Some(chunk) = response.chunk().await? {\n            if body.len().saturating_add(chunk.len()) as u64 > MAX_ARCHIVE_DOWNLOAD_BYTES {","sourceCodeStart":3110,"sourceCodeEnd":3146,"githubUrl":"https://github.com/farion1231/cc-switch/blob/a2e22f330273a5b6ffa87cb8b82b624601bac562/src-tauri/src/services/skill.rs#L3110-L3146","documentation":"Structured error from download_and_extract: the GET on the GitHub archive URL returned a non-2xx status. The suggestion field maps the status — 403→http403, 404→http404, 429→http429, anything else→checkNetwork. Because download_repo tries the custom branch then main then master, the error you finally see is the last candidate's; a wrong custom branch alone usually recovers silently via fallback.","triggerScenarios":"404: repo deleted/renamed, or repo is private (the archive endpoint is unauthenticated) or all three branches missing. 429: GitHub unauthenticated rate limit (~60 req/h per IP). 403: rate limiting or blocked access. Others: proxy/VPN intercepting github.com.","commonSituations":"Repo renamed by owner; typo in owner/name; heavy discovery browsing exhausting the IP rate limit; corporate proxies returning 407/502 mapped to checkNetwork.","solutions":["Open https://github.com/{owner}/{name} in a browser to confirm the repo exists and is public","On 429, wait for the rate-limit window to reset before retrying discovery","Clear the custom branch so the main/master fallback can resolve renamed default branches","Check proxy settings if the status is unusual (5xx/407) — the app routes through crate::proxy::http_client"],"exampleFix":"// before — treating any download failure the same\nmatch service.fetch_repo_skills(&repo).await {\n    Err(e) => log::error!(\"failed: {e}\"),\n    ...\n}\n\n// after — branch on the structured status\nlet err = e.to_string();\nif err.contains(\"\\\"code\\\":\\\"DOWNLOAD_FAILED\\\"\") {\n    if err.contains(\"\\\"status\\\":\\\"404\\\"\") { /* tell user repo not found */ }\n    else if err.contains(\"\\\"status\\\":\\\"429\\\"\") { /* back off and retry later */ }\n}","handlingStrategy":"try-catch","validationCode":"// Rust — pre-flight: confirm the repo is publicly reachable before discovery\nasync fn repo_exists(owner: &str, name: &str) -> bool {\n    crate::proxy::http_client::get()\n        .head(format!(\"https://github.com/{owner}/{name}\"))\n        .send().await.map(|r| r.status().is_success()).unwrap_or(false)\n}","typeGuard":"export interface SkillError { code: string; context: Record<string, string>; suggestion: string }\nexport function parseSkillError(e: unknown): SkillError | null {\n  if (typeof e !== \"string\") return null;\n  try { const v = JSON.parse(e); return v?.code ? (v as SkillError) : null; } catch { return null; }\n}","tryCatchPattern":"// TS — branch on the structured status\ncatch (e) {\n  const err = parseSkillError(e);\n  if (err?.code === \"DOWNLOAD_FAILED\") {\n    switch (err.context.status) {\n      case \"404\": return showHint(\"repoNotFound\");\n      case \"429\": return scheduleRetry(3600_000); // rate-limit window\n      case \"403\": return showHint(\"http403\");\n      default:   return showHint(\"checkNetwork\");\n    }\n  }\n  throw e;\n}","preventionTips":["Rate-limit discovery browsing client-side to stay under GitHub's ~60 unauthenticated requests/hour","Verify repo exists and is public before adding — private repos cannot be fetched by the unauthenticated archive endpoint","Clear invalid custom branches so the main/master fallback can rescue renamed default branches"],"tags":["network","http","github","rate-limit","download"],"backgroundTag":null,"analyzedSha":"a2e22f330273a5b6ffa87cb8b82b624601bac562","analyzedAt":"2026-08-16T03:46:07.889Z","schemaVersion":2},"datasetVersion":"2026-08-16T08:17:34.114Z"}