mirror of
https://github.com/Comfy-Org/comfy-typescript-sdk.git
synced 2026-11-04 23:47:27 +00:00
[PR #78] [MERGED] feat: retry comfy.models.run on transport failures and 5xx, bounded by elapsed time #80
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#80
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/78
Author: @mattmillerai
Created: 8/24/2026
Status: ✅ Merged
Merged: 8/25/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-8505-client-retry-abort-idempotency📝 Commits (2)
2e04993feat: retry comfy.models.run on transport failures and 5xx, bounded by elapsed time9e64b66test: bound the deadline-spans-attempts case by elapsed time, not by which terminal error wins📊 Changes
8 files changed (+760 additions, -59 deletions)
View changed files
📝
CHANGELOG.md(+14 -1)📝
README.md(+37 -11)📝
src/sdk/index.ts(+6 -0)📝
src/sdk/models.test.ts(+241 -2)📝
src/sdk/models.ts(+127 -44)➕
src/sdk/retry.test.ts(+120 -0)➕
src/sdk/retry.ts(+164 -0)📝
test/support/router-stub-server.ts(+51 -1)📄 Description
ELI-5
A model call can take minutes, because the server holds the connection open until the picture is finished. That makes two things ordinary rather than exceptional: the connection breaking after the model already ran, and a user wanting to press cancel. This PR handles both — the SDK now retries a broken call, and it retries it in a way that cannot pay for the same generation twice, because every attempt carries the same
Idempotency-Keyand the server answers the repeat with the original result.What changed
comfy.models.rungains a retry loop and a documented retry policy. The policy itself (src/sdk/retry.ts) is sans-IO — pure decisions, unit-tested without a socket — andmodels.tsholds the loop that acts on it.Idempotency-Keyis minted once perrun, outside the loop, so the first attempt and every retry send the same one. A freshrunis a fresh key and a new logical call. Both halves are asserted by test.404,409,422orcontent_policy_violationis the server's answer about the request, not a sign the server is unwell, so resending it buys the same verdict twice. A terminal bucket that arrives under a 5xx (X-Comfy-Error-Type: content_policy_violation) is treated the same way — status alone would read it as transient.retry.budgetMs(default 2 minutes) is measured against the clock from the first attempt, and a further attempt starts only if its backoff ends inside the budget. On a route whose attempts take minutes, "3 retries" says nothing at all about how long a call can run.retry.baseDelayMs(500ms), capped atretry.maxDelayMs(8s), with equal jitter — half the delay fixed so a struggling server still gets a real pause, half random so clients that failed against one incident do not re-land together.timeoutMsnow spans the whole call rather than restarting per attempt, so retrying cannot make a call outlive the deadline its caller asked for.AbortError.retry: falsemakes a call a single attempt. Defaults are documented in the README with a table.Acceptance criteria
All eight are met; two were already true before this PR and are now covered by a test rather than by inspection.
models.test.ts"sends the SAME Idempotency-Key…", "mints a new key for the next call…"retry.test.ts"bounds by total elapsed time, not by an attempt count";models.test.ts"a bigger budget buys more attempts"422/409/404/content_policy_violationnotretry.test.ts"never retries a verdict about the request itself";models.test.ts"does not retry a verdict about the request…"retry.test.ts"jitters…", "actually varies…"models.test.ts"aborts the underlying connection, so the server sees a disconnect"AbortError, told apart from a transportTypeErrorand from this SDK'sComfyError/request_timeout. Now pinned by "rejects with a distinguishable abort, not a generic network error"models.test.ts"stops the retry loop mid-backoff…"models.test.ts"makes the call a single attempt when retries are disabled"Judgment calls
comfy.models.run. That is the surface that holds a connection through server-side polling, which is what makes retry-with-a-stable-key and cancellation load-bearing here rather than nice-to-have. The class client (new Comfy().run(workflow)) submits and then polls, so each of its requests is short and it already has its own orchestration retries (queue-full backoff withRetry-After, an SSE reconnect loop, an abortable poll backoff) plussignalon every method. Adding a second, transport-level retry underneath those would double-retry the queue-full path. It is not covered here and is worth its own change if wanted.signal, notabortSignal. The option is alreadysignalacross this SDK andfetchitself; renaming it would break a published surface for no behavioral gain.retry: false.Retry-Afteris not honoured. The jittered backoff is used even when a 5xx carries the header.429is not retried at all, which is where that header usually lives on this surface.Known limitation, and why it is not fixed here
Retrying a 5xx is only free once the server stores the first response against the
Idempotency-Keyand replays it for a repeat. That server-side replay is separate, in-flight work and is explicitly out of scope for this change. Until it ships, a retried 5xx may re-run the model rather than replay it, so a call that failed after the generation completed could be billed twice. The client-side half has to land first — the key has to be on every attempt before there is anything for the server to key a replay off — which is why this ships now, but a reviewer should weigh that ordering rather than read "idempotent retry" as already end-to-end.retry: falseis the escape hatch in the meantime.Also unfixed and not attempted:
content_policy_violationis refused by status plus theX-Comfy-Error-Typeheader, never by reading the error body, so a policy verdict delivered under a 5xx with no such header would be retried once per budget.Unexercised artifacts
The ticket driving this work links four provenance sources (an internal doc, a chat permalink, and two interactive-session notes) and names a parent epic and a paired server-side issue. None of their contents are reachable from this environment, so this change was built against the ticket's own description and acceptance criteria plus the code already on
main. The paired server-side work in particular was not read — the limitation above is stated from the ticket's own summary of it, not from that issue's text.Everything the change itself touches was exercised: the stub router was driven through 5xx sequences, socket resets, a hung request with a real client abort, budget exhaustion, and a deadline that spans attempts.
Test plan
Two pre-existing tests (
504 provider_timeoutmapping,502HTML body) now passretry: false: they assert error mapping, and a 5xx is now retryable, so left alone they would spend the whole retry budget before raising the error under test. No assertion in them changed.The second commit fixes a race in one test I wrote: pinning
err.codetorequest_timeoutfor the deadline-spans-attempts case was wrong, because when the last 503 lands just before the deadline the loop correctly falls through and raises the server's own error instead. Both outcomes prove the deadline stopped the loop; the elapsed time and the attempt count are what prove it was the deadline rather than the 60x-larger retry budget, so those are what the test asserts now.Provenance
pnpm test: 278 passed, 0 failed (19 files);pnpm lint,pnpm format:check,pnpm typecheck,pnpm check:spec-drift,pnpm build,node scripts/check-public-repo-hygiene.mjs: all clean locally. CI green on the pushed head: Test (node 22), Test (node 24), build-check, public-repo-hygiene all passcomfy.models.runrather than the whole SDK — see Judgment calls. The retry-is-free premise depends on server-side idempotency replay that has not shipped — see Known limitation.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.