feat: collect a Router deadline 504 under the same Idempotency-Key by default - #99
Conversation
… default The router contract blesses exactly one same-key resend and the SDK was not making it. `spec/router-openapi.yaml`'s `deadline_exceeded` bucket says to "retry it with the SAME `Idempotency-Key`: when the provider had already accepted the generation, the retry collects that generation rather than dispatching another", and pins the `Retry-After` it carries to "seconds to wait before retrying the SAME request with the SAME `Idempotency-Key`" — sent "only when Comfy holds a handle to a generation the provider is still running". The default policy sorted that 504 with every other 5xx as an unknown outcome and declined it, so the designed replay path went unused and a caller got an error for a generation they are billed for either way. Adds a fourth failure class, "collectable", ahead of the unknown-outcome one: - a `deadline_exceeded` 504 carrying `Retry-After`, and - a 409 carrying `Retry-After` — the idempotency layer's "still in progress", which is what the collect retry meets before the generation finishes. Both gates are load-bearing. The 504 must name its bucket, because `deadline_exceeded` shares that status with `provider_timeout` (where nothing blesses the resend) and a header-less 504 from an intermediary reads as exactly that. The pace must be present, because without a handle to collect there is nothing to collect and a resend would dispatch new work. A 409 with no pace stays the deterministic refusal it was. `is_collectable` is the predicate; `error_bucket_of` reads the bucket off either layer's name for it (`error_type` on a typed router error, `code` on the protocol `ApiError` this route raises today) so the rule is not a silent no-op on the route it was written for. `retry_possibly_in_flight` is 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. Raises the default `max_elapsed` from 60s to 600s, one server deadline window and the same number as `MODEL_RUN_TIMEOUT`. A collect loop has to be able to outlast the deadline it is collecting after; 60s cannot. The cost lands on the other classes and is stated in the docs: an unreachable server now spends up to ten minutes connecting and backing off before it raises. `RetryPolicy(max_elapsed=60.0)` restores the old bound. No feature flag: `retry_collectable` is an ordinary `RetryPolicy` field (declared last so it cannot change what a positional `RetryPolicy(...)` means), and `Comfy(retry=NO_RETRY)` still disables retrying entirely. Corrects the `models.run()` docstring, which stated the v2 jobs API's single-use, reject-on-duplicate rule as though it governed this route. It is scoped to `submit()` now, and the router's replay-on-duplicate behaviour — same body collects, different body is rejected 422 — stated in its place. The stub server grows `model_run_collects_after_deadline`, the narrower carry the router describes (only the 504's reservation survives), kept distinct from `model_run_replays_idempotency_key`, the whole-deployment replay the opt-in is for — so the new tests assert against the contract rather than against a stub made permissive to fit them.
|
Important Approval pendingCodeRabbit has no unresolved comments, but it has not reviewed the latest commit. Use the checkbox below to review the latest commit. CodeRabbit will approve the changes if it finds no blocking issues.
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 10 finding(s).
| Severity | Count |
|---|---|
| 🟠 High | 3 |
| 🟡 Medium | 4 |
| 🟢 Low | 3 |
Panel: 8/8 reviewers contributed findings.
Review follow-up on the collect-by-default change. Eight findings from the
review panel, and the first two are the ones that mattered:
**The collect rule was a no-op against a real Router 504.** `POST
/api/v2/models/run` is fronted by Router, whose error body is `{detail,
error_type}` with the bucket repeated on `X-Comfy-Error-Type` -- not the v2
`{error: {code}}` envelope `error_from_envelope` read. Every real 504 therefore
reached `is_collectable` with the status-derived `"error"` bucket and fell
through, while every test passed because the stub only ever spoke the envelope.
`error_from_envelope` now reads the header and the body's `error_type` -- but
only after `_CODE_BY_STATUS`, so a Router 429 still maps to `queue_full` and
nothing integrators already catch gets retyped; what is left is the 5xx range,
where the status determines nothing. The stub grew a Router-shape mode and the
tests to go with it.
**`Retry-After: 0` was a busy loop.** `RouterRetryAfterHeader` is pinned to
`minimum: 1`, so zero is a server answering outside its own contract -- and it
was honoured verbatim, giving a zero-delay resend loop of full model-run POSTs
for the whole budget, entirely server-controlled. Zero now names no usable pace
and the jittered backoff answers instead. The header's *presence* still decides
collectability: that is the server saying it holds a generation, which a
nonsense value does not retract.
The rest:
- The 409 branch accepted any paced 409. Every 409 either vendored spec
documents is a deterministic refusal -- and `spec/openapi.yaml` gives its
`hash_mismatch` 409 a `Retry-After` outright -- so a permanent refusal or a
bucket-less proxy conflict was resent for the whole budget. Gated on
`generation_in_progress` now, fail-closed, the way the 504 is on its bucket.
- The budget is split in two. `max_elapsed` goes back to 60s and governs the
fast classes, so a blackholed host stops pinning a caller's thread for ten
minutes to buy the collect loop its room. `collect_max_elapsed` is 1200s and
governs the collect class alone -- a `deadline_exceeded` 504 arrives *at* the
server's own 600s bound, so the previous one-window budget was already spent
when it landed and the collect attempt it was sized for never started.
- `retry_collectable=False` did not hold with `retry_possibly_in_flight=True`:
the 504 fell through to the unknown-outcome branch and was retried anyway.
Collectability is classified first now and answers `retry_collectable`.
- The body snapshot was shallow, so a caller mutating a nested value during the
retry window sent a different body under the one key -- the 422 the snapshot
exists to prevent. Deep now.
- The stub's key-claim carve-out keyed on status 504 alone, making it more
permissive than the contract it stands in for; it takes the bucket too.
- README's "not retried" row listed the two 504 cases that *are* retried under
the in-flight opt-in.
Coverage added for the non-collecting deployment the default now bets against:
the resend comes back 422 `idempotency_key_reuse` in place of the real 504, and
`retry_collectable=False` is the way out. That trade is stated in the README
rather than left implicit.
|
All ten open review threads addressed in 8d0d883 and resolved with per-thread replies. Full suite green: 574 passed, 4 skipped; The two that changed the shape of the PR: The collect rule was a silent no-op against a real Router 504. The budget is split rather than raised. Two findings pulled opposite ways — one that 600s could not outlast the 600s deadline window it was sized for, one that a policy-wide 10x hurt every fast class. Both are right, so The rest: the 409 branch is bucket-gated on One item deferred rather than fixed here: when a same-key retry is rejected Not merging — leaving that to a human reviewer. |
|
🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:
The following carry
|
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.