mirror of
https://github.com/Comfy-Org/comfy-typescript-sdk.git
synced 2026-11-04 23:47:27 +00:00
[PR #126] [MERGED] feat: collect a still-running generation under the same Idempotency-Key #128
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#128
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/126
Author: @mattmillerai
Created: 9/5/2026
Status: ✅ Merged
Merged: 9/10/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-11972-collect-same-key-409-504📝 Commits (3)
a784af3feat: collect a still-running generation under the same Idempotency-Keyeef1fcffix: address bot review commentsf15729eMerge remote-tracking branch 'origin/main' into matt/be-11972-collect-same-key-409-504📊 Changes
17 files changed (+1309 additions, -57 deletions)
View changed files
📝
AGENTS.md(+14 -5)📝
CHANGELOG.md(+41 -0)📝
README.md(+63 -9)📝
parity/python-surface.json(+13 -2)📝
scripts/python-surface.mjs(+54 -1)📝
scripts/router-route-contract.mjs(+45 -0)📝
src/sdk/exceptions.ts(+48 -4)📝
src/sdk/index.ts(+1 -0)📝
src/sdk/models.test.ts(+261 -2)📝
src/sdk/models.ts(+144 -19)📝
src/sdk/retry.test.ts(+116 -1)📝
src/sdk/retry.ts(+157 -6)📝
src/sdk/router-spec-contract.test.ts(+25 -0)📝
src/sdk/routerErrors.test.ts(+87 -0)📝
src/sdk/routerErrors.ts(+105 -6)📝
src/sdk/surface-parity.test.ts(+94 -1)📝
test/support/router-stub-server.ts(+41 -1)📄 Description
ELI-5
You ask Comfy to make a video. It takes ten minutes, and your connection to it dies at minute nine — or Comfy itself gives up holding the line at its own ten-minute limit. The video is still being made, and you are still paying for it. Comfy knows this and says so: it answers with "that one is still running, wait N seconds and ask me again using the same ticket number". This SDK was hanging up instead — it threw those answers straight at you and your video was gone. Now it waits the seconds it was told to wait, hands back the same ticket, and gives you the video when it arrives. Nothing to turn on.
Description
Comfy Router pairs exactly two failures with a
Retry-After, and that pairing means one specific thing: the generation yourIdempotency-Keyalready names has not finished, so re-send THAT key and collect it rather than dispatching a second one.409+X-Comfy-Error-Type: concurrency_limit_exceeded— another call is already in flight for this key. It is what a same-key re-send after a dropped connection meets.504+X-Comfy-Error-Type: deadline_exceeded— Comfy stopped holding the connection at its own bound while the provider carried on generating.Both were terminal in this SDK. The
409becauseisRetryableStatusrefuses every sub-500 status; the504becauseDEFAULT_RETRY_BUDGET_MS(120 s) is measured from the first attempt and is long spent by the time a ten-minute deadline produces one, andDEFAULT_RUN_TIMEOUT_MS(600 s) equalled that deadline besides. SoDeadlineExceeded's own docstring promised a collect the SDK could not perform, and a caller lost a generation that had already been dispatched and charged. The sibling Python SDK has had this loop for a while; this closes that gap.What changed
Retry-Afteris parsed once and rides onto the errors. A strict parser (parseRetryAfter, exported fromrouterErrors.ts) reads only the integer-seconds form the contract pins (minimum: 1) — deliberately notparseInt, which reads"2 hours"as a two-second pace nothing named — and an HTTP-date, decimal or negative reads as absent.0is KEPT rather than flattened tonull, because the two say different things:nullmeans nothing to collect,0means there is a handle but the pace is nonsense. It lands onRouterError.retryAfterand onComfyError.retryAfter, andmodels.tsandtoRouterErrorshare the one helper.isCollectable(status, errorType, retryAfter)inretry.ts, sans-IO besideisRetryableStatus. Every gate is load-bearing: the bucket is required on both statuses because each is shared with a deterministic refusal (504/provider_timeout; the409invalid_inputkey cases, answered by a NEW key), and the header is required because Comfy sends it only where it holds something to collect.retry.collectBudgetMs, default1_200_000— two Comfy deadline windows, one to reach the504and one to collect what it left running, since a504arrives AT the first window's end. Measured from the same first attempt asbudgetMs; ordinary retries still give up atbudgetMs.collectBudgetMs: 0switches the collect off alone;retry: false/budgetMs: 0still means exactly one attempt.nextCollectDelayMshonours the server's pace verbatim (it knows more about the generation than any schedule here), falls back to the jittered backoff on aRetry-After: 0so a confused value can never become a spin of full model-run POSTs, and CLAMPS to what is left of the collect budget rather than refusing — one unlucky draw must not end a call that still has budget.models.run, collect-first and exclusively. Adeadline_exceeded504is also a 5xx, so both branches would take it; the server's verdict about what it is holding wins over this module's guess, and a collect that exhausts its budget raises rather than falling back into the ordinary backoff. When it does raise, it is the server's own last409/504— never a synthetic "retries exhausted".DEFAULT_RUN_TIMEOUT_MS600 s → 1 200 s. Not a drive-by: the deadline covers every attempt of a call rather than each one afresh, so a ten-minute default is exactly spent when adeadline_exceeded504lands and step 5 could never start under it. The deadline stays the OUTERMOST bound, ahead ofcollectBudgetMs— a caller-suppliedtimeoutMsshorter than Comfy's ten minutes therefore forfeits the504collect, which is documented on the option. (The409collect works fine inside a short deadline; that answer arrives in milliseconds, not at a ten-minute bound. The rejected alternative was making the deadline per-attempt like Python'stimeout, which contradictscomposeSignal's stated contract and its test.)retryAfterandidempotencyKeyon everyComfyError, so a caller who outlived the budget holds both halves of a manual re-ask. The key onerunmints for you was previously not visible anywhere, which meant an interrupted call could not be re-asked for at all.Chesterton's fence
DEFAULT_RUN_TIMEOUT_MS = 600_000traces to #76, whose commit message states the reasoning: minutes rather than seconds "because the finished generation is the response". This change extends that same reasoning by one Comfy deadline window rather than contradicting it — a deadline is a ceiling, not a wait, so no successful call is slowed by it.isRetryableStatusitself is UNCHANGED: it still answersfalsefor every sub-500 status, so a header-less409and aninvalid_input409stay terminal exactly as before. The409was removed only from that function's prose never-retry list, because the status now covers two opposite answers and only one of them isisRetryableStatus's business.The status set is pinned, not chosen
Which statuses may be collected is not this SDK's decision, so nothing here restates it unchecked.
scripts/router-route-contract.mjsgrows a reader for whichrunRouterModelresponses declare aRetry-Afterheader, androuter-spec-contract.test.tsassertsisCollectableaccepts exactly those ([409, 504]) — a Router sync that moved the header onto a429, or off the409, reddens by name instead of leaving the SDK re-sending an answer nothing blesses.AGENTS.md's spec-coupled list gains the entry.Cross-SDK:
parity/python-surface.jsonnow records the PythonRetryPolicydataclass's fields and defaults, andsurface-parity.test.tscomparescollect_max_elapsed(1200.0 s) againstDEFAULT_COLLECT_BUDGET_MS(1 200 000 ms) as a VALUE, so the one number whose correctness is the whole feature cannot drift between the two languages. The differing off-switch (Python'sretry_collectable=Falsevs.collectBudgetMs: 0) is declared inINTENTIONAL_ASYMMETRIESwith its reason, and the rot guard fails if either side stops matching. The manifest was regenerated withpnpm sync:python-surfaceagainst livemain, andpnpm check:sdk-parity(what the daily CI job runs) confirms it.Negative-claim falsification
Not triggered, and stated so it is not assumed away: this diff ADDS a capability and denies none. It introduces no "not supported"/"unavailable"/dead-end string, no new throw path, and no test flipped to assert a dead-end. The one denial-shaped assertion — a header-less
409stays one attempt — is the PRE-EXISTING behaviour onmain, whichmodels.test.tsalready pinned; it was split out of a table into its own test, not newly introduced.How this was tested
The artifacts the work names were exercised directly rather than only around the diff:
spec/router-openapi.yaml) — read, and now machine-read by the new contract test:RouterRetryAfterHeaderistype: integer, minimum: 1;RouterIdempotencyConflict(409) andRouterDeadlineExceeded(504) are the onlyrunRouterModelresponses declaring the header, and they are the only two whose prose says to re-send the same key.src/comfy_sdk/retry.pyon the public sibling repo) — fetched and read atmain. Itsis_collectablegates, itsmin(delay, remaining)clamp, itsRetry-After: 0fallback and itscollect_max_elapsed = 1200.0are what this mirrors; the parity test now reads that value out of it on every run.models.test.ts: a409collect twice then200withIdempotent-Replayed: true(3 requests, one key across all three), a504collect, a504with NORetry-Aftertaking the ordinary 5xx path (asserted on the clock, since the outcome alone cannot tell them apart), a409loop outlivingcollectBudgetMs, an abort mid-collect-wait,retry: false,collectBudgetMs: 0, and a shorttimeoutMsbeating a generous collect budget.Provenance
pnpm test: 462 passed, 0 failed (24 files);pnpm typecheck,pnpm lint,pnpm format:check,pnpm build: clean;pnpm check:spec-drift: both checks OK;pnpm check:sdk-parity: snapshot matches the Python SDK's livemain;node scripts/check-public-repo-hygiene.mjs: no internal-only references.409rejects with).Residual
Not fixed here, and worth its own follow-up:
ComfyError, notrouterErrors.ConcurrencyLimitExceeded. The plan for this work asked the new test to assert the latter. It cannot today, and the reason is structural rather than a shortcut:toRouterError— the factory that produces therouterErrors.*classes — is not onmodels.run's path at all.models.tshas its ownerrorFromResponse, whoseBY_ERROR_TYPEmaps four buckets (unauthorized,forbidden,insufficient_credits,model_not_found) toComfyErrorsubclasses and leaves everything else aComfyErrorcarrying the bucket ascode. Routingmodels.runthroughtoRouterErrorwould change the class every existingmodels.runfailure throws, which is a breaking change to the public error surface and well outside this change. So the test assertscode === "concurrency_limit_exceeded"plushttpStatus 409,retryAfter 1and the server'srequestId— every value the criterion named except the class. Unifyingmodels.run's error factory withrouterErrors.toRouterErroris the real follow-up, and it is a semver-major decision about the catchable surface, not a cleanup: today a caller ofmodels.runcatchesComfyErrorand branches on.code, whilerouterErrors.*is a parallel hierarchy nothing on that path constructs. Whoever picks it up should decide deliberately whether the two converge or stay separate, and say so inrouterErrors.ts's module docstring either way — right now nothing in the repo states which is intended.Retry-Afterparsers now exist in this repo, with different strictness. The one added here (routerErrors.parseRetryAfter) is strict;src/low/transport.tskeeps its ownparseRetryAfter, which usesNumber.parseIntand so reads"2 hours"as a two-second pace. That one serves the Comfy API v2 job/asset surface and feedsQueueFull.retryAfterand the SSE reconnect pause — a different contract, whoseRetry-Afterthis change does not touch. Unifying them would change v2submit()/reconnect behaviour for malformed headers and was deliberately left alone; it is a small, self-contained follow-up.429+Retry-Afterinmodels.run; changing this SDK's default of retrying a thrown fetch or a bucket-less 5xx under the same key (Python does not); and the cloud end-to-end harness classifier, where a409that survives the collect budget is still classified correctly.409and a ten-minute504, neither of which can be provoked without dispatching (and paying for) real partner-model generations — so they are verified against the vendored contract and the stub server rather than in production. Thepnpm check:spec-driftandpnpm check:sdk-paritygates are what keep the stub's fixtures honest against the two upstream sources. Two further items named in the plan for this work could not be read from here: the cloud-side middleware said to raise the in-flight409, which lives in a private repo, and a linked spike ticket whose findings comment is not reachable from this sandbox. Both are cited in that plan only as evidence for gates this change reads directly out ofspec/router-openapi.yaml, which IS in this repo and IS now machine-checked — so nothing rests on the unread copies, but neither was independently confirmed.Summary by CodeRabbit
New Features
409and504responses usingRetry-After.retryAfterandidempotencyKeymetadata.Retry-Afterdelays.Bug Fixes
Documentation
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.