mirror of
https://github.com/Comfy-Org/comfy-python-sdk.git
synced 2026-11-04 23:37:47 +00:00
[PR #168] fix(models,transport): read the Idempotent-Replayed header and lift the run timeout above Router's deadline #168
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#168
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/168
Author: @mattmillerai
Created: 9/18/2026
Status: 🔄 Open
Base:
main← Head:matt/be-15641-replayed-header-and-run-timeout📝 Commits (10+)
fff2165fix(models): read the Idempotent-Replayed header the Router contract declares5ec2ad1fix(transport): raise the model-run timeout above Router's deadlinef6865acfix(models,transport): case-insensitive run-result headers, spec-pinned names, sane write bound3e3cb7amerge main: reconcile Idempotent-Replayed/timeout lift with credits_used headeref02cd3docs(transport): stop the pool/write timeout prose contradicting itselffe40327Merge remote-tracking branch 'origin/main' into matt/be-15641-replayed-header-and-run-timeout501bbfddocs(changelog): record the run timeout lift and case-insensitive run headers449c171Merge remote-tracking branch 'origin/main' into matt/be-15641-replayed-header-and-run-timeout40add95Merge remote-tracking branch 'origin/main' into matt/be-15641-replayed-header-and-run-timeout0a962b8fix(retry): reserve the run's pool wait outside the collect budget📊 Changes
8 files changed (+355 additions, -44 deletions)
View changed files
📝
CHANGELOG.md(+17 -0)📝
README.md(+13 -9)📝
src/comfy_low/transport.py(+70 -2)📝
src/comfy_sdk/models.py(+48 -8)📝
src/comfy_sdk/retry.py(+35 -13)📝
tests/test_models_run.py(+51 -1)📝
tests/test_models_run_retry.py(+64 -9)📝
tests/test_router_spec_contract.py(+57 -2)📄 Description
ELI5
When a model run takes as long as the server allows, the server sends back a proper "ran out of time" answer that says which request it was and when to try again. The SDK used to give up waiting at exactly the same moment, so callers could get a bare timeout and lose that information about a generation that may already be billed. The SDK now waits one minute longer than the server's limit, it also reads the server's reply details reliably however their names are capitalised, and it keeps its automatic "collect the finished result" retry available even for a run that first had to wait for a free connection.
What changed
1.
replayedreads the header the contract declares.RouterRunResult._run_resultnow readsIdempotent-Replayedinstead ofX-Comfy-Idempotent-Replayed. The vendored contract confirms the name:spec/router-openapi.yamldefines theRouterIdempotentReplayedHeadercomponent and references it asIdempotent-Replayedon every replayable response (the200, and the recorded4xx/5xxshapes). The field's own docstring carried the same wrong prefix and is corrected too. Headers arrive ashttpx.Headers(case-insensitive), so the defect was purely the wrong name, not casing.Idempotent-Replayed) rather than a literal copied from the implementation — a literal repeated from the code is what let the bug pass review in the first place. A new end-to-end test (test_run_detailed_reports_a_replay_end_to_end_against_the_server) drives a real replay through the fake server, which replies with the actualIdempotent-Replayed: trueheader, and assertsrun_detailed(...).replayed is True. Reverting only the code line makes both of these fail (confirmed locally), so they genuinely guard the fix rather than restating it.2. Client run timeout now sits above Router's deadline.
MODEL_RUN_TIMEOUTis no longer a bare600.0. A namedROUTER_DEADLINE = 600.0constant mirrors Router's server-sideDefaultDeadline, and the client read timeout is derived asROUTER_DEADLINE + 60sof headroom — enough to receive the504and its headers instead of racing it. The coupling is documented at the constant ("if you raise Router's deadline, raise this too").connectstays 10s.test_the_collect_budget_outlasts_a_server_deadline_windowpreviously usedMODEL_RUN_TIMEOUT.readas its stand-in for "the server's deadline window" — that only worked because the two values were mistakenly equal, which is defect 2 itself. It now sources the window fromROUTER_DEADLINEand additionally asserts the new invariantMODEL_RUN_TIMEOUT.read > ROUTER_DEADLINE. The other timeout tests (>= 120sread,<= 30sconnect; the literal600.0server-deadline advances in the fake-clock tests) remain correct because Router's deadline itself is unchanged.Scope swept
Both defects are fully addressed in this repo. Sweep of the portion not being changed:
grepacrosssrc/found exactly three occurrences of the replayed-header spelling — the code read (fixed), the field docstring (fixed), and one prose reference atmodels.py:436that was already correct — and no other reader of the header anywhere insrc/. AllMODEL_RUN_TIMEOUTconsumers were checked; none assume the literal600except the one retry test corrected here.Residual
main.X-Comfy-Credits-Usedis now one of the five constants in_RUN_RESULT_HEADERS, and its name is pinned againstspec/router-openapi.yamlby both this PR's source-table test andmain's field-level lift test.router-reference.mdx(the header docs) androuterdeadline/routerdeadline.go(DefaultDeadline = 10 * time.Minute). I could not open either. I verified the header name against this repo's vendoredspec/router-openapi.yaml(synced from that service) instead, and encoded the 10-minute deadline asROUTER_DEADLINE = 600.0with a comment pointing at the Go constant; if Router's deadline is ever raised, this default must be raised to match.COMFY_ROUTER_DEADLINEin the client. The report floated deriving the timeout from that env var. I did not, because it configures the Router deployment's environment, not the caller's — a same-named client-side variable would be a misleading second source of truth that silently disagrees with the server it is meant to track. The client-side mitigation is the named-and-documentedROUTER_DEADLINEconstant plus headroom, which is the "at minimum, comment the relationship" path.Review round — cursor-review panel
Five threads, all resolved. Three fixes, one partial, one answered without a code change.
Fixed —
_run_resultreads headers case-insensitively. It is annotatedMapping[str, str]and all the names it reads are mixed-case, so it worked only becausepost_model_runhappens to hand it anhttpx.Headers. Any plain mapping —dict(resp.headers), which httpx lowercases — would have missed all of them and yieldedreplayed=Falseandrequest_id=Nonewithout raising, which is the same silent shape as the wrong header name this PR fixes. It now normalizes throughhttpx.Headers(headers)first.Fixed — the header names are pinned to the contract. The names (now five, including
X-Comfy-Credits-Used) are constants (_RUN_RESULT_HEADERS) andtests/test_router_spec_contract.py::test_the_headers_a_run_result_reads_are_the_spec_spins each againstspec/router-openapi.yaml'srunRouterModel200, parametrized per name, reached by searching for theoperationIdrather than looking up the path. The assertion is one-way (everything read is declared) because the 200 also carries headersRouterRunResultdoes not surface. Plustest_the_replayed_header_carries_no_x_comfy_prefixas a named regression case. This is the check whose absence let the prefixed name through.Fixed — docs that asserted the old equality. Decoupling the client timeout from Router's deadline falsified prose saying the
504arrives at "the same ten minutes asMODEL_RUN_TIMEOUT".retry.py's module docstring andcollect_max_elapseddoc,models.py'srundocstring, andREADME.mdnow point atROUTER_DEADLINE— the constant that is actually ten minutes and the onecollect_max_elapsed's "two windows" derivation depends on.Partial — the positional
httpx.Timeoutargument. Correct that one positional value setwriteandpoolas well asread.writeis fixed: pushing a request body has no business inheriting an eleven-minute generation wait, so it is now 120s (still generous for a multi-megabyte inline base64 image, which httpx hands to the transport as a single write).poolis deliberately kept generation-scale, because on this route the pool is full of generations: a run is awaited server-side, so all 100 of httpx's default connections are held for up toROUTER_DEADLINE, and the 101st caller is queued behind a generation rather than a handshake. A short pool bound would convert a legitimate fan-out from a queue that drains into a wall ofPoolTimeout— 150 concurrent 2-minute runs succeed today and would fail inside ~70s under a 10s bound plus the 60smax_elapsed. The accepted cost is written on the constant: in the pathological case where the pool never frees, the caller waits eleven minutes andPoolTimeoutsurfaces without a retry, its fast-class budget long spent. All four bounds are now named explicitly so none is inherited again, andtest_every_bound_of_the_run_timeout_is_chosen_rather_than_inheritedasserts the trade.Answered, no code change — a client-side
COMFY_ROUTER_DEADLINE. Already covered under Residual below; it configures Router's environment, not the caller's, and a same-named client variable would be a second source of truth that can silently disagree with the server it tracks. Additionally, this repo's env idiom is per-construction (_resolve_router_base_url()atclient.py:250/:414), not import-time, so doing it properly means threading a resolved value through everytimeout=MODEL_RUN_TIMEOUTdefault — a design change, not an override. The supported lever for a longer-deadline deployment is the publicrun(..., timeout=...).Deferred to a tracked follow-up.
Comfy/AsyncComfyexpose nolimits=, so a caller cannot raisemax_connections— the real lever for a fan-out that should not queue at all. New public API on both client surfaces, out of scope for a defect fix; proposed as a follow-up rather than dropped.Review round — collect budget after a pool wait
Fixed — the collect budget now reserves the pool wait. The collect budget's clock starts before a run waits for a pooled connection, but Router's deadline only starts once the request is sent. A run queued for most of the 660s pool bound and then held to the full 600s deadline received its
504 deadline_exceededafter the 1200s budget had closed, so it was raised instead of collected. The defaultRetryPolicy.collect_max_elapsedis now derived as the pool bound plus twoROUTER_DEADLINEwindows (1860s); an explicitly set value keeps its meaning.test_a_deadline_504_after_a_long_pool_wait_still_gets_a_collect_attemptpins the sequence on a fake clock and fails against the old default. README, CHANGELOG and docstrings now say thirty-one minutes.Provenance
ruff check .,ruff format --check .,mypy srcandscripts/check_public_repo_hygiene.py: clean.httpx.Headersnormalization, re-adding theX-Comfy-prefix, or puttingreadback at 600 each makes its test fail.MODEL_RUN_TIMEOUT.poolstays generation-scale rather than shortened, so fan-out past 100 concurrent runs still works; the longer default collect budget covers that wait instead.COMFY_ROUTER_DEADLINEis not read client-side (see Residual), and thelimits=passthrough on the clients was deferred to a follow-up.main's lift pins and this PR's source-table checks, and kept both CHANGELOG entries.Summary by CodeRabbit
Bug Fixes
Idempotent-Replayedresponse header.Documentation
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.