cjpais/Handy · error · anyhow::Error

server advertises {} bytes, expected {}

Error message

server advertises {} bytes, expected {}

What it means

When the catalog pins an expected_size and the server advertises a Content-Length, the downloader rejects the response up front if resume_from + content_length != expected. The server's object provably differs in size from the pinned model, so writing anything would produce a wrong file. Unlike the taint paths, this branch does not delete the partial.

Source

Thrown at src-tauri/src/managers/model/download.rs:285

            let starts_at = response
                .headers()
                .get(reqwest::header::CONTENT_RANGE)
                .and_then(|v| v.to_str().ok())
                .and_then(content_range_start);
            if starts_at != Some(resume_from) {
                let _ = fs::remove_file(partial_path);
                return Err(anyhow::anyhow!(
                    "server returned Content-Range starting at {:?}, expected {}",
                    starts_at,
                    resume_from
                ));
            }
        }
        // When the catalog pins the size, a server advertising a different
        // total is already misbehaving — reject before writing anything.
        if let (Some(expected), Some(len)) = (expected_size, response.content_length()) {
            if resume_from + len != expected {
                return Err(anyhow::anyhow!(
                    "server advertises {} bytes, expected {}",
                    resume_from + len,
                    expected
                ));
            }
        }

        let known_total =
            expected_size.or_else(|| response.content_length().map(|l| resume_from + l));
        let total_size = known_total.unwrap_or(0);
        let mut downloaded = resume_from;
        let mut file = if resume_from > 0 {
            std::fs::OpenOptions::new()
                .append(true)
                .open(partial_path)?
        } else {
            std::fs::File::create(partial_path)?
        };

View on GitHub (pinned to 98a4d80cce)

Solutions

  1. Delete the leftover partial and retry a fresh download against the current object
  2. Verify: curl -sI <url> and compare Content-Length with the catalog size
  3. Update the app so catalog metadata matches the published model, or pin the matching HF revision
  4. Prefer the official HuggingFace source, where sizes are pinned per revision
Defensive patterns

Strategy: validation

Validate before calling

// Before resuming, confirm the server object still matches the pinned size
async fn size_matches(url: &str, resume_from: u64, expected: u64) -> bool {
    let resp = reqwest::Client::new()
        .get(url)
        .header("Range", format!("bytes={}-", resume_from))
        .send()
        .await
        .ok()?;
    match resp.content_length() {
        Some(len) => resume_from + len == expected,
        None => false,
    }
}

Try / catch

match downloader.download_http_resumable(...).await {
    Ok(outcome) => Ok(outcome),
    Err(e) if e.to_string().starts_with("server advertises") => {
        // metadata/size skew: drop the partial and refresh catalog before retrying
        remove_partial(partial_path);
        refresh_catalog_metadata().await?;
        downloader.download_http_resumable(...).await
    }
    Err(e) => Err(e),
}

Prevention

When it happens

Trigger: Upstream re-published the model at a different size while the catalog still pins the old size; a mirror serving a different build; a proxy truncating or padding Content-Length.

Common situations: Catalog/app version skew after a model update; third-party mirrors that rebuild files; resuming against a host whose object changed since the partial was created.

Related errors


AI-assisted analysis of cjpais/Handy@98a4d80cce (2026-08-16). Data as JSON: /api/errors/52a302b503dbfbfb. Report an issue: GitHub.