mirror of
https://github.com/Comfy-Org/comfy-python-sdk.git
synced 2026-11-04 23:37:47 +00:00
[PR #154] [MERGED] fix(errors): surface a Router 422's detail[] on RouterError.errors from models.run #155
Labels
No labels
audit:ai-readiness
audit:community
audit:github
audit:health-scoring
audit:policy
audit:release
audit:supply-chain
effort:medium
effort:small
effort:small
effort:trivial
priority:nice-to-have
priority:nice-to-have
priority:recommended
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Comfy-Org/comfy-python-sdk#155
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
📋 Pull Request Information
Original PR: https://github.com/Comfy-Org/comfy-python-sdk/pull/154
Author: @mattmillerai
Created: 9/15/2026
Status: ✅ Merged
Merged: 9/18/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-14332-router-422-detail📝 Commits (1)
6fe83a4fix(errors): surface a Router 422's detail[] on RouterError.errors from models.run📊 Changes
6 files changed (+276 additions, -5 deletions)
View changed files
📝
CHANGELOG.md(+6 -0)📝
src/comfy_low/errors.py(+56 -1)📝
src/comfy_sdk/exceptions.py(+16 -0)📝
tests/conftest.py(+21 -0)📝
tests/test_error_mapping.py(+64 -4)📝
tests/test_models_run.py(+113 -0)📄 Description
ELI-5
When a model says "your
stepsis too big and yourmodelname is unknown", Comfy Router answers with a list — one entry per field it objected to. The SDK promises those land onRouterError.errorsso you can branch on them. On the queued surface they did. Onmodels.runthey did not: the list fell through a helper that only accepts strings, so you gotInvalidInput(detail='HTTP 422: {"detail":[…]}', errors=())— the reasons pasted into the message as raw JSON, and nothing to read as data. This makesrunbehave like the queued path.What changed
The layering rule shapes the fix:
comfy_lowmay not importcomfy_sdk(pinned bytests/test_error_contract.py), andValidationErrorDetaillives incomfy_sdk. So the entries are carried up raw and converted at the translation boundary.comfy_low/errors.py—ApiErrorgainsvalidation_errors: the raw, decodeddetail[]entries that are mappings, in wire order, empty everywhere else.error_from_envelopenow recognises the array form ofdetail(a non-str/bytesSequence), collects those entries, and — only when nothing else supplied a message — uses theirmsgvalues joined with"; "as the message. Because a message is now found, the existing "the excerpt is kept only where the body stated no message" rule drops the raw-JSONbody_excerpt, so it stops being glued ontostr(exc).comfy_sdk/exceptions.py—to_sdk_errorpasseserrors=tuple(_detail_from(e) for e in exc.validation_errors)on theRouterErrorbranch, importing_detail_fromlazily for the same reason_router_only_classimports_BY_ERROR_TYPElazily (router_exceptionssubclassesComfyErrorfrom this module).error_from_envelopecase is flipped to assert the entries are read (its original intent, "no Python repr in the message", is re-asserted as"[" not in err.message); new low-layer cases cover an entries-state-no-message body, a stringdetail, and anerror.messagethat outranks the array; new end-to-endmodels.runcases (sync + async) assert.errors,.loc,.msg,.ctx,.input,.detail,.error_type,.http_statusand the raw entries on__cause__; plus a non-mapping-members case and a test that the awaited and queued paths produce identical.errorsfor one wire body.tests/conftest.py— the stub server gainsmodel_run_validation_detail, which answers the run with Router's validation shape ({"detail": [...]}, bucket on the header, noerror_typein the body — the one Router error body that carries no bucket of its own).Chesterton's fence: this does change existing behaviour — a list
detailused to produceHTTP <status>plus an excerpt. The excerpt rule itself is untouched and is what makes the change consistent: an excerpt is dropped exactly when the body stated a message, which it now does. Theerror.message→ Routerdetail→ status precedence is unchanged, and there is a test for each rung.Deliberate judgment calls
msgvalues only, notlocation: msg.router_exceptions._summarise(the queued path) rendersbody.steps: too large; replicating that incomfy_lowwould mean duplicating the location-formatting rule across the layer boundary..errors[i].loccarries the location as data on both paths and a test pins that the two paths produce identical.errors. Consequence, stated plainly: thedetailstring now differs in format between the awaited and queued surfaces. See Residual._detail_fromkept private rather than promoted todetail_from; the cross-module private import matches the_BY_ERROR_TYPEprecedent two functions above it, and nothing outsidesrc/uses either name.httpx.MockTransport. There is noMockTransportusage anywhere in this repo, and the stub exercises the realcomfy_lowtransport, so the assertion covers the actual decode path rather than a hand-fed body.Verification
uv run --extra dev pytest— 874 passed, 4 skipped.uv run --extra dev mypy src— no issues in 20 files.uv run --extra dev ruff check ./ruff format --check .— clean.uv run --extra codegen python scripts/check_drift.py— models and all 15 router error types in sync.python3 scripts/check_public_repo_hygiene.py— clean. (The four gatesAGENTS.mdnames, plus the lint/format pair.)The pre-fix behaviour was reproduced on this branch before the fix landed, not assumed: the first run of the new non-mapping test failed with
assert 'HTTP 422: {"detail": [1, "x"]}' == 'HTTP 422', which is exactly the shape reported for the current default branch.Residual
Not fixed here, and each is actionable on its own:
.errorsis still empty when adetail[]arrives on a non-RouterErrorclass.to_sdk_errorpasseserrors=only on theRouterErrorbranch, becauseComfyError,QueueFulland the_BY_CODEclasses take no such parameter. Two real bodies reach that branch: a422carryingdetail[]but noX-Comfy-Error-Typeheader (the status table guessesinvalid_workflow→InvalidWorkflow), and a429bucketedqueue_full. Both now get the entries' messages as the message, but the typed entries are dropped. Fixing it means either anerrorsattribute onComfyErroror a wider retype, which is a public-surface decision rather than a bug fix.detailstring differently for the same wire body.rungivestoo large; unknown model variant; the queued path's_summarisegivesbody.steps: too large; body.model: unknown model variant..errorsis identical on both (asserted). Converging the two means either lifting the location formatting intocomfy_lowor moving the summary up to the translation boundary.router_exceptions.error_from_responsestill has zero callers insrc/. It reads this same wire shape a third time, alongsideerror_from_envelopeanderror_from_completion. This PR did not consolidate them — it would touch the retry and transport paths as well.body_excerpt(clean_body_excerpt) andrequest_id(clean_request_id). This is not new — a stringdetailhas always reachedstr(exc)the same way — but the array form now brings N messages instead of one, so a hardening pass over "message text that reaches a log line" would be worth its own change.spec/router-openapi.yaml, which documentsRouterValidationErrorDetailand the header-only bucket on the422) and the stub server built to match it. The measurement this work is based on was made elsewhere and is reproduced above from the pre-fix failure, not re-measured against a real deployment.Provenance
uv run --extra dev pytest: 874 passed, 4 skipped;uv run --extra dev mypy src: no issues in 20 source files;uv run --extra dev ruff check .andruff format --check .: clean;uv run --extra codegen python scripts/check_drift.py: models and all 15 router error types in sync;python3 scripts/check_public_repo_hygiene.py: cleanmsgonly rather than the queued path'slocation: msgformat;_detail_fromwas left private; the end-to-end tests drive the repo's stub server rather thanhttpx.MockTransport. One planned assertion was corrected rather than implemented: for adetailarray whose members are not objects, the plan expecteddetail == "HTTP 422", but the pre-existing body-excerpt rule keeps the excerpt precisely when the body stated no message, so the real value isHTTP 422: {"detail": [1, "x"]}and the test asserts that instead of changing the excerpt rule.Summary by CodeRabbit
Bug Fixes
Documentation
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.