Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ notes for each version.

### Added

- Every exception `client.submit()` — and so `client.run()`, which submits
through it — raises **for a failed call** now carries the `Idempotency-Key`
it was made under, on `.idempotency_key`, matching `client.models.run()`:
the mapped `ComfyError` subclasses, a `QueueFull` raised once the 429 retry
budget is exhausted (the same key on every retried attempt), and a transport
failure with no response at all (a dropped connection, a read timeout), which
previously escaped `submit()` untranslated and now reads `.request_id` and
`.retry_after` as `None` rather than raising `AttributeError`. Cancelling an
in-flight `AsyncComfy.submit()` yields the key too, and the cancellation
still propagates unchanged. The semantics differ from `models.run`'s and the
difference matters: `POST /jobs` **rejects** a reused key with
`422 idempotency_key_reuse` rather than replaying it, so the key on a
`submit()` error records what was sent — poll or list for the job the first
attempt may already have created — rather than being a replay handle to
resubmit under. Nothing about what is retried, what key is minted, or what
goes on the wire changed.
- Every exception `client.models.run()` raises **for a failed call** now
carries the `Idempotency-Key` it was made under, on `.idempotency_key` — the
typed `RouterError` buckets, a `RouterError` whose `error_type` this version
Expand Down
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,12 +584,22 @@ Three attributes carry this:
All three read as `None` rather than raising on any exception `models.run`
raises, so a handler never has to guard the attribute access itself.

`idempotency_key` is `None` on errors from *other* surfaces, though — it is
`models.run` that records it, and `submit()` sends a key without stamping one.
So `None` means "this SDK did not record a key for you", **not** "no key was
sent, resend freely": check for it before replaying, as the snippet above does,
rather than passing it straight back into `idempotency_key=` where `None` means
"mint a fresh one" and starts a second billed generation.
`submit()` — and so `run()`, which submits through it — stamps the key too, but
what the key is *good for* differs, so read it with the surface in mind.
`models.run` sends it to a surface that **replays** a claimed key, which is what
makes it a handle on a generation you were already billed for. `POST /jobs`
instead **rejects** a reused key with `422 idempotency_key_reuse` (see the
[`IdempotencyKeyReuse`](#typed-errors) bullet below): keys there are single-use
and there is no replay. So on a `submit()` failure `exc.idempotency_key` tells
you a key *was* sent — do not resubmit blindly under it, poll or list for the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low — "a key was sent — do not resubmit blindly under it, poll or list for the job" is wrong for the two failures this feature most targets: an httpx.ConnectError (which the new test pins as stamped) means no bytes reached the server, and an exhausted QueueFull means every attempt was rejected, so no job exists and the key was never claimed. Describe the stamp as "a key was minted for this attempt" and say when resubmitting under it is still valid. Raised by 2 of 6 reviewers (claude-opus-5-thinking-max adversarial, gpt-5.6-sol-max edge-case).

job the first attempt may already have created.

`idempotency_key` is still `None` on errors from every other surface — one that

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low — "idempotency_key is still None on errors from every other surface" overstates the read-never-raises guarantee for transport errors: asset upload calls translating() with no key and _stamp returns before setting any attribute, so exc.idempotency_key on an httpx error from that path raises AttributeError rather than reading None. Either stamp None explicitly on keyless scopes or say the guarantee covers only key-carrying ones. Raised by 1 of 6 reviewers (gpt-5.6-sol-max adversarial).

sends no key, and an asset upload, which mints a key per handle without
recording it. So `None` means "this SDK did not record a key for you", **not**
"no key was sent, resend freely": check for it before replaying, as the snippet above
does, rather than passing it straight back into `idempotency_key=` where `None`
means "mint a fresh one" and starts a second billed generation.

## Sync and async

Expand Down
65 changes: 42 additions & 23 deletions src/comfy_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@

from . import _core
from .assets import AssetFactory, AsyncAssetFactory
from .exceptions import MissingApiKey, WorkflowFormatUi, to_sdk_error
from .exceptions import MissingApiKey, WorkflowFormatUi, to_sdk_error, translating
from .jobs import AsyncJob, AsyncJobFactory, Job, JobFactory
from .models import AsyncModels, Models
from .retry import DEFAULT_RETRY, RetryPolicy
Expand Down Expand Up @@ -310,6 +310,14 @@ def submit(
reused key is *rejected*, not replayed: on reuse, catch the error and
poll/list for the job the first attempt already created.

Every exception a failed submit raises carries the key it was made

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low — "Every exception a failed submit raises carries the key" is false for failures that happen before the key exists: _guard_ui_format (WorkflowFormatUi) and _materialize (asset commit) both run above line 329 and outside translating, so they surface with .idempotency_key is None. The behavior is correct; narrow this sentence and the matching CHANGELOG bullet to failures of the POST /jobs attempt itself. Raised by 3 of 6 reviewers (kimi-k3-high adversarial, gpt-5.6-sol-max edge-case, claude-opus-5-thinking-max edge-case).

under on ``.idempotency_key`` — including a transport failure that
never reached a response (``httpx.ConnectError``, a read timeout),
which reads ``.request_id`` and ``.retry_after`` as ``None`` rather
than raising. Because a reused key is rejected rather than replayed,
that key is a record of what was sent, not a replay handle: use it to
go looking for the job, not to resubmit.

``api_key`` authenticates partner (API) nodes embedded in the workflow
(e.g. Gemini) — unrelated to idempotency and unrelated to the bearer
token this client was constructed with. It is never persisted or
Expand All @@ -321,17 +329,23 @@ def submit(
key = idempotency_key or _core.new_idempotency_key()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumsubmit takes a caller key by truthiness (idempotency_key or _core.new_idempotency_key()) instead of routing it through _core.validate_idempotency_key the way models.run does, so idempotency_key="" silently mints a fresh key — the caller's dedup is disabled and a retry can create a second billed job — while over-length or control/non-ASCII keys reach httpx and surface as an opaque LocalProtocolError. The new stamp compounds it: the exception now reports a key the caller never passed. Same at line 440 in AsyncComfy.submit. Raised by 4 of 6 reviewers (gpt-5.6-sol-max adversarial, gpt-5.6-sol-max edge-case, claude-opus-5-thinking-max adversarial, claude-opus-5-thinking-max edge-case).

extra_data = _core.extra_data_for(api_key)
deadline = _now() + _QUEUE_RETRY_BUDGET
while True:
try:
model = self._low.post_jobs(graph, idempotency_key=key, extra_data=extra_data)
return Job(self._low, model)
except ApiError as exc:
err = to_sdk_error(exc)
delay = _retry_delay(exc, deadline)
if delay is None:
raise err from exc
time.sleep(delay)
continue
# The key is a local of this frame, so anything that propagates past
# here takes the caller's only record of what was sent with it. The
# inner handler still owns what is retried and what surfaces; this
# stamps the key onto whatever it lets out — including a transport
# failure that never reached a response to translate.
with translating(idempotency_key=key):
while True:
try:
model = self._low.post_jobs(graph, idempotency_key=key, extra_data=extra_data)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — If POST /jobs answers 201 with a body that fails Job.model_validate, the resulting Pydantic ValidationError is neither an ApiError, a ComfyError, nor an httpx error, so translating lets it escape unstamped — exactly when the server may already have durably created the job. Normalize response-validation failures into a stampable invalid-response error. Raised by 2 of 6 reviewers (gpt-5.6-sol-max adversarial, gpt-5.6-sol-max edge-case).

return Job(self._low, model)
except ApiError as exc:
err = to_sdk_error(exc)
delay = _retry_delay(exc, deadline)
if delay is None:
raise err from exc
time.sleep(delay)
continue

def run(
self,
Expand Down Expand Up @@ -426,17 +440,22 @@ async def submit(
key = idempotency_key or _core.new_idempotency_key()
extra_data = _core.extra_data_for(api_key)
deadline = _now() + _QUEUE_RETRY_BUDGET
while True:
try:
model = await self._low.post_jobs(graph, idempotency_key=key, extra_data=extra_data)
return AsyncJob(self._low, model)
except ApiError as exc:
err = to_sdk_error(exc)
delay = _retry_delay(exc, deadline)
if delay is None:
raise err from exc
await asyncio.sleep(delay)
continue
# See :meth:`Comfy.submit` — same stamp, and here it also rides out on
# the cancellation of an in-flight `post_jobs`.
with translating(idempotency_key=key):
while True:
try:
model = await self._low.post_jobs(
graph, idempotency_key=key, extra_data=extra_data
)
return AsyncJob(self._low, model)
except ApiError as exc:
err = to_sdk_error(exc)
delay = _retry_delay(exc, deadline)
if delay is None:
raise err from exc
await asyncio.sleep(delay)
continue

async def run(
self,
Expand Down
23 changes: 17 additions & 6 deletions src/comfy_sdk/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,23 @@ class ComfyError(Exception):
"""Base for every SDK-level error."""

#: The ``Idempotency-Key`` the failed call was made under. Populated by
#: :meth:`comfy_sdk.models.Models.run` and its async twin, which are the
#: operations that pass a key to :func:`translating`; ``None`` everywhere
#: else — including on operations that *do* send a key but do not stamp it
#: (``Comfy.submit()``), and on an exception constructed by hand. So
#: ``None`` means "this SDK did not record a key for you", never "no key
#: reached the server": do not infer from it that a resend is safe.
#: :meth:`comfy_sdk.models.Models.run` and its async twin, and by
#: :meth:`comfy_sdk.client.Comfy.submit` /
#: :meth:`comfy_sdk.client.AsyncComfy.submit` — and therefore by
#: ``Comfy.run`` / ``AsyncComfy.run``, which submit through them. It is

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — "and therefore by Comfy.run / AsyncComfy.run" holds only for the submit phase: translating(idempotency_key=key) exits when submit returns, so the most common run() failures — a JobFailed from polling, or the TimeoutError from _run_with_timeout — still read .idempotency_key as None, and neither carries the Job handle either. Scope the sentence to the submit phase, or carry the key through the polling phase. Raised by 3 of 6 reviewers (gpt-5.6-sol-max adversarial, claude-opus-5-thinking-max adversarial, claude-opus-5-thinking-max edge-case).

#: ``None`` everywhere else: on an operation that sends no key, on an
#: asset upload (which mints a key per handle and does not record it), and
#: on an exception constructed by hand. So ``None`` means "this SDK did not
#: record a key for you", never "no key reached the server": do not infer
#: from it that a resend is safe.
#:
#: What the key is *good for* differs by surface, so read it with the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low.idempotency_key now spans two surfaces with opposite reuse semantics — replayable for models.run, single-use for POST /jobs — and the only discriminator is prose about which call raised. A handler that catches both (the natural generalization of the README snippet at lines 548-568) will take the replay branch on a submit() failure and feed a jobs key back into models.run(idempotency_key=...); consider a machine-readable flag on the exception (e.g. replayable) rather than documentation alone. Raised by 1 of 6 reviewers (claude-opus-5-thinking-max adversarial).

#: operation in mind: ``models.run`` sends it to a surface that replays a
#: claimed key, so the key is a handle on the generation you were already
#: billed for. ``POST /jobs`` instead *rejects* a reused key with
#: ``422 idempotency_key_reuse``, so on a ``submit`` failure the key says a
#: key was sent — poll or list for the job the first attempt may have
#: created rather than resubmitting under it.
#:
#: Declared on the base rather than set per subclass so that a bucket this
#: SDK version has never heard of — which arrives as a bare
Expand Down
6 changes: 6 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,11 @@ class ServerState:
# Idempotency-Key -> job id of the first (accepted) request, so a reuse of
# the same key can be detected and rejected (single-use, no replay).
idempotency: dict[str, str] = field(default_factory=dict)
# Every Idempotency-Key seen on POST /jobs, in arrival order (`None`
# records a submit that arrived without the header at all). Distinct from
# `idempotency`, which only records the keys an *accepted* request claimed
# — a test about a failed submit needs the key the server actually saw.
jobs_idempotency_keys: list[str | None] = field(default_factory=list)
# Raw bytes of the last POST /assets multipart body (so tests can inspect
# the parts actually sent — e.g. how many `tags` fields were included).
last_upload_body: bytes = b""
Expand Down Expand Up @@ -660,6 +665,7 @@ def _post_jobs(self) -> None:
state.last_workflow = body.get("workflow")
state.last_jobs_body = body
key = self.headers.get("Idempotency-Key")
state.jobs_idempotency_keys.append(key)

if key and key in state.idempotency:
# Reject-on-duplicate (single-use keys, no replay): any reuse of
Expand Down
Loading
Loading