mirror of
https://github.com/Comfy-Org/comfy-python-sdk.git
synced 2026-11-04 23:37:47 +00:00
[PR #97] [MERGED] feat: carry the Idempotency-Key and request id on every exception models.run raises #98
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#98
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/97
Author: @mattmillerai
Created: 8/27/2026
Status: ✅ Merged
Merged: 8/27/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-9834-run-exception-idempotency-key📝 Commits (4)
0742a44feat: carry the Idempotency-Key and request id on every exception models.run raises7c2b0bffix: address the review panel on the exception idempotency-key surfacec4d757edocs: scope the changelog claim to failed calls rather than every exception11a9f60Merge origin/main into matt/be-9834-run-exception-idempotency-key📊 Changes
10 files changed (+940 additions, -16 deletions)
View changed files
📝
CHANGELOG.md(+44 -0)📝
README.md(+58 -0)📝
src/comfy_low/errors.py(+35 -0)📝
src/comfy_low/transport.py(+38 -3)📝
src/comfy_sdk/exceptions.py(+120 -3)📝
src/comfy_sdk/models.py(+35 -4)📝
src/comfy_sdk/router_exceptions.py(+6 -1)📝
tests/conftest.py(+62 -1)📝
tests/test_error_contract.py(+188 -1)📝
tests/test_models_run.py(+354 -3)📄 Description
ELI-5
When you call
client.models.run(...), the SDK quietly makes up a one-timeIdempotency-Keyand sends it with the request. That key is your receipt: if the answer never comes back — the server gives up holding the connection and returns a504, or your connection drops mid-generation — the generation may well have finished and been billed, and asking again under the same key is how you collect it. The problem: you never saw the key. It lived in a local variable insiderun(), and whenrun()raised, it went away with the call. So the receipt was shredded at exactly the moment you needed it, and your paid-for generation was unreachable. Only people who had passed their ownidempotency_key=and written it down somewhere could recover.Now every exception
run()raises hands you the receipt back, onexc.idempotency_key. Re-run withidempotency_key=exc.idempotency_keyand you get your generation instead of paying for a second one.What changed
comfy_sdk.exceptions.translating()takes an optionalidempotency_key=, and stamps it onto whatever leaves the block. BothModels.runandAsyncModels.runpass the key they minted. That is the whole mechanism.Stamping at the translation boundary rather than in each exception's constructor is the load-bearing choice, and it buys three things a per-subclass approach would miss:
error_typethis SDK version has never heard of falls through to the bareRouterErrorbase — and still carries the key;httpx.ConnectError, a read timeout on a run the server is probably still generating), and it is exactly the "lost the response" case the recovery contract is about — it carries the key too;_STAMPABLEis(ComfyError, httpx.HTTPError, asyncio.CancelledError)— deliberately not "everything". AZeroDivisionErrorescaping the block is a bug in the SDK or in caller code, not a failed call, and a key means nothing on it; the same reasoningmodels._CANDIDATE_FAILURESalready documents for the retry classifier. It also meansKeyboardInterruptis never touched. Tested both ways.asyncio.CancelledErroris the oneBaseExceptionin that tuple, added in review: cancelling an in-flightAsyncModels.run— which is what theasyncio.wait_fora caller wraps a ten-minute call in does — abandons a generation that may already be dispatched and billed, and that is exactly the case the key exists to collect. It is re-raised bare like the rest of that branch, so nothing is swallowed and the cancellation still propagates.request_idrides along for a closely related reason.X-Comfy-Request-Idwas read off a router error response but dropped on the floor by the shared error envelope, so the id a user quotes in a support request was unreachable once the response object was gone.parse_or_raisenow reads it,ApiErrorcarries it besideretry_after(same pattern, same reason), andto_sdk_errorpasses it through.RouterError's existingrequest_idis untouched — the base now just declares the same attribute so it is uniform.retry_afterjoined them in review.to_sdk_errorhad been droppingApiError.retry_afterfor every code exceptqueue_full, which left the flow this PR documents incoherent: the docs tell a caller to replay after the pace the server named, and adeadline_exceeded504carryingRetry-After: 2had nowhere to surface it.ComfyErrornow carries it for every code. This does not touch the retry path —RetryPolicy.should_retryandclient._retry_delayboth read the protocol-levelApiErrorbefore translation, so whatto_sdk_errorproduces has never fed a retry decision.All three attributes are declared on
ComfyErrorwith aNonedefault, and_stampdefaults the readable ones onto exceptions this SDK does not build (httpx's,CancelledError) without ever overwriting a real value — soexc.request_idon a dropped connection readsNonerather than raisingAttributeError, and no caller is forced intogetattr(exc, ..., None).Additive, verified as such
translating()with no key behaves exactly as it did before the parameter existed. The newexcept _STAMPABLEbranch does a bareraise, which preserves the original traceback and exception identity;contextlibseesexc is valueand does not suppress. Pinned by a test.tests/test_models_run.pyandtests/test_models_run_retry.pystill pass unchanged.tests/test_error_contract.pynow pins the base error's whole attribute surface as a written-out list, so adding one is a deliberate edit to a reviewable list and removing one is a failing test rather than a silent break of somebody'sexceptblock.Tests
New coverage in
tests/test_models_run.py(sync and async throughout):504 deadline_exceededfromrun()exposes the exact auto-minted key — compared against the key the stub server actually received, not merely asserted truthy, because a freshly minted key is precisely the value that cannot collect the generation;httpx.ConnectError,httpx.ReadTimeout) carries the key;error_typelands on the baseRouterErrorwith the key, and a typed subclass does too;request_idpresent when the response carried the header,Nonewhen it did not, independent of the key.tests/conftest.pygains amodel_run_request_idknob so the stub can emitX-Comfy-Request-Idon a failed run. No httpx mocking —server.stateonly, per the repo's convention.tests/test_error_contract.pygains the attribute-surface pins described above,translating()'s stamp behaviour with and without a key, the not-stamped bug case, and a guard thatcomfy_lowandcomfy_sdkspellX-Comfy-Request-Ididentically (each layer needs its own copy —comfy_lownever importscomfy_sdk— so a drift would silently drop the id on one of the two surfaces).Review round (
7c2b0bf,c4d757e)Ten findings from the review panel, all addressed. Eight in code, two answered:
ComfyError— the high-severity one, and correct: a dropped connection or read timeout is one of the two headline cases the section exists for, and it arrives as anhttpxerror with no response to translate. A reader copying that snippet never reached the replay for exactly the failure it is about. Nowexcept (ComfyError, httpx.HTTPError), and it checks the key forNonebefore passing it back._stampwrote onlyidempotency_key, soexc.request_idon an httpx failure was anAttributeErrorrather than the documentedNone— on precisely the no-response failures the pair matters most on. It now defaults the whole readable surface, pinned against the base's attribute list so a fourth attribute cannot be added without it.to_sdk_errordroppedRetry-Afterfor every code butqueue_full— described above.resp.json()unguarded, so a200with a non-JSON body (a proxy interstitial, a truncated response) escaped asjson.JSONDecodeErrorfrom outside the translated surface, and so unstamped. Onmodels.runthat is a generation that ran and was billed with the result lost — the exact failure the key has to ride out on. It is now anApiError(code="invalid_response")raised at the decode site, which also gives it arequest_id.asyncio.CancelledErrorpassed unstamped — described above.X-Comfy-Request-Idwas stored verbatim from a server-controlled header, with no length bound and no filtering, and the docs tell users to display it and paste it into support tickets. Now bounded and filtered through a singleclean_request_idincomfy_low.errorsthat both error surfaces call, so an id cannot be safe to display on one and not the other. It matches the leading run of allowed characters rather than deleting disallowed ones, which gives the right answer for the duplicate-header joinhttpx.Headers.getperforms:"a1, a2"yields"a1", not a spliced"a1a2"that identifies no call at all.model_run_replays_lost_resultknob, so the existing retry tests keep the old flag's narrower meaning. The assertion is now one generation across two requests, with the failure knob deliberately left set so the second call succeeds because the key collected the recording, not because the error was switched off.rundocstring named a409"still in progress" refusal the mapping does not deliver:_CODE_BY_STATUSmaps a body-less 409 tohash_mismatch, so a caller would read a bytes-integrity failure. The docstring no longer promises a class and points atexc.retry_afterinstead. Fixing the mapping is deferred — see Residual.Answered rather than changed:
idempotency_key=Noneis rejected. Declined:Noneis the documented default meaning "mint one", sorun(m, a, idempotency_key=maybe_key)would start raising with no deprecation. The underlying footgun is real, so the idiom changed instead — the snippet and docstring both say to check forNonefirst, andtest_replaying_without_the_key_would_start_a_second_generationasserts the outcome directly so the guard is not silently dropped later.submit()'s errors too. Out of scope here and filed separately — see Residual.Every one of the ten threads has a reply explaining what was done and why, and is resolved.
CodeRabbit then raised one more against the changelog, and was right: "every exception" was literally overbroad.
_STAMPABLEis deliberately not "everything" — a programming error escaping the call reaches the caller untouched, andKeyboardInterruptis never touched either, both with tests asserting it. The entry now names the boundary as the failed call rather than the exception type, which is the property the design actually holds and the one that stays true if another call-failure type is added later. Fixed inc4d757e; thread resolved.Judgment calls
ComfyError, and transport errors — which is what_STAMPABLEimplements. A programming error propagates unstamped, on purpose, and there is a test asserting that. Flagging it because it is a readable-two-ways criterion, not because I think the other reading is right.request_idwas added atparse_or_raise, which every transport method shares — not just the model-run path. It is one line and purely additive (a new attribute defaulting toNone), and scoping it to one route would have meant the same header meaning two different things on two surfaces.RouterError-carries-the-key assertion lives intests/test_models_run.pyandtests/test_error_contract.py, not intests/test_router_exceptions.pywhich the issue also names. That file testserror_from_responsein isolation, where no key exists yet; the property under test is about whatrun()produces, so it belongs whererun()is driven._stampusessetattr, which is the riskiest line in the diff. It is safe becauseBaseExceptiondoes not define__slots__, so every exception instance — including httpx's, which this SDK does not construct — has a__dict__and cannot raiseAttributeErrorhere. A raise inside thatexcepthandler would have masked the caller's real error, which is why it is worth stating.Negative-claim falsification
Not applicable, checked deliberately: this diff denies no capability. It adds one, contains no
not supported/unavailable/ dead-end path, and flips no test to assert an absence. Every new test asserts a capability now works.Provenance
7c2b0bf(c4d757eis changelog prose only, no code) —uv run --extra dev pytest: 598 passed, 4 skipped (integration suite skips without live credentials, as designed);ruff check .: all checks passed;ruff format --check .: 51 files already formatted;mypy src: no issues in 19 source files;scripts/check_public_repo_hygiene.py: OK. The 20 new tests are the review round's coverage: the request-id sanitiser (including the duplicate-header join and a terminal escape), both layers cleaning through the same function,retry_afterforwarded for a non-throttled code, every stamped attribute readingNoneon a transport failure, the stamped-attribute list pinned against the base's readable surface, a cancelled call stamped and still cancelled,KeyboardInterruptstill untouched, the undecodable success body, and the rewritten replay test plus its negative.submit()(deferred to the filed follow-up). CodeRabbit's changelog finding was accepted and fixed inc4d757e.Residual
Three of the five auto-minting sites are not covered by this change, and the same gap exists on them. I swept
new_idempotency_keyacrosssrc/to size the fix: five call sites mint a key the caller never supplied. Two areModels.run/AsyncModels.runand are fixed here. The other three are not, and an exception from any of them still drops the key:src/comfy_sdk/client.py—Comfy.submit()andAsyncComfy.submit()(two sites). These do not go throughtranslating(); they hand-rollto_sdk_errorinside their own429retry loop, so the one-line fix used here does not apply and it is a separate edit.src/comfy_sdk/assets.py— the asset uploader mints one key per uploader instance.I left them alone deliberately rather than by oversight: the recovery story differs. A submitted job is created and pollable by id, so the route back is
jobs.get(...)rather than a same-key replay, and whethersubmit()'s exception surface should grow the attribute is a real API decision rather than a mechanical extension. It is worth its own issue, and the count above is the size of it. A follow-up has now been filed for the twosubmit()sites, raised again by the review panel from the other direction: becausesubmit()mints and sends a key but reports.idempotency_key is None, a caller who readsNoneas "no key was sent, resend freely" duplicates the job. The docs half of that is fixed here — the attribute's docstring and the README now say plainly thatNonemeans "this SDK did not record a key for you", never "no key reached the server".The server-side contract this change is written against was not exercised. The recovery idiom in the
run()docstring and the README — a same-key resend returning the original result (200plus anIdempotent-Replayedmarker) or being refused while the original generation is still running, with aRetry-After— is the router idempotency middleware's behaviour as described in the source issue. That middleware lives in a private repository that is not reachable from this environment, so I read neither it nor its tests. What I verified is the SDK half: the key survives onto the exception, and the resend carries it verbatim. The end-to-end replay test drives the repo's own stub server configured as a key-replaying deployment, which asserts the SDK's side of the handshake, not the real server's. If the real middleware's replay semantics differ from that description, the prose in the docstring and README needs correcting — the attribute and the plumbing do not.The
409status fallback is left as it is, and a follow-up filed._CODE_BY_STATUSmaps a 409 carrying noerror.codetohash_mismatch, so any body-less 409 — from the router, from an intermediary — surfaces ascomfy_sdk.HashMismatchwith the response's own detail discarded. It is reachable today. I did not change it here because it is a wire-contract decision rather than a mechanical fix: 409 genuinely ishash_mismatchon the assets path, so the entry is right for the surface it was written for and wrong for this one. The follow-up carries the three candidate approaches for a human to pick between.Two named artifacts could not be read. The tracking issues linked from the source issue (the replay contract, the deadline-survival reservation, the release cutoff, the cross-SDK parity check, and the companion retry-default issue) are internal and I hold no access to them, so this change is built from the source issue's own quoted evidence rather than from those. Likewise the TypeScript SDK twin, which the source issue puts out of scope for this change and asks to be filed separately: I did not check
Comfy-Org/comfy-typescript-sdkfor the same gap, so whether it has one is still unestablished.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.