feat!: subscribe's timeout detaches from an in-flight run instead of erroring - #158
mattmillerai wants to merge 3 commits into
Conversation
…erroring The queue honours a cancel only while a request is still waiting to be dispatched. A run it has already started is served to the end and billed whatever the caller does, and the cleanup cancel `subscribe` fires on its own timeout is refused rather than ignored. Until now that refusal was swallowed along with every other cancel failure and the caller saw a bare `TimeoutError` — which reads as "the run stopped", the one thing that did not happen. A timeout is therefore a detach, and `subscribe` now says so. Its three endings, told apart by type and never by a message: * cancelled — the cancel was accepted, nothing ran, nothing is billed. Raises `SubscribeTimeout`, a `TimeoutError` subclass, with `cancelled=True`. * detached — the queue refused the cancel on the request's own state and a confirming poll found the run still going. Returns a `DetachedRequest` (`AsyncDetachedRequest`) carrying the request id, the model id and a live handle, so the generation being billed for stays collectable. * completed during teardown — the same refusal, but the run had just finished. The result exists and is paid for, so it is collected and returned. A cancel that fails for any other reason is no longer swallowed: a transport failure, a `401` or a `500` reaches the caller on `SubscribeTimeout .cancel_error` and `__cause__` with `cancelled=False`, because a cancel that never landed says the run may still be going. The refusal is recognised by its HTTP status — `409`, the state conflict the queue already answers a cancel-after-completion with — rather than by its prose, which no contract pins. When the contract names an error bucket for it, `_refused_on_state` is the one predicate to widen. BREAKING: `models.subscribe` returns `dict[str, Any] | DetachedRequest`.
|
Warning Review paused — included plan limit reachedKeep your review moving with free on-demand reviews.
On-demand reviews are free for the next 21 days.
Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing. Promotion and pricing detailsOn-demand reviews are free for the next 21 days. After that, they cost $0.25 per reviewed file. Review limit detailsOr wait 25 minutes for your next included review. Limit details: You’ve used the included review currently available. Your 139 included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Review configuration: ⚙️ Run configurationConfiguration used: Repository: Comfy-Org/comfy-python-sdk/.coderabbit.yaml Review profile: ASSERTIVE Plan: Team Run ID: 📒 Files selected for processing (8)
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 8 finding(s).
| Severity | Count |
|---|---|
| 🟠 High | 1 |
| 🟡 Medium | 2 |
| 🟢 Low | 5 |
Panel: 6/6 reviewers contributed findings.
Two conflicts, both resolved keeping BOTH sides: * `CHANGELOG.md` — additive. `### Added` (this branch's detach surface) and `### Fixed` (main's Router-refusal typing) are disjoint; the two `### Changed` bodies are concatenated in Keep-a-Changelog section order. * `tests/conftest.py` — both sides reshaped the stub's cancel refusal. Main's `(status, body_dict)` is strictly more general than this branch's `(status, detail_str)`, so main's wins and this branch's tests now pass the body they mean. The auto-merge had also left `queue_cancel_refusal` declared TWICE on `ServerState` (one from each side, the later silently winning); deduped to main's, carrying across the part of this branch's comment that still holds. `uv run --extra dev pytest -q`: 992 passed, 9 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…out cancelled Review round on the detach surface. Six findings, all on the timeout teardown. **A 2xx cancel was taken as proof the run stopped** (raised by 5 of 6 reviewers). `_cancel_best_effort` discarded the response body and the binding accepts 200, 202 and 204 alike, so a `202`/`CANCELING` — or a `200` on a request that won the race into flight — came back as `SubscribeTimeout(cancelled=True)`: "nothing ran, nothing is billed", which is the one reading the response does not support, and `cancel()`'s own docstring already tells its callers the opposite. The cancel now hands back the `QueueUpdate` it was answered with and `_reading_of_accepted_cancel` reads it: terminal with a bucket is a stop, terminal without one is a run that finished and is collected, a live status is a detach. A body-less `204` still reports a cancellation — nothing in an empty body contradicts the accept, and charging every timeout an extra round trip for the rare case is the wrong trade. **`_refused_on_state` keyed on the bare 409** (6 of 6). Every 409 the cancel route can answer read as the benign state refusal, including buckets the vendored contract documents as something else (`invalid_input`, `concurrency_limit_exceeded`), which were then reported as a detach asserting the run is in flight and billed. Now: a typed `CancelRefused` by its class first — main #157 landed that while this branch was open — then a 409 that names NO bucket, which is the in-flight refusal's shape today. It fails closed, matching `retry.is_collectable` and `_CODE_BY_STATUS`. "Names no bucket" covers `comfy_low`'s synthetic `http_409`, which that layer documents as "nothing identified this response". **A failing teardown collect strandeded the caller** (5 of 6). The `_collect` sat outside the guard, so a transport error, a 5xx or the budget expiring propagated raw out of the timeout handler: the caller's `except TimeoutError` never fired and nothing handed back the ids for a generation that HAS finished and HAS been billed. `_collect_or_detach` degrades that to a `DetachedRequest` over the same handle. A completion carrying its own `error_type` still raises — that is the run's outcome, not a failure to read it, and a detach there would claim a finished run is still going. **The teardown completion never reached `on_queue_update`** (4 of 6), despite the docstring promising "every change of status ... and the completion". On the one timeout ending that returns a result it is the only place a caller's state machine can learn the run ended. Routed through the callback, awaited on the async side as the loop does. **`SubscribeTimeout` could not be pickled** (2 of 6). `BaseException.__reduce__` rebuilds from `args` alone, so it reconstructed as `SubscribeTimeout(message)` and died on the three required keyword-only fields — masking the real error in exactly the cross-process workflow this surface exists for, where `request_id` is the only route back to a billed generation. `__reduce__` added. **`status=""` left the billing claim unverified** (1 of 6) — the confirming poll swallows auth and 404 alike. Documented on the field rather than changed; telling those apart needs a contract that does not exist yet. Teardown overrun (3 of 6) is documented rather than restructured: `timeout` bounds the wait, and deciding the ending costs up to three further bounded round trips (~30s worst case). Said so on the method and in the README. `uv run --extra dev pytest -q`: 1004 passed, 9 skipped (12 new tests). `ruff check`/`ruff format --check`/`mypy src`/`check_public_repo_hygiene.py`/ `check_drift.py`: all clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 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
models.subscribe(..., timeout=N)runs out of time it quietly asks the server to cancel the run. The queue only accepts that cancel while the request is still waiting in line — once it has been dispatched, the run is served to the end and billed whatever you do, and the cancel is refused. Until now the SDK swallowed that refusal along with every other cancel failure and raised a bareTimeoutError, which reads as "the run stopped" — the one thing that did not happen. Now a timeout on an in-flight run is reported as what it is: a detach. You stop waiting, the generation carries on, and you get back the ids and a live handle to collect the thing you are being billed for.What changed
models.subscribe(timeout=N)now has three endings, told apart by type and never by a message string:SubscribeTimeout(aTimeoutErrorsubclass) with.cancelledTrueDetachedRequest/AsyncDetachedRequestcarrying.request_id,.model,.statusand a live.handleA cancel that fails for any other reason — a transport failure, a
401, a500— is no longer swallowed.SubscribeTimeoutis raised with.cancelledFalseand the cancel's own failure on.cancel_errorand__cause__, because a cancel that never landed means the run may still be going. Before this change all three of those looked identical to a successful cancellation.Sync and async, with the same names and the same arguments; the async side returns
AsyncDetachedRequestso the handle it carries is the awaitable one.New public names, all exported from
comfy_sdk:DetachedRequest,AsyncDetachedRequest,SubscribeTimeout.How the refusal is recognised
One predicate,
_refused_on_state, and it asks the typed question first. Rebasing ontomainpicked up #157, which types the cancel route's refusals asCancelRefused/AlreadyCompleted— so the "branching on a typed class is strictly better than branching on a status" this originally deferred is now simply what it does:The status clause is the fallback for the one refusal the contract has not named yet — the in-flight one, which today carries no bucket on the header or in the body. It fails closed: a
409that names a bucket the contract does document (invalid_input,concurrency_limit_exceededwith itsRetry-After) is not the state refusal and surfaces on.cancel_errorrather than being reported as a detach. That matches two nearby precedents —retry.is_collectablegates409on status and bucket, andcomfy_low.errors._CODE_BY_STATUSomits409outright as ambiguous."Names no bucket" has two spellings, because the layer below fills the gap rather than leaving it:
comfy_low.errorssynthesisescode = f"http_{http_status}"for a response that identified itself with nothing, and documents that shape as unable to collide with a wirecodeor a Router bucket. So a bucket-less409arrives carryinghttp_409, notNone, and both are read as "named nothing".No body-text match anywhere.
How an ACCEPTED cancel is read
A 2xx says the route took the message, not that the run stopped — the binding accepts
200,202and204alike. The cleanup cancel now hands back theQueueUpdateit was answered with, and that body decides:COMPLETEDwith anerror_typeCANCELEDstatus; it expresses a stop asCOMPLETEDplus a bucketCOMPLETEDwithout a bucket202/CANCELING, or a200on a request that won the race into flight)204)Because the refusal names nothing about the request's state, the detach is confirmed against the server: one unretried, 10s-bounded status poll, which is what separates "still running" from "finished while we were tearing down". That follows the rule the surface already states — the authoritative state of a request is the next status read, never what a cancel answered. A poll that itself fails does not undo what the refusal established (the request was not cancelled), so it still reports a detach, with
statusleft empty to say the state was unconfirmed.Compatibility
SubscribeTimeoutsubclassesTimeoutError, so every existingexcept TimeoutErroraroundsubscribekeeps catching the cases that still raise, with the same message plus a clause naming the outcome.models.subscribeis now annotateddict[str, Any] | DetachedRequest. A typed caller that indexes the result directly has to check the type first. This is the behaviour change the detach is — the alternative is telling a caller their run was cancelled when it was not — and the queued surface shipped two days ago behind a server-side gate, so the blast radius is small. Recorded under### Changedin the changelog.SubscribeTimeoutpickles and copies with every field intact (__reduce__), so.request_idsurvives reaching another process — which is the workflow the queued surface exists for.timeoutbounds the wait, not the teardown. Deciding which of the three endings happened costs up to three further round trips past the deadline (the cancel, the confirming poll, and on the completed path the result fetch), each bounded at 10s — so asubscribe(timeout=N)can return up to ~30s afterNin the worst case. Now stated on the method and in the README; it was previously ~10s and undocumented.handle.cancel(),handle.get()andhandle.iter_events()are untouched.iter_eventsstill never cancels — aforloop with abreakmust not be destructive.Verification of the negative claim
This diff's user-facing outcome does not deny a capability, and the empirical check says so rather than the prose: the cleanup cancel is still attempted on every timeout via exactly the path it used before, and the tests assert
queue_cancel_count == 1on each of the three endings. Nothing that could be cancelled before is refused here — a cancel the queue accepts still cancels, andhandle.cancel()is unchanged. What the diff removes is a raise, not a capability: the previously-dead-ended timeout now hands back a working handle, and the re-attach path it points at is exercised (models.handle(model, request_id).get()returns the completed result, sync and async).Judgment calls
SubscribeTimeoutrather than a bareTimeoutError. A subclass, so no handler breaks; it is what carries.request_id,.modeland.cancelledfor the outcomes that still raise..cancel_errorand__cause__.DetachedRequest/AsyncDetachedRequestare frozen dataclasses whose whole surface is fields, so the exemption is declared in a new_DATA_ONLYtable with its reason, alongside the file's existing_RENAMES/_ALLOWED_ASYMMETRY/_SYNC_ON_BOTHtables, and it is itself checked for staleness (it fails if the pair ever grows a method the exemption would then hide).Residual
CancelRefused/AlreadyCompleted, from fix(errors): makeexcept RouterErrorcatch every Router refusal #157), but the in-flight refusal has not shipped server side; its409is the documented state-conflict status the cancel route already uses for cancel-after-completion, not a value read off the new refusal. If the server ships it with a different status, every timeout raises as it does today — no regression, but the detach never fires — and if it ships a named bucket, that bucket becomes aCancelRefusedsubclass and the status clause can be deleted outright._refused_on_stateinsrc/comfy_sdk/model_requests.pyis the single predicate to reconcile when the server PR lands. This is the follow-up: re-pin the predicate against the shipped refusal.QueueUpdate.statusis a deliberately open string;COMPLETEDis the only value the SDK names) and a behaviour decision about whatsubscribeshould do instead. Raised in review, deferred to a follow-up alongside the re-pin above, and tracked on that thread.PUT /v2/models/{provider}/{model}/requests/{id}/cancel— was exercised only against this repo's in-tree stub, never a live deployment. The test suite has no network dependency by design and this environment has no credentials for the router; the refusal is therefore a stub the SDK was written against, not a recorded response.src/for places that suppress a cleanup cancel's failure finds three: the syncsubscribetimeout and the asyncsubscribetimeout (both fixed here), and the asyncsubscribe'sasyncio.CancelledErrorpath (src/comfy_sdk/models.py), which still swallows every cancel failure alike, refusal included. It cannot report a detach — the caller is receiving no value from that call, and replacing theirCancelledErrorwould break the cancellation they asked for — so a task cancelled from outside still loses the request id of a run that may be billing. Reaching it needssubmitrather thansubscribe. Worth its own decision; a comment in the code now says so. The workflow-jobs surface has no equivalent site (Comfy.rundoes not cancel on timeout), so the count there is zero.timeoutor to making cancel-on-timeout opt-out.Provenance
uv run --extra dev pytest -q: 1004 passed, 9 skipped, 0 failed (12 new tests this round);ruff check .: all checks passed;ruff format --check .: 57 files already formatted;mypy src: no issues in 21 source files;python3 scripts/check_public_repo_hygiene.py: no internal-only references;uv run --extra codegen python scripts/check_drift.py: models in sync, all 18 router error types covered, run route matches the spec. All re-run on the merged tree (mainmerged in at21a7b0b), not on the branch alone.SubscribeTimeoutrather than a bareTimeoutError. The subclass keeps every existingexcept TimeoutErrorworking and is what carries the request id, but it is a narrowing of the exact class raised. The review round narrowed it further: an accepted cancel is no longer assumed to have stopped the run, so a 2xx answering a live status now returns aDetachedRequestwhere it previously raised withcancelled=True— closer to the criterion's intent (only a real cancellation reports one), but a second change to the exact outcome. No criterion was skipped; see## Residualfor what could not be exercised.