mirror of
https://github.com/Comfy-Org/comfy-python-sdk.git
synced 2026-11-04 23:37:47 +00:00
[PR #132] [MERGED] fix(errors): drop 409 from the status->code fallback so a code-less 409 raises ComfyError #132
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#132
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/132
Author: @mattmillerai
Created: 9/5/2026
Status: ✅ Merged
Merged: 9/14/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-9933-drop-409-status-fallback📝 Commits (4)
fe623edfix(errors): drop 409 from the status->code fallback8059666Merge remote-tracking branch 'origin/main' into matt/be-9933-drop-409-status-fallback86d7950fix: address bot review commentsfd8eab6Merge remote-tracking branch 'origin/main' into matt/be-9933-drop-409-status-fallback📊 Changes
3 files changed (+150 additions, -9 deletions)
View changed files
📝
CHANGELOG.md(+17 -0)📝
src/comfy_low/errors.py(+54 -7)📝
tests/test_error_mapping.py(+79 -2)📄 Description
ELI-5
When the server sends back an error, it normally says what went wrong in a
machine-readable
error.code. If it doesn't, the SDK guesses the code from theHTTP status. For
409 Conflictthe guess washash_mismatch— "the bytes youuploaded don't match the hash you declared, upload them again."
That guess was only ever consulted for responses that came from somewhere other
than the surface where a real hash mismatch happens, and it told those callers to
re-upload bytes over a conflict that had nothing to do with bytes. This removes
the guess. A
409that names no code is now just aComfyErrorthat says409.Real hash mismatches are unaffected whenever their body decodes —
error.codenames them and wins outright. (The exception is a body that will not parse at all; see The one path this does not cover below.)Why the guess was never reachable for a real hash mismatch
_CODE_BY_STATUSis consulted only when the response named no code of its own(
src/comfy_low/errors.py): no envelopeerror.code, and no Router bucket fromX-Comfy-Error-Type/ a top-level bodyerror_type. Verified against thevendored specs:
ErrorEnvelopelistscodeandmessageunderrequired, so every v2envelope response carries a code and the table never decides one.
spec/openapi.yamldocuments exactly two409s, both answering inErrorEnvelope:hash_mismatchonPOST /api/v2/assetsandasset_in_useonDELETE /api/v2/assets/{id}. So the contract itself already spells the statustwo ways — the status alone cannot say which even on the compliant surface.
spec/router-openapi.yamldocuments one409(RouterIdempotencyConflicton
POST /v2/models/{provider}/{model}), whose body isRouterErrorResponse—
{detail, error_type}, witherror_typerequired and noerror.code. Thatpath is decided by the bucket, which already outranks the table, so it cannot
regress here.
What is left reaching the table on a
409is therefore Router-shaped bodieswhose bucket was stripped, and intermediaries — neither bound by what any route
documents for the status.
The one path this does not cover
transport.pysetsbody=Nonewhenever the error body fails to JSON-decode — anHTML proxy page, a truncated response. Such a
409names no code, so it reachesthe table, and after this change a genuine
POST /assetsmismatch behind a brokenintermediary raises
ComfyErrorrather thanHashMismatch.That is the trade this change makes rather than an oversight: on a body that will
not parse there is nothing that distinguishes the two documented
409s, andinventing the re-upload one is exactly the guess being removed. Scoping the drop
to the ambiguous route is not available —
error_from_responsesees status, bodyand headers, not the route. Raised by the Cursor panel; the docstring now states
this explicitly instead of claiming the assets path is unconditionally unaffected.
Change
409: "hash_mismatch"removed from_CODE_BY_STATUS. A code-less409nowgets
code = "error"and stays a bareApiErrorcarrying the realhttp_status, the response's own message (orHTTP 409), andretry_after;to_sdk_errorsurfaces it as a plainComfyError.added is judged the same way: only a status with ONE meaning across every
documented surface gets a typed guess.
422and429are kept deliberately— their possible misreadings stay inside the right action class (terminal
refusal / back-off-and-retry), unlike
HashMismatch, which tells the callerto re-upload bytes.
tests/test_error_mapping.py(module docstring widened from its 404scope) pin: a code-less
409is a plainApiErrorwithcode == "error"andhttp_status == 409andto_sdk_errorgives exactlyComfyError; a code-less409keepsretry_afteron both layers; an envelopedhash_mismatch409isstill
HashMismatchon both layers; a body-less401is stillUnauthorized.Riskiest line, and why it is safe
The deletion itself. Three things independently keep it from changing anything a
caller relies on:
hash_mismatchstill wins outright(
error.codeis read before the table).tests/test_assets.py'stest_hash_mismatch_surfaced_without_blind_retry— which drives the stubserver's real
409 hash_mismatchand asserts exactly one upload attempt —passes unchanged.
is_collectablegates a409onbucket == "concurrency_limit_exceeded". The old code-less bucket washash_mismatch; the new one iserror. Neither matches, so a code-less409stays a terminal refusal under
RetryPolicyexactly as before — no newsame-key resend, no new billed generation.
status; the existing
(409, "invalid_input")andconcurrency_limit_exceeded → ConcurrencyLimitExceededcases still pass._CODE_BY_STATUShas exactly one reader (error_from_envelope), so there are noother call sites to update.
Sweep of what is not changed
I re-read all 6 remaining entries in the table against the admission rule this
PR writes down. None fails it the way
409did: where a status is spelleddifferently by the two surfaces (
404not_foundvs Router'smodel_not_found;403forbiddenvsnot_enabled) the readings stay inside one action class —terminal, fix-the-request — and none of them directs the caller at a distinct
mutating action the way
HashMismatchdirects a re-upload. So no further entryis dropped here, and this is a deliberate finding rather than an unexamined
remainder.
Behaviour change for callers
A caller catching
HashMismatcharound a409that arrives without anerror.codenow seesComfyErrorinstead. That is the intended fix, and it isdocumented in the CHANGELOG. Every
409the contracts actually document — bothenveloped ones and the Router one — is unaffected.
Verification
Full required CI set locally on the worktree, plus the two non-
test-job gates:ruff check .— cleanruff format --check .— 51 files already formattedmypy src— no issues in 19 source filespytest— 722 passed, 4 skipped (the 4 are the env-gated live gateway suite)scripts/check_public_repo_hygiene.py— no internal-only referencesscripts/check_drift.py— models in sync, all 15 router error types coveredResidual
{detail, error_type}→ typedRouterErrorwork is out of scopehere and is only partly landed on
mainalready.error_from_envelopepreserves the bucket and
to_sdk_errorselects the typed subclass, soexcept NotEnabledand friends do fire — but that depends on the bucketactually reaching this function via the
X-Comfy-Error-Typeheader or atop-level body
error_type. A Router-shaped409that reaches the SDK withits bucket stripped (an intermediary that drops response headers and rewrites
the body) still decodes off the status table, which after this PR means a plain
ComfyErrorrather than theConcurrencyLimitExceededit really was. This PRdeliberately does not try to recover a bucket that is not on the wire; it only
stops the table from asserting a wrong specific cause. Anyone picking this up
should look at whether
{detail, error_type}bodies on the/models/runpathneed a stronger identification path than the bucket alone.
documented surfaces in the two vendored specs, not a measurement of live
traffic. If an intermediary is in practice minting bucket-less
403s or404sthe way the record shows it mints
409s, the same argument would apply tothose entries and it would be visible in production error telemetry, which I
cannot query from here.
findings write-up, are in an internal tracker I have no access to — I could not
read them and reconstructed the reasoning from the vendored specs and the code
instead (which is what the empirical section above is). The prior change that
added
request_idand madeto_sdk_errorforwardretry_afterwas open whenthis work was specified; I confirmed it is merged by reading
main(
src/comfy_sdk/exceptions.py), not by reading that PR, and added theto_sdk_errorretry_after assertion accordingly. No live server was called —the suite is stdlib-stub-driven by design.
Provenance
Summary by CodeRabbit
409) responses without an explicit error code: they now remain generic errors instead of being classified as hash mismatches.422,429, andRetry-Afterresponses.401) responses continue to be identified correctly.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.