mirror of
https://github.com/Comfy-Org/comfy-python-sdk.git
synced 2026-11-04 23:37:47 +00:00
[PR #158] feat!: subscribe's timeout detaches from an in-flight run instead of erroring #158
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#158
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/158
Author: @mattmillerai
Created: 9/16/2026
Status: 🔄 Open
Base:
main← Head:matt/be-14561-subscribe-timeout-detach📝 Commits (6)
b6f253efeat!: subscribe's timeout detaches from an in-flight run instead of erroring21a7b0bMerge origin/main into matt/be-14561-subscribe-timeout-detachc76ebd4fix(models): read the cancel's answer before calling a subscribe timeout cancellede077856fix(models): read the cancel route's 202 CANCELLATION_REQUESTED per contract (#172)2ba9e5dMerge origin/main into matt/be-14561-subscribe-timeout-detache3aadfdfix(tests): reconcile the credits_used tripwire now that the spec declares it📊 Changes
9 files changed (+2024 additions, -94 deletions)
View changed files
📝
CHANGELOG.md(+70 -22)📝
README.md(+83 -6)📝
src/comfy_sdk/__init__.py(+16 -1)📝
src/comfy_sdk/model_requests.py(+698 -10)📝
src/comfy_sdk/models.py(+120 -25)📝
tests/conftest.py(+64 -15)📝
tests/test_models_queue.py(+930 -3)📝
tests/test_router_spec_contract.py(+19 -9)📝
tests/test_sync_async_parity.py(+24 -3)📄 Description
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 #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.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.