fix(models): emit envelope/1 errors on every model download failure (BE-4217)#581
fix(models): emit envelope/1 errors on every model download failure (BE-4217)#581mattmillerai wants to merge 2 commits into
model download failure (BE-4217)#581Conversation
`comfy --json model download` predated the renderer.error() envelope infra:
its failure paths either exited 1 with no envelope (so the local MCP saw
`code=None` and a raw stderr tail) or, worse, exited 0 with no envelope at
all — which the MCP's plain_ok synthesizer turns into a synthesized SUCCESS
for a download that never happened.
Every failure now funnels through a single `_download_failure()` helper that
emits an `envelope/1` error and exits 1:
- file already exists -> model_file_exists (was exit 0)
- Hugging Face 401, no token -> hf_unauthorized (was exit 0)
- no resolvable filename -> missing_argument (was a bare Exit(1)
/ an unhandled DownloadException)
- transfer failure -> download_failed
- local write failure (OSError) -> download_failed (was a traceback)
- CivitAI metadata lookup fails -> download_failed, details.stage=resolve
(was a traceback)
- Hugging Face download / hub install fails -> download_failed (was a traceback)
Registers `model_file_exists` and `hf_unauthorized`, and widens the existing
`download_failed` entry to cover model downloads. The success path is
unchanged (prints + exit 0, no envelope) because the MCP synthesizer depends
on exit-0-no-envelope meaning success.
The ticket also proposed a `model_source_unknown` hard stop on the "Model
source is unknown" branch. That branch is NOT an error: it is how a plain
direct file URL is downloaded, verified live (a raw.githubusercontent.com URL
downloads 35823 bytes, exit 0) and covered by the pre-existing direct-URL
tests. Stopping there would break direct downloads and contradict the
ticket's own acceptance case (a 404 direct URL must yield download_failed),
so the branch keeps working and only its misleading log line was corrected.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 1 minute Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify code
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 7 finding(s).
| Severity | Count |
|---|---|
| 🟠 High | 1 |
| 🟡 Medium | 4 |
| 🟢 Low | 1 |
| ⚪ Nit | 1 |
Panel: 6/8 reviewers contributed findings.
Reviewers that did not contribute: kimi-k2.5:adversarial (empty), kimi-k2.5:edge-case (empty)
…(BE-4217) Follow-ups to the review panel on #581: - Honour `--filename` on the *gated* Hugging Face path. `hf_hub_download` names the file after the repo path, so `--filename` was silently ignored there while the public-HF path (via `download_file`) honoured it. That split made the `local_filepath.exists()` guard inspect a path the branch never writes, and made the new `model_file_exists` hint ("pass `--filename`") impossible to act on. The result is now moved to the requested name. - Reject path components that escape the workspace. `local_filename` (from `--filename`, or from the CivitAI API's `file["name"]`, which is the accepted default in non-interactive runs) and `basemodel` (`version["baseModel"]`) are joined into the destination without sanitisation; `pathlib` does not neutralise `..` or an absolute component. Both are now validated, with the hint pointing at `--relative-path` — the option that exists for choosing a directory, so the capability is redirected rather than removed. - Scrub credentials out of the URL before it is echoed. CivitAI links carry the token as `?token=`; `tracking._scrub_value` already strips it before telemetry, and an error envelope is a louder channel than telemetry. Query, fragment and userinfo are dropped from every `details.url` and log line. - Restore `escape()` on interpolated values. `print` is Rich's markup-parsing print, so a URL holding `[/]` (or an IPv6 literal like `http://[::1]/x`) raised MarkupError out of the *logging* — an uncaught crash with no envelope, which is the exact failure this command promises not to have. - Guard `int(Content-Length)` in `_download_file_httpx`: a non-numeric header from a broken proxy raised ValueError past both handlers. Root-caused there, plus a catch-all backstop at the call site so no downloader failure can end the command without an envelope. - Drop the ineffective `patch("comfy_cli.tracking.track_command", ...)` from the tests: `download` is decorated at import time, so patching the factory afterwards never rebinds the wrapper. The HF auto-install targeting the workspace interpreter (Low finding) is left as-is — it is pinned by tests/comfy_cli/test_models_python_resolution.py and changing it is a separate decision; deferred to a follow-up.
|
🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:
|
ELI-5
If you ask the CLI to download a model and it fails, it should say so in a way a
program can understand — a JSON error with a code — and it should exit with a
failure code.
Two of the failure cases didn't do that. If the file was already there, or if
Hugging Face said "you need a token," the CLI printed a message and exited 0
— i.e. "success." The local MCP wrapper sees "exit 0, no JSON" and synthesizes
a success envelope, so an agent was told "download complete" for a download
that never happened. Other failures exited 1 but with no JSON at all, so the MCP
could only forward a raw stderr tail with
code=None.Now every failure emits one
envelope/1error object with a realerror.codeand exits 1. Success is untouched (prints + exit 0, no envelope) because the
MCP's synthesizer depends on that meaning success.
What changed
All failure paths in
comfy_cli/command/models/models.pydownloadfunnelthrough one
_download_failure()helper (emitrenderer.error(...), return thetyper.Exit(1)to raise):model_file_existshf_unauthorizedmissing_argumenttyper.Exit(1), no messagemissing_argumentDownloadExceptiontracebackDownloadException)download_failedOSError)download_faileddownload_failed+details.stage="resolve"huggingface_hubinstall faileddownload_failedRegistry (
comfy_cli/error_codes.py): addsmodel_file_existsandhf_unauthorized; widens the existingdownload_failedentry to cover modeldownloads (reused rather than adding a fourth code, per the ticket's
"reuse an exact fit" instruction).
escape()around theDownloadExceptionmessage is dropped:error_panelbuilds its body from
rich.text.Text, which is literal, so markupmetacharacters (
[/],[id]) survive without aMarkupError. The existingmarkup regression test still passes, and a new JSON-mode test asserts the raw
message reaches
error.messagebyte-for-byte.Acceptance criteria — verified live against the real CLI
Run in a temp workspace on this branch:
tests/comfy_cli/output/test_error_code_registry.pypasses (criterion 4 ✅),as does the full suite: 2780 passed, 37 skipped;
ruff check+ruff formatclean on the CI-pinned ruff 0.15.15.
Judgment call — the
model_source_unknownhard stop was NOT shippedThe ticket's plan item 2 asked for a
model_source_unknowncode on theelse: print("Model source is unknown")branch, made to stop (exit 1)instead of falling through.
That branch is not an error path — it is how a plain direct file URL is
downloaded. Before shipping a capability denial I went and attempted the
capability by every path the CLI offers:
comfy --json model download --url https://raw.githubusercontent.com/Comfy-Org/comfy-cli/main/LICENSE --relative-path models/checkpoints --filename direct.bin→ exit 0,35823bytes written to disk. This URL is neithercivitai.comnorhuggingface.co, so it takes exactly the branch the ticket wanted to turn into a dead end.main:TestDownloadCommandDownloaderOptionandTestDownloadCommandErrorHandlingintests/comfy_cli/command/models/test_models.pypatch both host checks toFalseand then assertdownload_fileis called and"Done in "is printed — the direct-URL path is already the tested contract.<404-url>(a direct URL) and requireserror.code == "download_failed". Amodel_source_unknownstop would have producedmodel_source_unknowninstead, so the plan item and the acceptance criterion are mutually exclusive.So the branch keeps working. What I did change is its misleading log line, which
is what made it read like an error path in the first place:
Model source is unknown→Model source is unknown; treating <url> as a direct file URL(stderr in
--jsonmode, so no result-shape change). Nomodel_source_unknowncode was registered — the registry test enforces both directions, so an
unraised code would fail CI anyway.
The underlying JSON-mode concern the plan item was reaching for — "there is no
interactive prompt to save it in
--jsonmode" — is real, and it is handled atthe place it actually bites: when no filename can be resolved (prompt skipped or
cancelled), the command now errors with
missing_argument+pass --filenameinstead of a bare exit or a traceback.
Other notes for review
not just
--json. The ticket offered keeping exit 0 for TTY users but calledexit-1-in-both "the simplest consistent choice," and acceptance criterion 2
states it unqualified. A script that re-ran a download to no-op on an existing
file will now see a failure exit — the friendly message is preserved (as the
red error panel with the same
File already exists: <path>text).because they violated the same invariant by crashing with a traceback and no
envelope: CivitAI metadata resolution, the Hugging Face download/
huggingface_hubinstall, and a local
OSErroron write. Each is a boundedtry/exceptthatre-raises as
typer.Exit(1)after emitting the envelope; the broadexcept Exceptionclauses cannot swallow Ctrl-C (cancellation.pyraisesKeyboardInterrupt, aBaseException).print("Error parsing CivitAI model URL")insidecheck_civitai_url'sexcept. It is a pure predicate returning a tuple, andevery outcome downstream of it now lands on a covered path (direct download →
download_failed, or CivitAI resolve →download_failed), so the invariantholds without changing that function's contract.
_download_failure'scodeis keyword-only on purpose: the registry testAST-scans for
code="..."keyword arguments, so a positional call site wouldsilently drop out of the both-ways registry enforcement.
tests/comfy_cli/command/models/test_model_download_json.py(12cases). They pin the renderer's machine/pretty streams to explicit buffers
rather than relying on
CliRunner'smix_stderr, which is a default inClick 8.1 and removed in 8.2 — so the "exactly one envelope on stdout, human
logs on stderr" assertion holds on either Click.