mirror of
https://github.com/Comfy-Org/comfy-python-sdk.git
synced 2026-11-04 23:37:47 +00:00
[PR #133] [MERGED] feat: carry the Idempotency-Key on every exception submit() raises #133
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#133
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/133
Author: @mattmillerai
Created: 9/5/2026
Status: ✅ Merged
Merged: 9/10/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-9932-submit-exception-idempotency-key📝 Commits (2)
239ea18feat: carry the Idempotency-Key on every exception submit() raisesbfe04f6fix: address bot review comments📊 Changes
6 files changed (+429 additions, -37 deletions)
View changed files
📝
CHANGELOG.md(+27 -0)📝
README.md(+26 -6)📝
src/comfy_sdk/client.py(+66 -25)📝
src/comfy_sdk/exceptions.py(+23 -6)📝
tests/conftest.py(+6 -0)➕
tests/test_client_submit_idempotency.py(+281 -0)📄 Description
ELI-5
submit()puts a one-time ticket number on every job it sends, so the server can refuse an accidental duplicate. If the send blew up, that number vanished with the error — and ifsubmit()had minted it for you, you never saw it at all, so you couldn't go look up whether a job had been created. Now the ticket number rides out on the exception, exactly as it already does forclient.models.run().What changed
Comfy.submit/AsyncComfy.submitwrap theirwhile True:retry loop intranslating(idempotency_key=key)— the same boundarymodels.runhas used since #97. That is the whole product change.except ApiErrorhandler is untouched: it still converts, still consults_retry_delay, stilltime.sleep/await asyncio.sleeps and retries. The outer context manager only stamps what the inner handler lets out (aComfyErrorre-raised bare throughtranslating's_STAMPABLEbranch).httpx.HTTPError) previously escapedsubmit()untranslated. It now carries.idempotency_key, and.request_id/.retry_afterdefault toNoneon it rather than raisingAttributeError— the documented trio finally reads uniformly on the failure it is most needed on.AsyncComfy.submit()carries the key too, and still propagates bare (same contract asAsyncModels.run).Comfy.run/AsyncComfy.runinherit it for free: they submit throughsubmit.key = idempotency_key or _core.new_idempotency_key()), what is retried, and the bytes on the wire are all unchanged.The semantic difference is documented, not glossed.
models.runsends its key to a surface that replays a claimed key, which is what makes that key a handle on a generation you were already billed for.POST /jobsinstead rejects a reused key with422 idempotency_key_reuse—spec/openapi.yaml:645: "Single-use: the first request to present a key is processed; any later request with the same key is rejected422idempotency_key_reuse(reject-on-duplicate, no response replay)", againstspec/router-openapi.yaml:102, where a replayed response "carriesIdempotent-Replayed: trueand is not charged a second time". So on asubmit()error the key tells you a key was sent: poll or list for the job the first attempt may have created, rather than resubmitting under it. The README paragraph says exactly that and cross-references the existingIdempotencyKeyReusebullet;ComfyError.idempotency_key's docstring and theCHANGELOGentry carry the same distinction.Tests
New module
tests/test_client_submit_idempotency.py(14 tests, sync and async throughout), mirroring the stamping tests intests/test_models_run.pyagainst thePOST /jobsstub:422 validation_error, deliberately an unmapped code so the stamp is proven on the baseComfyError) equals the header the server actually received.idempotency_key="abc"round-trips onto the exception and onto the wire.QueueFullcarries the key, and it is the same key on every retried request.422 idempotency_key_reuse— driven by really reusing a key against the stub, not by a stubbed envelope — carries the key that was rejected.httpx.ConnectErrorcarries the key and reads.request_id is None,.retry_after is None.run()surfaces the key of a failed submit phase.AsyncComfy.submit()yields the key, reads.request_idasNone, and theCancelledErrorstill propagates.tests/conftest.pygains one additive knob,ServerState.jobs_idempotency_keys— everyIdempotency-Keyseen onPOST /jobsin arrival order, mirroring the existingmodel_run_idempotency_keys. See the judgment call below for why.Judgment calls
exc.idempotency_key == server.state.idempotency, which cannot work.ServerState.idempotencyis adictofkey -> job_idand it is only written on an accepted submit, so it is empty on exactly the error paths these tests exercise. I added the ordered-capture list instead (the same shape the model-run side already uses) and asserted against the key the server genuinely received, which is what the criterion was reaching for._client_module._nowwith a clock that advances 1.0s per read (budget 3.0) makes "the budget ran out after N attempts" exact. Timing it against a real 20–50 ms budget would have been a race on a loaded machine, and the assertion needslen(sent) > 1to mean anything._guard_ui_formatand_materializeare not, per the ticket. Both run before the key is minted, so a failure there genuinely has no key to carry.run()'s polling phase is deliberately not stamped. Oncesubmitreturns, the job exists and is addressed by its id; a submit key there would be noise, and the CHANGELOG scopes the guarantee to "a failed call".client.py:161-173andREADME.md:523from the #97 branch; on today'smainthey areclient.py:329/README.md:587, and the import line isMissingApiKey, WorkflowFormatUi, to_sdk_error, not theQueueFull, ...the ticket quotes). I matched the code, not the line numbers.mainrather than stacking. #99 is also merged and touchesmodels.py/retry.pyonly — no overlap.Residual
Idempotency-Keywithout stamping it.AssetHandlemints one per handle (src/comfy_sdk/assets.py:69) and passes it topost_assets(assets.py:139,assets.py:185) inside a barewith translating():— no key argument — so every exception an upload raises still reads.idempotency_key is None. That is out of this change's scope (the ticket scopes tosubmit), but it makes the flat sentence the ticket asked for in the docs (Noneon operations that "send no key") untrue, so I did not write it: theComfyError.idempotency_keydocstring and the README paragraph both name the asset-upload surface explicitly as the remaining key-sending-but-unrecording one. Fixing it is a one-line change per call site (with translating(idempotency_key=self._idempotency_key):) plus its own tests, and it deserves a decision first, because an upload key is deduplicated by content hash and its recovery story is not the same as a job's.spec/openapi.yamlandspec/router-openapi.yaml, not observed against Comfy Cloud.tests/integrationneeds a live target and a credential and was not run.Provenance
uv run --extra dev pytest -q: 732 passed, 4 skipped, 0 failed (full suite, under the shared per-repo suite lock);uv run --extra dev ruff check .: all checks passed;uv run --extra dev ruff format --check .: 52 files already formatted;uv run --extra dev mypy src: no issues in 19 source files.tests/integrationnot run (needs a live deployment + credential).server.state.idempotencyassertion replaced with an ordered header capture (see Judgment calls); the docs sentence the ticket dictated was amended so it does not make a false claim about asset uploads (see Residual). No other criterion skipped.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.