Skip to content

feat: carry the Idempotency-Key on every exception submit() raises - #133

Open
mattmillerai wants to merge 1 commit into
mainfrom
matt/be-9932-submit-exception-idempotency-key
Open

feat: carry the Idempotency-Key on every exception submit() raises#133
mattmillerai wants to merge 1 commit into
mainfrom
matt/be-9932-submit-exception-idempotency-key

Conversation

@mattmillerai

Copy link
Copy Markdown
Contributor

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 if submit() 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 for client.models.run().

What changed

Comfy.submit / AsyncComfy.submit wrap their while True: retry loop in translating(idempotency_key=key) — the same boundary models.run has used since #97. That is the whole product change.

  • The inner except ApiError handler is untouched: it still converts, still consults _retry_delay, still time.sleep / await asyncio.sleeps and retries. The outer context manager only stamps what the inner handler lets out (a ComfyError re-raised bare through translating's _STAMPABLE branch).
  • A transport failure (httpx.HTTPError) previously escaped submit() untranslated. It now carries .idempotency_key, and .request_id / .retry_after default to None on it rather than raising AttributeError — the documented trio finally reads uniformly on the failure it is most needed on.
  • Cancelling an in-flight AsyncComfy.submit() carries the key too, and still propagates bare (same contract as AsyncModels.run).
  • Comfy.run / AsyncComfy.run inherit it for free: they submit through submit.
  • Key minting (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.run sends 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 /jobs instead rejects a reused key with 422 idempotency_key_reusespec/openapi.yaml:645: "Single-use: the first request to present a key is processed; any later request with the same key is rejected 422 idempotency_key_reuse (reject-on-duplicate, no response replay)", against spec/router-openapi.yaml:102, where a replayed response "carries Idempotent-Replayed: true and is not charged a second time". So on a submit() 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 existing IdempotencyKeyReuse bullet; ComfyError.idempotency_key's docstring and the CHANGELOG entry carry the same distinction.

Tests

New module tests/test_client_submit_idempotency.py (14 tests, sync and async throughout), mirroring the stamping tests in tests/test_models_run.py against the POST /jobs stub:

  1. Auto-minted key on a non-retryable error (422 validation_error, deliberately an unmapped code so the stamp is proven on the base ComfyError) equals the header the server actually received.
  2. A caller-supplied idempotency_key="abc" round-trips onto the exception and onto the wire.
  3. Queue-full budget exhaustion: the QueueFull carries the key, and it is the same key on every retried request.
  4. 422 idempotency_key_reuse — driven by really reusing a key against the stub, not by a stubbed envelope — carries the key that was rejected.
  5. Transport failure against a closed port: httpx.ConnectError carries the key and reads .request_id is None, .retry_after is None.
  6. run() surfaces the key of a failed submit phase.
  7. Cancelling an in-flight AsyncComfy.submit() yields the key, reads .request_id as None, and the CancelledError still propagates.

tests/conftest.py gains one additive knob, ServerState.jobs_idempotency_keys — every Idempotency-Key seen on POST /jobs in arrival order, mirroring the existing model_run_idempotency_keys. See the judgment call below for why.

Judgment calls

  • The ticket's acceptance for test 1 asserts exc.idempotency_key == server.state.idempotency, which cannot work. ServerState.idempotency is a dict of key -> job_id and 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.
  • The queue-full retry budget is spent by a stepped fake clock, not by wall time. Monkeypatching _client_module._now with 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 needs len(sent) > 1 to mean anything.
  • The retry loop is wrapped; _guard_ui_format and _materialize are 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. Once submit returns, 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".
  • Line references in the ticket had drifted (it names client.py:161-173 and README.md:523 from the feat: carry the Idempotency-Key and request id on every exception models.run raises #97 branch; on today's main they are client.py:329 / README.md:587, and the import line is MissingApiKey, WorkflowFormatUi, to_sdk_error, not the QueueFull, ... the ticket quotes). I matched the code, not the line numbers.
  • The prerequisite (feat: carry the Idempotency-Key and request id on every exception models.run raises #97) is merged, so this branches off main rather than stacking. feat: collect a Router deadline 504 under the same Idempotency-Key by default #99 is also merged and touches models.py/retry.py only — no overlap.

Residual

  • Asset uploads still send an Idempotency-Key without stamping it. AssetHandle mints one per handle (src/comfy_sdk/assets.py:69) and passes it to post_assets (assets.py:139, assets.py:185) inside a bare with 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 to submit), but it makes the flat sentence the ticket asked for in the docs (None on operations that "send no key") untrue, so I did not write it: the ComfyError.idempotency_key docstring 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.
  • No live deployment was exercised. Everything here runs against the in-repo stub server; the reject-not-replay semantics this PR documents are cited from the vendored spec/openapi.yaml and spec/router-openapi.yaml, not observed against Comfy Cloud. tests/integration needs a live target and a credential and was not run.
  • The ticket's linked spike issue and its findings comment are named but not reachable from this environment, so the evidence behind the ticket was taken as given rather than re-read.

Provenance

  • Authored by: agent-work loop
  • Verified: 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/integration not run (needs a live deployment + credential).
  • Deviations: ticket test-criterion 1's server.state.idempotency assertion 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.

`Comfy.submit` / `AsyncComfy.submit` mint an `Idempotency-Key` per call and
send it on `POST /jobs`, but the key was a local of the submitting frame: any
exception the call raised took the caller's only record of what was sent with
it. A transport failure escaped `submit()` untranslated as well, so the
documented `.request_id` / `.retry_after` pair raised `AttributeError` there
rather than reading `None`.

Wrap each retry loop in `translating(idempotency_key=key)` — the same
boundary `models.run` already uses. The inner `except ApiError` handler still
owns what is retried and what surfaces; the outer stamp only attaches the key
to whatever it lets out, so nothing about the retry policy, the key minting or
the wire changed.

The semantics differ from `models.run`'s and the docs say so: `POST /jobs`
rejects a reused key (`422 idempotency_key_reuse`) rather than replaying it, so
the key on a `submit()` error is a record of what was sent — poll or list for
the job the first attempt may have created — not a replay handle.
@mattmillerai mattmillerai added the agent-coded Authored by the agent-work loop label Sep 5, 2026
@mattmillerai
mattmillerai marked this pull request as ready for review September 5, 2026 21:42
@mattmillerai
mattmillerai requested review from a team as code owners September 5, 2026 21:42
@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 47 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 136 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Team

Run ID: 2426a905-0253-45d0-bc4b-b87187fe5d7c

📥 Commits

Reviewing files that changed from the base of the PR and between ce4242b and 239ea18.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • README.md
  • src/comfy_sdk/client.py
  • src/comfy_sdk/exceptions.py
  • tests/conftest.py
  • tests/test_client_submit_idempotency.py

Comment @coderabbitai help to get the list of available commands.

@mattmillerai mattmillerai added the cursor-review Request an automated Cursor review label Sep 5, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 10 finding(s).

Severity Count
🟡 Medium 4
🟢 Low 4
⚪ Nit 2

Panel: 6/6 reviewers contributed findings.

Comment thread src/comfy_sdk/client.py
@@ -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).

#: :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).

Comment thread src/comfy_sdk/client.py
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).



async def test_cancelling_an_in_flight_submit_still_yields_the_key(server) -> None:
# A submit cancelled mid-flight — what `asyncio.wait_for` around it does —

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 — The test raises CancelledError from a stubbed post_jobs and catches it directly, so it never exercises the asyncio.wait_for path this comment cites: wait_for swallows the stamped cancellation and raises a fresh TimeoutError that has no .idempotency_key, leaving a wait_for caller without the key after a possibly-accepted submit. Add a case that actually wraps submit in wait_for (and expose the key on that caller-visible failure), or drop the wait_for claim. Raised by 2 of 6 reviewers (gpt-5.6-sol-max adversarial, gpt-5.6-sol-max edge-case).

#: 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).

Comment thread README.md
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).

Comment thread src/comfy_sdk/client.py
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).

Comment thread README.md
you a key *was* sent — do not resubmit blindly under it, poll or list for the
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).

return client.workflows.from_json(_GRAPH)


def _closed_port() -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit_closed_port() releases the port when the with block exits, so between that and the connect in the three tests that use it anything on the machine (including a parallel test run) can bind it — those tests would then hit an unrelated listener instead of raising httpx.ConnectError. Holding a bound-but-not-listening socket open for the test's duration removes the window. Raised by 1 of 6 reviewers (claude-opus-5-thinking-max adversarial).

await client.submit(_wf(client))
sent = server.state.jobs_idempotency_keys
assert len(sent) > 1
assert set(sent) == {excinfo.value.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.

Nit — The async twin omits the assert excinfo.value.idempotency_key is not None that line 127 adds to the sync test, so if the header were ever dropped on the wire sent would be all None and this assertion would compare {None} == {None} — passing against a completely unstamped exception. Add the same guard. Raised by 1 of 6 reviewers (claude-opus-5-thinking-max edge-case).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-coded Authored by the agent-work loop cursor-review Request an automated Cursor review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant