refactor(http): shared capped-read request_json; migrate workflow + models search, fixing search's uncaught oversize/non-UTF-8 crashes (BE-4363) - #623
Conversation
…json Two near-identical capped-read JSON helpers had drifted apart: workflow._http_request and models.search._http_get_json. Converge them onto one shared comfy_cli.http.request_json, preserving each caller's exception surface exactly. This also fixes two latent search.py crashes. An oversize response raised a bare ValueError and a non-UTF-8 body let UnicodeDecodeError escape json.loads — neither is in the callers' (URLError, OSError, JSONDecodeError) tuple, so both took down the CLI with a traceback instead of emitting a --json envelope. Both now route through ResponseTooLarge / JSONDecodeError to the existing cloud_http_error / server_not_running envelope path. - http.py: add ResponseTooLarge + request_json (keyword-required max_bytes so each caller keeps owning its own cap constant). - workflow.py: _http_request becomes a thin wrapper (name, signature, and call-time _HTTP_MAX_BYTES read preserved); _ResponseTooLarge becomes a top-level alias of the shared class so all ten catch sites keep working. - search.py: _http_get_json wraps request_json and raises JSONDecodeError for an empty/unparseable body; ResponseTooLarge added to all four except-tuples.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 17 minutes 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 (6)
Comment |
📄 Knowledge reviewDosu skipped reviewing this PR because your organization has used its |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 8 finding(s).
| Severity | Count |
|---|---|
| 🟠 High | 1 |
| 🟡 Medium | 4 |
| 🟢 Low | 2 |
| ⚪ Nit | 1 |
Panel: 7/8 reviewers contributed findings.
Reviewers that did not contribute: gemini-3.1-pro:adversarial (error)
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 6 finding(s).
| Severity | Count |
|---|---|
| 🟠 High | 1 |
| 🟡 Medium | 2 |
| 🟢 Low | 3 |
Panel: 8/8 reviewers contributed findings.
…auth headers (BE-4363) Addresses cursor-review findings on PR #623: request_json now opens through the shared NoRedirectHandler-backed opener (matching every other authenticated call site) instead of the default redirect-following opener, and gates on assert_safe_url before attaching auth headers (moved from comfy_client into http.py so both share it). Also guards non-dict response bodies at the models/search.py and workflow.py call sites that were indexing with .get() unconditionally, catches RecursionError alongside the other unparseable-body cases, and validates max_bytes >= 1. Also fixes an unrelated pre-existing CI failure: two independent PRs each registered an ErrorCode("server_died", ...) entry, tripping the no-duplicate-codes registry test; merged into one entry covering both call sites (both already emit the same "server_died" code at runtime). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR: |
|
This PR currently conflicts with Please rebase (or merge |
Resolve conflicts in http.py, comfy_client.py, error_codes.py, search.py, and workflow.py: keep main's http(s)-only opener infrastructure and its already-shipped ResponseUnparseable/ workflow_unparseable distinction for workflow.py's _http_request, and layer this branch's request_json/ResponseTooLarge helper on top of main's shared _AUTHED_OPENER for search.py's migration.
|
Rebased — merged |
bigcat88
left a comment
There was a problem hiding this comment.
Approving. Every failure mode in the description reproduces on main and is fixed here — I drove the real CLI against a stub serving each malformed body shape, including a genuinely oversize one (71.5 MB, past the 64 MiB cap).
models — all three crashes are real
main this branch
null -> ok, exit 0, count=0 (list-folders) cloud_http_error
-> AttributeError: 'NoneType' has no attribute 'get' (search, show)
nonutf8 -> UnicodeDecodeError, no JSON on stdout cloud_http_error
oversize -> ValueError: response from …, no JSON on stdout cloud_http_error
Confirmed on list-folders, search --text foo, and show independently. The two tracebacks exit non-zero with nothing on stdout, so a --json caller gets neither a result nor an error object — the worst shape for an agent.
The null row is the intentional behavior change, and your read of it is right: list-folders reporting ok, count=0 for a null body silently presents a malformed response as "this backend has no folders", which is a wrong answer rather than a crash. Turning all three into one envelope is the correct call.
The workflow side is byte-identical — the refactor didn't regress it
This is what I most wanted to check, since _http_request keeps its name and ten except sites:
body main this branch
good ok ok
null ok ok
empty ok ok
html workflow_unparseable workflow_unparseable
nonutf8 workflow_unparseable workflow_unparseable
oversize workflow_too_large workflow_too_large
Identical across all six. Aliasing _ResponseTooLarge to the shared class rather than replacing it is what keeps those except sites and the two isinstance checks working untouched, and reading _HTTP_MAX_BYTES at call time is what keeps the existing monkeypatching tests honest.
Guards check out
assert_safe_url: ALLOW https://… , http://127.0.0.1 , http://localhost , http://[::1]
REJECT http://evil.example.com , http://10.0.0.5
And end to end: a cloud Target carrying an api_key against http://evil.example.com is refused before the request is sent. Gating on if headers: is the right shape — an unauthenticated local call over plain http stays legal, which is exactly the local-ComfyUI case.
max_bytes rejects 0 and negatives rather than silently reading nothing. Making it keyword-required with no default is the right call: a shared default would quietly re-unify the two caps this PR is careful to keep separate.
Reading max_bytes + 1 so a complete body is distinguishable from a truncated one is the small detail that makes ResponseTooLarge trustworthy rather than a guess.
Notes
Deleting search._authed_request is correct — it had no other caller, and leaving it plus its import would trip ruff F401. Keeping workflow._authed_request because _userdata_request still uses it is the right asymmetry.
Widening the return annotation to tuple[int, dict | list | None] is a truthfulness fix; json.loads could always return a list there.
Good call leaving NoRedirectHandler attachment out of scope — that would change behavior at these call sites rather than dedupe them.
Full suite green on this branch merged with current main; ruff check + ruff format --diff clean under the CI-pinned 0.15.15.
ELI-5
Two places in the CLI did the exact same thing: "call an authenticated URL, refuse to read more than N bytes, hand back the parsed JSON." They had drifted into two separate copies. This merges them into one shared helper.
The interesting part is what the second copy got wrong.
comfy models …capped its reads, but when a response blew past the cap it raised a plainValueError— and none of its four error handlers catchValueError. Same story for a response body that isn't valid UTF-8:json.loadsraisesUnicodeDecodeError, which is aValueErrorbut not aJSONDecodeError, so it slipped past those handlers too. Either one crashed the CLI with a Python traceback and no--jsonenvelope at all. Both now come out as a normalok:falseenvelope.What changed
comfy_cli/http.py— newResponseTooLargeexception andrequest_json(url, target, *, method, body, timeout, max_bytes). It reads one byte past the cap so a complete body is distinguishable from a truncated one, raises urllib errors verbatim so callers keep mapping them to their own envelope codes, and returnsNonefor an empty or unparseable body.max_bytesis keyword-required with no default, so each caller keeps owning its own cap constant rather than inheriting a shared one by accident.comfy_cli/command/workflow.py—_http_requestis now a thin wrapper that delegates. Its name, signature, and exception surface are unchanged, and it still reads the module-global_HTTP_MAX_BYTESat call time (so the existing tests that monkeypatch that constant keep working). The local_ResponseTooLargeclass is now a top-level alias of the shared one, which keeps all tenexceptsites and the twoisinstancechecks working untouched. A class used in anexceptclause at module scope can't be lazy-imported, so this is a real top-level import —comfy_cli.httpis stdlib-only, andsearch.py/jobs.pyalready pullurllib.requestin at import time, so it doesn't change the import-cost picture.comfy_cli/command/models/search.py—_http_get_jsonwrapsrequest_jsonand preserves its contract (returns the parsed body, raises decode errors verbatim).ResponseTooLargejoins(URLError, OSError, JSONDecodeError)in all four caller except-tuples, which routes the oversize case to the existingcloud_http_error/server_not_runningpath with the exception message interpolated — so the helper raises a descriptive message (response from <url> exceeds <n> byte cap) rather than a bare exception._emit_http_errorand every error mapper are untouched, as arecomfy_client.py,jobs.py, andworkflow._userdata_request(raw bytes, not JSON — out of scope). Redirect behavior is unchanged:request_jsonuses the default opener exactly as both helpers it replaces did. Deliberately not attachingNoRedirectHandlerhere — that would be a behavior change to these call sites and belongs in its own ticket.Judgment calls
One intentional behavior change.
request_jsoncannot distinguish a literal JSONnullbody from an unparseable one — both parse toNone. Forworkflowthis is a no-op (its old code returned(status, None)fornulltoo). Forsearchit means anullbody now raisesJSONDecodeError→ error envelope. Previouslymodels list-foldersreportedok:true, count:0for anullbody, which silently presented a malformed response as "this backend has no folders";models search/models showcrashed outright with anAttributeErroronbody.get(). An error envelope is the right answer in all three cases. Pinned bytest_literal_null_body_yields_envelope.Deleted
search._authed_request(and its now-unusedimport urllib.request). The ticket said "delete nothing else," but that helper existed solely to serve_http_get_jsonand had no other caller — leaving it would be dead code, and leaving the import would failruffF401.workflow._authed_requestis kept, since_userdata_requeststill uses it.Widened
_http_request's return annotation fromtuple[int, dict | None]totuple[int, dict | list | None]. The old annotation was already inaccurate —json.loadshas always been able to return a list there — so this is a truthfulness fix, not a contract change.Testing
tests/comfy_cli/test_http.py— 12 new tests forrequest_json: oversize raisesResponseTooLargewith a descriptive message, body exactly at cap parses, empty body →(status, None), non-UTF-8 →(status, None), unparseable →(status, None), list body parses, GET sends no body/Content-Type, POST attaches both, cloud target gets auth headers, local target's header bag is empty even with stray credentials set, urllib errors propagate verbatim,max_bytesis required.tests/comfy_cli/command/models/test_search.py— a newTestMalformedResponseEnvelopesclass covering the two regressions end-to-end, plus a parametrized case asserting all four_http_get_jsoncall sites (list-folders,list-folder,search,show) route oversize to an envelope. I verified these are genuine regression tests by running them against the pre-changesearch.py: 7 of 9 fail there withno envelope on stdout(the other 2 are preservation tests for paths that already worked)._patch_urlopengainedbytes-payload support so a test can serve a body that isn't valid JSON at all.Full suite: 3095 passed, 37 skipped, 1 failed. The one failure is
test_error_code_registry.py::test_no_duplicate_codes(duplicateserver_diedcode in the registry) — I confirmed it fails identically on a cleanorigin/mainworktree, so it is pre-existing and unrelated to this change.ruff checkandruff format --diffare clean under the CI-pinnedruff==0.15.15.