-
Notifications
You must be signed in to change notification settings - Fork 6
feat: carry the Idempotency-Key on every exception submit() raises #133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| job the first attempt may already have created. | ||
|
|
||
| `idempotency_key` is still `None` on errors from every other surface — one that | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low — " |
||
| 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 | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: |
||
| 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 | ||
|
|
@@ -321,17 +329,23 @@ def submit( | |
| key = idempotency_key or _core.new_idempotency_key() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium — |
||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium — If |
||
| 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, | ||
|
|
@@ -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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium — "and therefore by |
||
| #: ``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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low — |
||
| #: 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 | ||
|
|
||
There was a problem hiding this comment.
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 exhaustedQueueFullmeans 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).