fix(models): read the cancel route's 202 CANCELLATION_REQUESTED per contract - #172
Conversation
…ontract
The cancel route answers `202 {"status": "CANCELLATION_REQUESTED"}` only
once its guarded cancellation write has already moved the request to
COMPLETED with error_type=cancelled; its only 409 is ALREADY_COMPLETED,
and there is no in-flight refusal. The timeout teardown did not model
that value at all, so the contract's own answer fell through the
"non-terminal status" branch, detached, and the confirming poll's
COMPLETED+cancelled row was re-raised as `Cancelled` ("the model refused
the request") for a stop the SDK itself had asked for.
Name the statuses (IN_QUEUE / IN_PROGRESS exported, CANCELLATION_REQUESTED
kept private since it is a cancel-body value, not a queue state), read the
202 as ACCEPTED-and-confirm, and let one poll decide the ending: a row
COMPLETED/cancelled is the cancelled ending, IN_PROGRESS or an
unrecognised live status is a detach, and a row still IN_QUEUE reports
that the cancel did not take effect (ComfyError, code cancel_not_applied)
rather than claiming a detach the contract says cannot have been charged.
A DetachedRequest can no longer be constructed carrying IN_QUEUE at all.
The stub's cancel defaults now follow the contract rather than a shape no
deployment sends, and every test that inherited the old defaults states
the shape it is testing instead.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository: Comfy-Org/comfy-python-sdk/.coderabbit.yaml Review profile: ASSERTIVE Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Every reviewer in the matrix failed to contribute — see the panel summary for which cells errored, and the run logs for the underlying cause.
Panel: 0/6 reviewers contributed findings.
Reviewers that did not contribute: claude-opus-5-thinking-max:adversarial (error), gpt-5.6-sol-max:adversarial (error), kimi-k3-high:adversarial (error), claude-opus-5-thinking-max:edge-case (error), gpt-5.6-sol-max:edge-case (error), kimi-k3-high:edge-case (error)
e077856
into
matt/be-14561-subscribe-timeout-detach
STACKED — merging lands on
matt/be-14561-subscribe-timeout-detach(owned by Matt Miller, PR #158), NOTmain. Do not read anything below as "ready to merge" into the default branch: #158 has to land first.ELI-5
When
subscribe(timeout=N)runs out of patience it asks the server to cancel. The server answers202 CANCELLATION_REQUESTED— "I took your ask". The SDK had never heard of that word, so it filed it under "some status I don't recognise", detached, then polled, found the requestCOMPLETEDwith thecancelledbucket, and treated that like any other failed run: it raisedCancelled— "the model refused the request" — for a stop the SDK itself had just asked for. This teaches it the word, and makes one confirming poll decide the ending honestly.Description
The vendored contract (
spec/router-openapi.yaml,cancelRouterModelRequest) declares exactly two answers on the cancel route: a202namingCANCELLATION_REQUESTED, and a409namingALREADY_COMPLETED. The cancellation write is guarded on the request being non-terminal and lands before the202is written, so a request in either live state is cancelled rather than refused — there is no in-flight409.RouterQueueStatusis a deliberately closed enum ofIN_QUEUE/IN_PROGRESS/COMPLETED.Before this,
grep CANCELLATION_REQUESTED src testsreturned nothing, and the timeout teardown's reading of a 2xx cancel had no branch for it.What changed, all in
src/comfy_sdk/model_requests.pyunless noted:IN_QUEUEandIN_PROGRESSjoinCOMPLETEDin__all__and incomfy_sdk/__init__.py— they are contract values a caller comparingQueueUpdate.statuslegitimately wants, and each carries the spec line it comes from and the note that the enum is closed on purpose.CANCELLATION_REQUESTEDstays private (_CANCELLATION_REQUESTED): it is a value of the cancel body's own vocabulary, not a queue state, and the status route can never answer it.202is read as "accepted, confirm". A new_CancelReading.ACCEPTED, compared against the raw string case-sensitively.STOPPEDstill covers the body-less204andCOMPLETED+bucket,FINISHEDstill coversCOMPLETEDwith no bucket, andUNSTOPPEDstill covers a 2xx echoing a live queue state._detach_report(both handles) now answers four ways:COMPLETEDcarrying thecancelledbucket →None, the cancelled ending (SubscribeTimeout(cancelled=True)); any other completion → the update, unchanged;IN_QUEUE→ aComfyErrorcodedcancel_not_applied; any other live status → a detach. The cancelled case deliberately does not route through_collect_or_detach, whoseerror_type is not Nonebranch re-raises the typed error — right for a run that failed on its own, wrong for a stop we asked for._is_cancelled_completionis shared by both handles so they cannot drift (tests/test_sync_async_parity.pywalks both).IN_QUEUEis never reported as a detach. The spec'scancelledmeaning pins a request cancelled in that state as "never dispatched and cannot be charged", which is the exact opposite of what aDetachedRequestclaims. The invariant is enforced in_DetachedBase.__post_init__rather than in one_detached, so it covers the sync handle, the async handle, and direct construction alike. No capability is lost by that raise:SubscribeTimeoutcarries.request_idand.model, soclient.models.handle(model, request_id)still reaches the run exactly as the detach report's handle would have.409fallback stays, and now says what it is for. It has no producer today; the module comment no longer describes an in-flight refusal as "the shape the queue's state refusals have today" and instead says the route emits one409, that it is typed, and that this clause is kept because it fails closed.tests/conftest.py):queue_cancel_status200 → 202,queue_cancel_accept_status"COMPLETED" → "CANCELLATION_REQUESTED",queue_cancel_error_type"client_disconnected" → None. An accepted cancel marks the row cancelled for either accept status (the real row is terminal before the202), and the status route answersCOMPLETED+ a newqueue_cancelled_error_typeknob (default"cancelled"). The legacy204knob is unchanged. One knob is new —queue_cancel_applies— because a conforming stub cannot produce the violated-write-order case at all, and that branch needs a test.202confirmation rule; the sentence "the queue honours a cancel only while the request is still waiting to be dispatched" is gone, because the server cancelsIN_PROGRESSrows too and the spec says a mid-flight cancel "may still be charged".DetachedRequest,_DetachedBase.status,SubscribeTimeout.cancelled,RequestHandle.cancelandModels.subscribelose the same premise. CHANGELOG### Changedentry under feat!: subscribe's timeout detaches from an in-flight run instead of erroring #158's.Every existing test that inherited a non-contract default now states the shape it is testing explicitly.
IN_FLIGHT_REFUSALis renamedUNSHIPPED_BUCKETLESS_REFUSALand its comment says it models a hypothetical; theCANCELINGfixture stays as the unknown-live-status case with a docstring that no longer claims the server sends it; and the threeoutcome.status == server.state.queue_pending_statusassertions are now explicitIN_PROGRESSsetups.Falsification of the new deny path
Per the negative-claim rule, this diff adds a raise (
cancel_not_applied) and aValueErrorguard, so the premise behind them was checked against the artifact rather than assumed. Read-only, against the deployed handler and its repository layer atorigin/main(f76b2f9906):202 CANCELLATION_REQUESTEDis returned only on the branch where the guarded cancellation write reported rows affected — confirmed in the handler;409isALREADY_COMPLETED, reached when the guarded write matched nothing. There is no in-flight409, confirming the fallback clause has no producer;IN_QUEUE/IN_PROGRESS/COMPLETEDis the persisted column's own enum.Because the write precedes the
202, anIN_QUEUErow after an accepted cancel is genuinely unreachable on the shipped path — which is why it is reported as a server-side ordering violation rather than silently detached, and why it needs a stub knob to test at all. Source paths are deliberately not quoted here; this repository is public.How has this been tested?
uv run --extra dev pytest -q— 1021 passed, 9 skipped, 0 failed.ruff check .,ruff format --check .,mypy srcandscripts/check_drift.pyall clean.New coverage in
tests/test_models_queue.py, sync and async each: the contract path on a queued request (202→COMPLETED/cancelled→cancelled is True,queue_cancel_count == 1,queue_status_count == 2,queue_result_count == 0); the same wire shape on an in-flight request, with the spec's charging caveat in the docstring;202then a row stillIN_QUEUE(cancelled is False,.cancel_error.code == "cancel_not_applied", noDetachedRequest, the request id instr(exc), and the error not chained onto a poll failure);202thenIN_PROGRESS→DetachedRequestpinned to the enum value;409 ALREADY_COMPLETEDracing an earlier cancel → the cancelled ending rather thanCancelled; the narrow-side test that any other terminal bucket still raises its typed router error; a parametrised unit test of every 2xx cancel-body shape including a lower-case echo that must not read as the contract's accept; theIN_QUEUE-detach invariant; and the spike's exact stub reproduction, which now raisesSubscribeTimeoutwithcancelled is True.Documentation
README "A
timeoutdetaches; it does not reliably cancel" and thehandle.cancel()row above it; CHANGELOG### Addedand### Changed; the docstrings listed above.Residual
409the route emits. This repository is public, and the fleet's public-repo guardrail forbids private backend paths, schema or handler names in code, commits or PR text — so the comment cites the vendoredspec/router-openapi.yamlinstead and describes the behaviour (the write is guarded on the request being non-terminal, the409is reached only when the guard matched nothing). The claim is identical and was verified directly against the backend source; only the citation moved. The falsification section above names no paths for the same reason. Nothing else in this repository carried such a reference before this change, and nothing does now.IN_QUEUE-after-refusal case changed behaviour too, beyond the202. A bucket-less409refusal whose confirming poll finds the request stillIN_QUEUEpreviously producedDetachedRequest(status="IN_QUEUE"); it now raisescancel_not_appliedlike the accepted-cancel path. That is the same invariant applied consistently and it fixes the same false billing claim, but it is a second behavioural change and worth a reviewer's eye. No shipped deployment produces that refusal, so no real caller is expected to be on this path._CancelReading.ACCEPTEDis never branched on. It falls through to_detach_reportexactly asUNSTOPPEDdoes, per the plan. It is distinguished because the two mean genuinely different things and the parametrised reading test pins both; a reviewer who prefers one member for both readings should say so.202and409are exercised only against the in-repo stub — no live or staging deployment was called, and nothing here was verified end to end on the wire. The related cloud PR the plan names measures the cancel409envelope end to end but does not assert this SDK's exception class, so it neither covers nor conflicts with this; it was not run or read as part of this change. The upstream investigation ticket's findings comment, which carries the original evidence, was not readable from this environment — the backend verification above was done independently against the deployed source rather than inherited from it.Provenance
uv run --extra dev pytest -q: 1021 passed, 9 skipped, 0 failed;ruff check .: all checks passed;ruff format --check .: 57 files already formatted;mypy src: no issues in 21 source files;scripts/check_drift.py: 3 OK, 0 driftIN_QUEUEinvariant is enforced in_DetachedBase.__post_init__rather than in_detached, which the plan offered as an alternative — it is strictly broader, covering both handles and direct construction.