mirror of
https://github.com/Comfy-Org/comfy-python-sdk.git
synced 2026-11-04 23:37:47 +00:00
[PR #99] [MERGED] feat: collect a Router deadline 504 under the same Idempotency-Key by default #99
Labels
No labels
audit:ai-readiness
audit:community
audit:github
audit:health-scoring
audit:policy
audit:release
audit:supply-chain
effort:medium
effort:small
effort:small
effort:trivial
priority:nice-to-have
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-python-sdk#99
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-python-sdk/pull/99
Author: @mattmillerai
Created: 8/27/2026
Status: ✅ Merged
Merged: 8/27/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-9836-router-deadline-504-same-key-retry📝 Commits (2)
d0f22dcfeat: collect a Router deadline 504 under the same Idempotency-Key by default8d0d883fix: close the gates the collect loop is only safe behind📊 Changes
10 files changed (+1078 additions, -153 deletions)
View changed files
📝
README.md(+50 -25)📝
src/comfy_low/errors.py(+43 -1)📝
src/comfy_low/transport.py(+10 -1)📝
src/comfy_sdk/models.py(+31 -11)📝
src/comfy_sdk/retry.py(+296 -69)📝
src/comfy_sdk/router_exceptions.py(+18 -13)📝
tests/conftest.py(+63 -14)📝
tests/test_error_mapping.py(+79 -2)📝
tests/test_models_run_retry.py(+479 -15)📝
tests/test_router_exceptions.py(+9 -2)📄 Description
ELI-5
When a model run takes longer than Comfy is willing to hold the connection open, the server answers
504 deadline_exceeded— but it keeps your idempotency reservation, stamps the provider's task handle on it, and tells you (inRetry-After) when to ask again with the sameIdempotency-Key. Asking again collects the generation that is still running rather than starting a second one. The SDK was not asking. It filed that 504 with every other 5xx as "outcome unknown, key possibly claimed" and handed the caller an error for a generation they get billed for either way. Now onerun()call waits out the pace, re-presents the same key, and rides the collect loop to the finished result.What changed
A fourth failure class — collectable — sits ahead of the unknown-outcome one in
RetryPolicy.should_retry:deadline_exceeded504 carryingRetry-After, andgeneration_in_progress409 carryingRetry-After— the idempotency layer's "still in progress", which is what the collect retry meets when it arrives before the generation finishes.Both gates are load-bearing, and both come straight off the vendored contract in
spec/router-openapi.yaml:deadline_exceededshares 504 withprovider_timeout, and a header-less 504 from an intermediary is read as the latter — where nothing blesses a same-key resend. Only the bucket the contract names is resent. (This is the exact reason the previous PR gave for not acting on the bucket;error_bucket_ofis what removes it.)Retry-Afteris present on this bucket "only when Comfy holds a handle to a generation the provider is still running… Absent when there is nothing to collect". No pace, nothing to collect, no resend.error_bucket_ofreads the bucket off either layer's name for it —error_typeon a typedRouterError,codeon thecomfy_low.ApiErrorthatPOST /models/runraises today. Reading onlyerror_typewould have made the whole rule unreachable on the route it was written for: a retry that is a silent no-op with no test failing, which is the failure mode this module has already been bitten by twice.retry_possibly_in_flightis untouched and keeps its meaning — the class no contract characterises (a 5xx that named no collectable pace, and the client-side timeout). The new class is the one where the server itself said the resend is safe. Both docstrings now say which is which.Two budgets, because the classes have two shapes.
max_elapsedstays at 60s and governs the fast classes (connect failure, paced 429), which resolve in seconds or not at all — a blackholed host has no business pinning a caller thread for longer. A newcollect_max_elapsedis 1200s and governs the collect class alone, selected per failure byis_collectable; both are measured from the sameRetrierorigin.1200s is derived, not invented: a
deadline_exceeded504 arrives at Comfy's own bound, the same ten minutes asMODEL_RUN_TIMEOUT, so a budget of one deadline window is already spent by the time the 504 lands and the collect attempt it exists for never starts. Two windows is one to reach the 504 and one to collect what it left running. Nothing else pays for that room.No feature flag, as asked. The control surface is the existing
RetryPolicy:Comfy(retry=NO_RETRY)disables retrying entirely, andRetryPolicy(retry_collectable=False)turns off just this rule. The new field is declared last in the dataclass, deliberately out of reading order, so that adding it cannot silently change what an existing positionalRetryPolicy(...)means.Docstring correction.
models.run()claimed the key is "single-use and reject-on-duplicate". That is the v2 jobs API's rule (spec/openapi.yaml's sharedIdempotencyKeyparameter), and it governssubmit(), not this route. It is scoped there now, and the router's replay-on-duplicate behaviour — same key + same body collects the generation already started, different body is rejected422 idempotency_key_reuse— is stated in its place. The README's retry table and budget row were carrying the same claim and are corrected too.Judgment calls
generation_in_progressbucket and onRetry-Afterinstead, the same two-gate shape the 504 uses, and it fails closed: a bucket-less proxy or WAF conflict,hash_mismatch(whichspec/openapi.yamlgives aRetry-Afterand which is still a deterministic refusal), andasset_in_useall stay refusals. A 409 is a 4xx, so this is the one place the diff narrows an existing "every other 4xx is never retried" branch; the narrowing is bucket-scoped, and it is tested in both directions.422 idempotency_key_reuseand the caller sees that instead of the 504 — one extra request, and a less diagnosable error. That is the same objection the previous PR raised against retryingservice_unavailableby default, and the thing that answers it here is that the contract states the carry for this bucket only.retry_collectable=Falseis the escape hatch, and it is named in theDeadlineExceededdocstring where someone hitting the 422 will look.service_unavailableis deliberately still not retried by default. Nothing in either vendored spec says a 503 releases the key; that decision, and its test, are unchanged.provider_timeout504 — are the contract's own distinctions, read out ofspec/router-openapi.yamlin this repo rather than assumed.Scope not covered by the fix
Three SDK surfaces send an
Idempotency-Key:POST /models/run(this change),POST /jobsbehindsubmit(), andPOST /assets. The other two are the v2 jobs contract's reject-on-duplicate surface with no documented replay, and they keep their own 429 handling — untouched, and correctly so. Of the fifteen router error buckets the vendored spec declares, exactly one (deadline_exceeded) carries a contract-level same-key-retry blessing and is the one this enables; a second (service_unavailable) asks for a retry but says nothing about the key and stays behind the opt-in; the remaining thirteen are unchanged.Testing
pytest -q— 574 passed, 4 skipped (the same 4 gateway-e2e skips as onmain).ruff check .,ruff format --check .,mypy src,scripts/check_public_repo_hygiene.pyandscripts/check_drift.py(both model and router-error-type checks) all clean.Collect-loop tests: a deadline 504 +
Retry-Afteris collected under one key, sync and async, with both attempts asserted to carry the same non-Noneheader value; a 504 with no pace is raised, not retried; aprovider_timeout504 with a pace is raised, not retried; ageneration_in_progress409 +Retry-Afteris waited out across three attempts on one key; a 409 with no pace, and a pacedhash_mismatch409, are both still refusals;NO_RETRYandretry_collectable=Falseeach disable the loop (the latter now also withretry_possibly_in_flight=True, where it previously did not hold); the loop gives up atcollect_max_elapsed(asserted both on the wire and exactly against a fake clock, where a 30s pace is clamped to the 10s left of a 100s budget); and the bucket is read off botherror_typeandcode.Gate tests added in review: a
Retry-After: 0falls back to the jittered backoff instead of spinning, while still counting as the server holding a handle; the collect budget outlasts one deadline window and applies only to the collect class; a 504 arriving at the 600s bound still gets a collect attempt; the body snapshot survives a caller mutating a nested value between attempts (verified to fail on the shallow copy); anderror_from_envelopereads Router's{detail, error_type}+X-Comfy-Error-Typeshape without retyping any status this API already has a documented code for.The stub server gained
model_run_collects_after_deadline— the narrower carry the router describes, where only the 504's reservation survives — kept distinct frommodel_run_replays_idempotency_key, the whole-deployment replay the opt-in exists for. That carve-out is keyed on the bucket as well as the status, so the stub is not more permissive than the contract it stands in for. It also gainedmodel_run_router_error_shape, which answers in Router's own error shape rather than the v2 envelope; without it, nothing exercised the shape a real deployment actually sends. Without the carry flag the stub still claims the key on a 5xx and answers the retry422, andtest_the_default_collect_loop_against_a_non_collecting_deploymentpins exactly that outcome: the assertion is the contract, not a stub loosened to fit the change.Residual
Two items previously listed here — a server-named pace of
0spinning for the whole budget, and the larger budget being charged to every failure class — were fixed in review (8d0d883) rather than carried, along with six other panel findings: the bucket never reachingis_collectableagainst Router's real error shape (which made the whole rule a production no-op), the ungated 409, the shallow body snapshot, the ineffectiveretry_collectable=Falseunder the in-flight opt-in, the stub's status-only key-claim carve-out, and the README table row.spec/router-openapi.yamlare taken on report: the 409 "already in progress" +Retry-Afterresponse shape, the 24h replay window, and same-body-collects / different-body-422. The 504 half is fully grounded — the vendored spec states the bucket's same-key retry and itsRetry-Aftersemantics verbatim — but the 409 rule and the correctedmodels.run()docstring rest on the unread half. Anyone with access should confirm the 409 status and header against the middleware before this is treated as settled.spec/router-openapi.yaml'srunRouterModeloperation declares noIdempotency-Keyparameter and no409response, so the idempotency layer's contract is invisible to the SDK's own drift gate. The next Router spec sync should add both; until thentests/test_models_run_retry.pyis the only thing pinning the 409 behaviour, and it pins this PR's premise rather than the server's.422 idempotency_key_reusereplaces the original 504. The retry loop raises whatever the last attempt raised, so on a deployment without the reservation carry the caller loses the diagnosable error.test_the_default_collect_loop_against_a_non_collecting_deploymentnow pins that outcome rather than leaving it undescribed, andretry_collectable=Falseis the escape hatch. Preserving and re-raising the first failure when the retry fails with a key-reuse error would fix it properly, at the cost of state in the loop; filed as a follow-up rather than attempted here, since it changes what the loop raises for every class, not just this one.Provenance
pytest -q: 574 passed, 4 skipped;ruff check .: All checks passed;ruff format --check .: 51 files already formatted;mypy src: no issues in 19 files;scripts/check_drift.py: models in sync withspec/openapi.yaml, all 15 router error types covered;scripts/check_public_repo_hygiene.py: no internal-only referencesmax_elapsedchange stated in the original acceptance criteria (60s → 600s) was superseded in review:max_elapsedstays at 60s and the collect class gets its owncollect_max_elapsed, which meets the criterion's intent (a collect loop that outlasts the deadline) without charging the other classes for it.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.