mirror of
https://github.com/Comfy-Org/comfy-typescript-sdk.git
synced 2026-11-04 23:47:27 +00:00
[PR #152] [MERGED] fix(models): stamp the Idempotency-Key onto every error models.run / models.submit throws #154
Labels
No labels
audit:ai-readiness
audit:community
audit:github
audit:health-scoring
audit:policy
audit:release
audit:supply-chain
bug
cursor-review-pin
effort:medium
effort:small
effort:trivial
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-typescript-sdk#154
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-typescript-sdk/pull/152
Author: @mattmillerai
Created: 9/17/2026
Status: ✅ Merged
Merged: 10/9/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-15123-stamp-idempotency-key📝 Commits (7)
fddafc6fix(models): stamp the Idempotency-Key onto every error models.run / models.submit throws9017304Merge origin/main into matt/be-15123-stamp-idempotency-keyd0725c1fix(models): never stamp a key onto an abort reason other calls share1825a93Merge origin/main into matt/be-15123-stamp-idempotency-keya10627bfix(exceptions): leave a caller's own Error subclass unreplicatede076784docs(readme): say that run/submit transport failures and aborts carry idempotencyKey, and list it on RouterError7a730efMerge origin/main into matt/be-15123-stamp-idempotency-key📊 Changes
9 files changed (+940 additions, -118 deletions)
View changed files
📝
CHANGELOG.md(+1 -0)📝
README.md(+14 -3)📝
src/sdk/exceptions.test.ts(+264 -0)📝
src/sdk/exceptions.ts(+239 -0)📝
src/sdk/modelRequests.test.ts(+96 -0)📝
src/sdk/modelRequests.ts(+30 -13)📝
src/sdk/models.test.ts(+146 -0)📝
src/sdk/models.ts(+131 -101)📝
src/sdk/routerErrors.ts(+19 -1)📄 Description
ELI-5
When a
comfy.models.run(...)orcomfy.models.submit(...)call blows up, you often want to know which request it was so you can look it up on the server or safely re-ask for the generation it may still be running. TheIdempotency-Keyis that handle. Until now only ONE kind of failure carried it — the case where the server answered with an error. The scarier failures — the network dropping the socket mid-request, an already-aborted signal, or a bare502on the queue path — threw a raw error with no key on it at all, and on a dropped connection the server never even minted a request id, so there was literally nothing left to correlate the failure with. This PR stamps theIdempotency-Keyonto every error those two calls can throw, so a caller (and the cloud E2E harness that readserr.idempotencyKey) always has it.What changed
src/sdk/exceptions.ts(not exported from the package root — the public surface is unchanged, sosurface-parity.test.tsneeds no edit):stampIdempotencyKey(exc, key, options?)— attacheskeyas an ownidempotencyKeyproperty on the raw throwable, in place, returning the same object with the same stack. It never overwrites a value already set (so aComfyErrorbuilt WITH a key keeps it), anullkey writes nothing, and a non-object, a non-extensible throwable (a string, a frozen object) or one whose write raises passes through untouched. Except when the throwable is one the caller owns and other calls share — see "Shared abort reasons" below. It also defaultsrequestId/retryAftertonullwhen absent, so a stamped transport error reads every attribute a caller expects — without clobbering one it already carried.stamping(key, fn)— runsfn, stamping anything it throws. Mirrors the second arm of the Python SDK'stranslating(idempotency_key=…).models.runwraps its whole retry loop instamping(idempotencyKey, …). The pre-mint input/credential checks stay outside and unstamped (there is no key yet), exactly as in Python. The three existingComfyErrorconstructions already carry the key, so the stamp is a no-op on them.models.submitwrapssend()+decode()+new RequestHandle(...)instamping(...), so both the transport re-throw and theRouterErrorfromdecodeleave stamped.models.subscribecallssubmitand inherits it.handle()/RequestHandlemethods are addressed byrequestId, not the key, and are left alone. Therequest_timeoutComfyErrorinsend()now also receivescall.idempotencyKeydirectly.routerErrors.RouterErrorgains a typedreadonly idempotencyKey: string | nullfield (plus the options entry and a class doc mention), so the stamped queue-path error is typed rather than duck-typed.CHANGELOG.md(Unreleased → Fixed) updated.The rationale for stamping in place rather than through a constructor argument: the transport failures are undici's own
TypeError/DOMException, which this SDK does not construct and cannot catch by subclass.ComfyError.idempotencyKeyisreadonlyat the type level only; the write through theRecordcast is deliberate and guarded so it never overwrites a non-null value.Shared abort reasons (review round)
The panel raised one High finding that changed the design, and it was right.
fetchrejects withsignal.reason, andAbortSignal.anypropagates the SOURCE signal's reason object, not a copy — verified directly:AbortSignal.any([caller, timeout]).reason === caller.signal.reasonistrue. So N concurrentrun/submitcalls sharing oneAbortControllerall reject with oneDOMException, the first call to reach the stamp decided the key every caller then read, and the stamp permanently mutated caller-owned state. A manual collect under someone else's key re-asks for someone else's generation while your own stays uncollectable.stampIdempotencyKeynow takesStampOptions.callerSignal, wired from bothmodels.runandmodelRequests.submit. When the throwable is that signal's reason, the call receives a private stand-in carrying its own key, andcontroller.signal.reasonis left exactly as the caller built it. The stand-in keeps the same prototype,name, message, stack and own properties, so everything a caller branches on —err.name === "AbortError",instanceof DOMException,instanceof TypeError— reads identically; only object identity differs. An error already carrying a different call's key takes the same path, as belt and braces for a shared throwable arriving by a route not plumbed.Worth recording: the stand-in cannot be a descriptor copy.
DOMExceptionkeepsname/messagein internal slots behind prototype accessors, soObject.defineProperties(Object.create(proto), descriptors)yields an object that throwsValue of `this` must be of DOMExceptionthe moment anyone readserr.name— precisely the property callers branch on. It is rebuilt through the constructor instead, andstack(an accessor bound to its original on bothErrorandDOMException) is pinned across as a data property. A throwable that cannot be faithfully reproduced travels on UNSTAMPED: no key beats another generation's key. That includes a caller's ownErrorsubclass: a descriptor copy never runs its constructor, so#privatestate is missing and an accessor reading it would throw on the stand-in. Only the built-in error classes, this SDK's ownComfyErrorclasses and plain objects are replicated (follow-up to a CodeRabbit finding).The other three findings are fixed too — the stamp's writes are wrapped (an own non-writable
idempotencyKeyholdingundefined, a getter-only accessor or aProxywith a throwingsettrap used to raise from insidestamping's catch and REPLACE the transport failure); a transport failure that exhausts the collect budget now carries theRetry-AfterpacecollectingAtholds rather than defaulting it tonull; andrequestId/retryAfteruse value checks likeidempotencyKey, so an ownrequestId: undefinedreads back asnull.How this was verified
pnpm typecheck— clean.pnpm lint(oxlint) — clean.pnpm exec oxfmt --check .— clean.pnpm test— 27 files, 671 tests passed (0 failed), including the new cases:exceptions.test.ts— the stamp defaultsrequestId/retryAftertonullon a bareError; never overwrites an existingretryAfter/idempotencyKey; anullkey writes nothing; a frozen object and a string throwable pass through unchanged. These are the TS twins of the Pythontest_error_contract.pytest_a_stamped_transport_error_reads_every_attribute_as_noneandtest_the_stamp_never_overwrites_an_attribute_that_is_already_set.models.test.ts— a mid-request socket destroy (retry: false) rejects with aTypeErrorwhosemessageis"fetch failed",idempotencyKeyis the caller's key (and, in a second case, the SDK-minted key), andrequestId/retryAfterarenull; a pre-aborted signal rejects with aDOMExceptionAbortErrorcarrying the key; a bare502still carries the key AND theretryAfterread off the header (the stamp does not overwrite it).modelRequests.test.ts— the same transport + abort cases onmodels.submit; a502→ProviderErrorwithidempotencyKeyset; a spent deadline →ComfyError code request_timeoutwithidempotencyKeyset.Twelve further tests were added in the review round, each verified to FAIL against the pre-fix code: concurrent
run/submiton oneAbortControllerreading their own keys (pre-fix:expected 'k-one' to be 'k-two'), stand-in fidelity, plain-object and exotic (Map) abort reasons, the unwritable and throwing-set-trap throwables, the uniformundefined->nulldefault, and the collect-budget failure carrying the pace (pre-fix:expected null to be 1).Provenance
a10627b(origin/main@9d2fa64merged in):pnpm lint,pnpm format:check,pnpm typecheck,pnpm check:spec-driftall clean.pnpm test: 714 passed, 0 failed, 5 skipped. The new custom-Error-subclass test fails against the previousreplicate.sdk-parityis red onmainas well, so this PR doesn't cause it. #167 taught the extractor about the Python SDK'sRouterRunResult, but the committed Python snapshot has gone stale since then. #201 refreshes it.Errorsubclass) is returned UNSTAMPED rather than mutated.Residual
Not defects in this change — verification I could not perform from the sandbox, named so a reviewer can close the loop:
Comfy-Org/cloud,testing/e2e/router_sdk/typescript/src/harness.ts,observe(exc)). The motivating consumer readserr.idempotencyKeyby property; this PR exposes exactly that own property on every error path, but I could not run that harness (separate private repo, no access here), so the end-to-end fix that the EDGE rows stop recordingnullis asserted by unit test rather than exercised against the harness itself.comfy-python-sdksrc/comfy_sdk/exceptions.pytranslating/_stamp,tests/test_error_contract.py). The behaviour mirrors the Python contract as described, and the two new TS tests are named as twins of the two Python tests, but I could not read the Python source or run its suite to confirm the assertions match byte-for-byte.dist/index.jsand driving it against a closed loopback port. I reproduced the same raw-transport-failure path in-tree via the existingRouterStubServermid-request socket destroy (resetTimes) rather than a builtdistagainst a real closed port; the code paths exercised are identical.router-provider-params(#150). RESOLVED — it merged as21454a3andorigin/mainis merged into this branch. The only textual conflict wasCHANGELOG.md(both added anUnreleasedsection); both are kept.models.runcomposes correctly: #150'srouterRunQueryis built pre-mint and so stays outside thestampingwrapper, matching this branch's rule that pre-mint checks are unstamped. Full suite green on the merged tree.Summary by CodeRabbit
comfy.models.runandcomfy.models.submitnow include that call’s idempotency key, including transport, abort, timeout, and Router failures. This makes failed requests easier to identify.nullwhen none applies.Retry-Afterpace, the failure retains that value.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.