mirror of
https://github.com/Comfy-Org/comfy-python-sdk.git
synced 2026-11-04 23:37:47 +00:00
[PR #157] [MERGED] fix(errors): make except RouterError catch every Router refusal #157
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#157
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/157
Author: @mattmillerai
Created: 9/16/2026
Status: ✅ Merged
Merged: 9/19/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-14632-router-error-hierarchy📝 Commits (6)
868df8dfix(errors): makeexcept RouterErrorcatch every Router refusal9253dceMerge remote-tracking branch 'origin/main' into matt/be-14632-router-error-hierarchyecffbb1docs(errors): state the untyped fallbacks and the constructor break20ef7c5Merge remote-tracking branch 'origin/main' into matt/be-14632-router-error-hierarchye41dcfetest(errors): cover the three queue buckets in the refusal inventory5c8bab7fix(errors): gate the cancel-refusal lookup on 409📊 Changes
10 files changed (+868 additions, -168 deletions)
View changed files
📝
CHANGELOG.md(+35 -0)📝
README.md(+62 -1)📝
src/comfy_low/errors.py(+73 -3)📝
src/comfy_sdk/__init__.py(+12 -0)➕
src/comfy_sdk/_errors.py(+104 -0)📝
src/comfy_sdk/exceptions.py(+142 -138)📝
src/comfy_sdk/router_exceptions.py(+105 -18)📝
tests/conftest.py(+11 -0)➕
tests/test_exception_modules.py(+309 -0)📝
tests/test_router_exceptions.py(+15 -8)📄 Description
ELI-5
The SDK had two sets of exception classes that shared three names. The one you
could import and would obviously write a handler for was not the one the SDK
actually raised. So this:
compiled, type-checked, read as a correct catch-all, and caught nothing. A
workspace with no credits got an unhandled exception in production instead.
The three shared buckets are now one class each, so either import path
catches what the other does and
except RouterErrorcatches every Routerrefusal.
The collision, before and after
comfy_sdk.exceptionsandcomfy_sdk.router_exceptionsboth exportedUnauthorized,ForbiddenandInsufficientCredits— as different classes.to_sdk_errorraised theexceptionsones, which descended fromComfyErrorbut not from
RouterError, so the two hierarchies were siblings and neithercaught the other.
The root cause was a circular import.
router_exceptionsneedsComfyErrorfrom
exceptions;exceptionstherefore could not name aRouterErrorsubclass, so it grew its own three beside the Router ones.
ComfyErrormovesinto a new, tiny
comfy_sdk/_errors.py, which breaks the cycle and lets thedependency run one way:
_errors→router_exceptions→exceptions. Nothingabout
ComfyErroritself changed, andcomfy_sdk.exceptions.ComfyErroris thesame object it was.
What changed
Unauthorized,Forbidden,InsufficientCreditsare defined inrouter_exceptionsand re-exported fromexceptions. Every existing import keeps working and keeps catching.RouterError, not a bareComfyError.ApiError.error_typeis new: it records the bucket the responsenamed (
X-Comfy-Error-Type, or the body'serror_type), which is also howthe SDK tells a Router response from a v2 one. Without it,
except RouterErrorwould still have had a hole on exactly the refusals nobody canenumerate in advance.
RouterErroris exported from the package root, withCancelRefusedandAlreadyCompleted. The fifteen per-bucket classes stay incomfy_sdk.router_exceptions— one import path for the whole closed setrather than half of it in each place.
409 {"status": "ALREADY_COMPLETED"}names no bucket and no code at all, so it arrived as anuntyped
409that could only be identified by substring-matching the responsebody. It now raises
AlreadyCompleted, under aCancelRefusedbase so asecond refusal shape is one table entry plus one subclass rather than a second
substring match.
import path, and states the consequence below.
The consequence worth reviewing
One class cannot descend from
RouterErroron one surface and not on the other.So a workflow call that fails
401/403/402now raises aRouterErrorsubclass too.
except Unauthorized/except Forbidden/except InsufficientCredits(from either module) andexcept ComfyErrorareunchanged; only
except RouterErrorsees more than its name suggests, and onlyfor those three buckets. This is the smaller of the two wrongs — the alternative
leaves a documented, importable, inert handler in place — and it is called out
in the README, the CHANGELOG and both module docstrings.
The one other behaviour interaction, spelled out at
ApiError.error_type:retry.error_bucket_ofreadserror_typebeforecode, so it now answers withthis attribute on a bucketed response. That is the same string either way — the
two error contracts are mutually exclusive on the wire (Router's body is
{detail, error_type}and carries noerror.code; a v2 envelope carrieserror.codeand no bucket), socodeis set from the bucket whenever thereis one.
should_retryitself is status-driven and references no exception class,so nothing about what is retried moved. The 1,300-line retry suite is unchanged
and green.
Acceptance
except RouterErrorcatches every bucket, asserted per bucket —tests/test_exception_modules.pydrives all 15 contract buckets plus adeferred one and an invented one through a real stubbed call, on
models.submit,models.runand (for the shape the report reproduced)models.subscribewith a402. Sync and async.directions, from a real refusal.
is not the same object — over
__all__anddir(), because a class leftout of
__all__is still importable and still as dead a handler. Plus aguard against that test passing vacuously if the modules stopped sharing
names, and a separate assertion that the merge landed on the
RouterErrorside (identity alone would be satisfied by merging the wrong way).
ALREADY_COMPLETEDraisesAlreadyCompleted, sync and async, with notest matching on body text. Covered alongside: a
409naming a real bucketis still decided by that bucket, and a
409naming no status at all staysuntyped rather than being guessed at.
Corpus swept: 3 colliding names across the two modules, 3 fixed, 0
remaining (that is the assertion in criterion 3). Router buckets reachable as a
RouterErrorthroughto_sdk_error: 15 of 15, was 12 of 15, plus unknownbuckets which were 0 of ∞ and are now covered by the base class.
Residual
different repository; this PR does not cross repo boundaries). Confirmed by
reading it, not assumed:
sdk/exceptions.tsdefinesInsufficientCredits,UnauthorizedandForbiddenextendingComfyError,sdk/routerErrors.tsdefines three more of the same names extending
RouterError, andBY_CODE.insufficient_creditspoints at theexceptions.tsone — somodels.runthrows it andcatch (e instanceof routerErrors.RouterError)isdead there exactly as it was here.
sdk/models.test.tspins that behaviour.It is worse in one respect:
RouterError extends Error, notComfyError, sothe
catch ComfyErrorescape hatch this SDK offered does not exist there.The same three names, same three classes. Worth its own ticket against that
repo.
from this repo. Reported as not-found rather than as absent: if one exists
elsewhere it has not been checked.
production with a key bound to a credit-less workspace; this environment has
no such credential, and a live Router call is not something to conjure for a
test. The refusal is reproduced instead against the repo's stub server in the
exact shape Router sends (bucket on the header and in the body, no v2
envelope), on the same call the report used.
(a path under a reporter's own home directory), so the workaround it documents
was not read first-hand. The workaround it is described as using — catching
ComfyErrorand branching ontype(exc).__name__— is made unnecessary bythis change, but that is inferred from the description, not verified against
the file.
this environment. One is described as adding a second cancel-refusal shape
("in-flight tasks cannot be cancelled"); its wire shape is unknown, so no
entry was invented for it.
CancelRefusedplus the one-entry-per-shape tablein
comfy_low.errorsis the seam it lands in. The other is described as e2ecredential coverage that asserts on the wire because of this bug; whether
those assertions can now be rewritten against classes was not checked.
CancelRefusedis not itself raised by anything today, deliberately.Unlike an unknown bucket — where the response still named a bucket, so
RouterErroris an honest answer — an unrecognised refusal shape carriesnothing identifying it as a refusal: it is a
409with no bucket and no code,indistinguishable from any other. Those stay untyped
ComfyErrors rather thanbeing guessed at. The class is there as the catchable base for the family.
Provenance
uv run --extra dev pytest: 931 passed, 4 skipped, 0 failed(65 of those in the new
tests/test_exception_modules.py);ruff check .: clean;ruff format --check .: 56 files already formatted;mypy src: no issues in 21 files;scripts/check_drift.py: models in sync,all 15 router error types covered and blessed, run route matches the spec;
scripts/check_public_repo_hygiene.py: no internal-only references. Onepre-existing test was inverted rather than deleted:
test_the_shared_names_are_not_the_workflow_surface_classesasserted thecollision this PR removes, and now asserts the identity, with a comment saying
why it flipped.
reported, not fixed — see Residual. No Go SDK was found to check.
Summary by CodeRabbit
New Features
AlreadyCompletedfor cancellation attempts after work has finished.Documentation
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.