feat: add the typed router exception hierarchy for the models surface - #75
Merged
Merged
Conversation
One exception class per error_type in the router's closed error set, so a caller writes `except ContentPolicyViolation` instead of inspecting a string. The class name is the PascalCase of the wire value, always — that mechanical rule is what keeps this list and the TypeScript SDK's identical without either side maintaining a second table, and a test asserts it over every class. Every class derives from RouterError, which derives from ComfyError, so a caller can catch at whichever width they want. An error_type this version has never heard of raises RouterError itself carrying the raw value, rather than failing to decode: the set grows on the server's release cycle while an SDK is pinned by its users. The per-field validation body arrives as ValidationErrorDetail entries on `.errors` with loc/msg/type/ctx/input readable as data. The message summarises them for a human in addition to — never instead of — the entries, because flattening them is what loses the per-field branch a caller writes. X-Comfy-Request-Id is attached to every exception built from a response, so a user reporting a failure has the id to quote. The bucket is read off X-Comfy-Error-Type first and the body's error_type second, since the per-field body carries no error_type of its own.
|
Warning Review limit reachedNext included review available in 1 minute. View limit detailsLimit details: You’ve used the included review currently available. Your 108 included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
Comment |
robinjhuang
approved these changes
Aug 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ELI-5
When a model run fails, the server says why in one short machine-readable word —
content_policy_violation,provider_timeout, and nine others. Today a Python caller would have to compare that word to a string. This adds one exception class per word, so you writeexcept ContentPolicyViolation:instead. Every one of them is also aRouterError, so you can catch them all at once when you don't care which. And when a newer server sends a word this version has never heard of, you still get a catchable exception with the word on it — the client does not fall over at the exact moment something has already gone wrong.What this adds
src/comfy_sdk/router_exceptions.py— the typed hierarchy for theclient.modelssurface, pluserror_from_response(status, headers, body), which turns a response into the right exception.The intended class names, so both SDKs implement one list rather than two. The rule is mechanical: the class name is the PascalCase of the wire value, always.
test_class_names_are_the_pascal_case_of_the_wire_valueasserts it over every class, so a hand-picked name cannot creep in later on either side.error_typeinvalid_inputInvalidInputcontent_policy_violationContentPolicyViolationprovider_errorProviderErrorprovider_timeoutProviderTimeoutinsufficient_creditsInsufficientCreditsmodel_not_foundModelNotFoundunauthorizedUnauthorizedforbiddenForbiddenconcurrency_limit_exceededConcurrencyLimitExceededclient_disconnectedClientDisconnectedinternal_errorInternalErrorBase is
RouterError, which derives fromComfyError, soexcept ComfyErrorstill covers the whole SDK. The three deferred types (file_download_error,cancelled,queue_timeout) are deliberately absent and a test pins their absence — adding one should be a decision someone makes on purpose, not a constant that quietly widens a set two SDKs generate from.Per-field validation detail survives as data. A body whose
detailis an array becomesValidationErrorDetailentries on.errors, each withloc(a tuple, integer array indexes kept as ints),msg,type,ctxandinput.typeis where the specific provider reason lives —image_too_small,file_too_large,missing— which is the granularity the coarse bucket cannot express, so it is an open string rather than an enum. The exception message summarises the entries for a human in addition to, never instead of, the entries themselves.X-Comfy-Request-Idis on every exception built from a response, as.request_id, so a user reporting a failure has the id to quote.An unrecognised
error_typeraisesRouterErroritself with the raw value on.error_type, rather than failing to decode. Treat one likeinternal_error; becauseInternalErroris also aRouterError, a caller who wrote the broad catch gets both.Judgment calls
X-Comfy-Error-Typefirst, the body'serror_typesecond. The per-field validation body carries noerror_typeof its own, so the header is the only place its bucket appears. Written from one value by one writer server-side, the two cannot normally disagree; the header wins if they ever do.except Unauthorizedshould still fire for a rejected key.400is deliberately excluded: it carries eitherinvalid_inputorcontent_policy_violation, and those differ in whether a retry can ever succeed — guessing between them would tell a caller to retry a deterministic refusal.422is excluded because the contract pins no bucket to it. Both fall through toRouterError, which is the honest answer. This mirrors the existingcomfy_low.errors._CODE_BY_STATUSprecedent in this repo..errorslives on the base class, not on one subclass. The bucket a per-field failure carries is read off the header, and the server-side producer for that response is a later story, so the entries have to survive whichever bucket it turns out to be. Nothing in this change depends on guessing it.comfy_sdk/__init__.py, on purpose.Unauthorized,ForbiddenandInsufficientCreditsalready exist there for the workflow surface. One name cannot be two classes, and quietly redefiningcomfy_sdk.Unauthorizedwould change what an existingexceptclause catches. Import the router ones fromcomfy_sdk.router_exceptions, or catchComfyErrorto cover both surfaces. A test pins that the two families stay distinct. If the preference is for acomfy_sdk.modelssub-namespace instead, that is a one-line move and worth settling before the first release that ships it.error_from_responsenever raises. Every field is tolerated missing or wrongly-typed, because this code runs while already handling a failure — dropping the two fields that did arrive because a third was malformed helps nobody. 12 malformed-body shapes are tested, includingNone, an HTML string,b"", a bare list, and adetail[]entry whose every field has the wrong type.Retry-Afteron the 429, so nothing was invented here.What is deliberately NOT here
This hierarchy is not yet wired to a call site, because there is no router call to wire it to.
client.modelslanded in #69 as the namespace plus a read-only view of the host client's configuration; it has norun()yet.error_from_responseis therefore reachable only from tests today, and the story that adds the model-run call is the one that must route its error responses through it. That is the shape the ticket asks for — the exception hierarchy lands first, generated against the server-side mapping — but it is the one thing a reviewer should not assume is covered.Verification of the name list — the part that could not be taken on faith
The whole value of these names is that they match the server's set exactly, and a wrong string fails silently: every exception would degrade to the base class and no caller's
exceptclause would ever fire. So the eleven values were not inferred from the story text. They were read off the authoritative server-sideerror_typedeclaration and the canonical API contract that defines the two error body shapes and the two response headers, and cross-checked against each other: the six request-level buckets, the five transport-level ones, the three deferred values held out, the per-field entry'sloc/msg/type/ctx/inputfields, and the status paired with each bucket (which is where this PR's status column and the status-fallback table come from). No value in this change is a guess.The TypeScript SDK has not implemented its half yet — there is no
error_typeor router error handling on itsmaintoday — so this list is the proposal it should mirror, not a match against an existing one. That cross-check is still open.Unexercised artifacts
spec/openapi.yaml(v2.0.0) does not carry the router routes at all, soscripts/check_drift.pydoes not cover this module and every test drives a stubbed response rather than a server.Verification
pytest258 passed / 4 skipped (78 of them new).ruff check,ruff format --check,mypy src,python3 scripts/check_drift.pyandpython3 scripts/check_public_repo_hygiene.pyall clean.One note for whoever runs the checks locally:
uv run ruff …without--extra devresolves an unpinned ruff, and a newer ruff reformats the Python code blocks insideREADME.md, which the pinnedruff~=0.15.22does not touch. That produced an unrelated 24-line README diff here that was reverted. Useuv run --extra dev ruff …, which is what CI installs.Provenance