cjpais/Handy · error · anyhow::Error

server advertises bytes, expected

Error message

server advertises {} bytes, expected {}

What it means

Raised when the catalog pins an expected size and the server's advertised remaining length (Content-Length plus the resume offset) does not sum to that size, before any bytes are written. The guard exists because writing to a mis-sized expectation either corrupts the file or masks truncation. Nothing has been written yet, but unlike some siblings this path does not delete the partial — the caller can retry once state is consistent.

Solutions

  1. Retry the download; if the catalog entry is stale it will keep failing — update the app so the refreshed catalog is used.
  2. Delete the model's partial and cached entry, then re-download from scratch.
  3. Try the alternate source (mirror vs. primary) in case only one host has the divergent object.
  4. Report the model id if both hosts disagree with the pinned size — it is a catalog bug.
Defensive patterns

Strategy: retry

Try / catch

match manager.download_model(model_id, None).await {
    Ok(_) => Ok(()),
    Err(e) if e.to_string().contains("server advertises") => {
        // size metadata disagrees; drop local state and retry fresh
        let _ = std::fs::remove_file(partial_path);
        manager.download_model(model_id, None).await
    }
    Err(e) => Err(e),
}

Prevention

When it happens

Trigger: Resuming a download where the server now hosts a different-sized object (rebuilt artifact) while the catalog still pins the old size; servers advertising compressed transfer-length instead of object-length.

Common situations: Upstream re-published/repacked the model file after the catalog snapshot; catalog size entries going stale across releases.

Related errors


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

Appendix: 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 c89b7bf389)