Skip to content

fix(models,transport): read the Idempotent-Replayed header and lift the run timeout above Router's deadline - #168

Open
mattmillerai wants to merge 3 commits into
mainfrom
matt/be-15641-replayed-header-and-run-timeout
Open

mattmillerai wants to merge 3 commits into
mainfrom
matt/be-15641-replayed-header-and-run-timeout

Conversation

@mattmillerai

@mattmillerai mattmillerai commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

ELI-5

Two small money bugs in the model-run path. First, the SDK asked the response for a header spelled X-Comfy-Idempotent-Replayed, but the server actually sends Idempotent-Replayed (no X-Comfy- prefix) — so RouterRunResult.replayed came back False on every response, including a genuine replay, which is exactly the "you already paid for this, don't charge again" signal. Second, the client's model-run timeout was 600s and Router's own deadline is also 600s, so the client could hang up at the same instant the server was sending its 504 — losing the request id and Retry-After a caller needs to reason about a generation that is billed and still running. This fixes the header name and lifts the client timeout to sit one minute above Router's deadline.

What changed

1. replayed reads the header the contract declares. RouterRunResult._run_result now reads Idempotent-Replayed instead of X-Comfy-Idempotent-Replayed. The vendored contract confirms the name: spec/router-openapi.yaml defines the RouterIdempotentReplayedHeader component and references it as Idempotent-Replayed on every replayable response (the 200, and the recorded 4xx/5xx shapes). The field's own docstring carried the same wrong prefix and is corrected too. Headers arrive as httpx.Headers (case-insensitive), so the defect was purely the wrong name, not casing.

  • Tests: the existing unit test is re-pointed at the contract name (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 actual Idempotent-Replayed: true header, and asserts run_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_TIMEOUT is no longer a bare 600.0. A named ROUTER_DEADLINE = 600.0 constant mirrors Router's server-side DefaultDeadline, and the client read timeout is derived as ROUTER_DEADLINE + 60s of headroom — enough to receive the 504 and its headers instead of racing it. The coupling is documented at the constant ("if you raise Router's deadline, raise this too"). connect stays 10s.

  • The retry test test_the_collect_budget_outlasts_a_server_deadline_window previously used MODEL_RUN_TIMEOUT.read as 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 from ROUTER_DEADLINE and additionally asserts the new invariant MODEL_RUN_TIMEOUT.read > ROUTER_DEADLINE. The other timeout tests (>= 120s read, <= 30s connect; the literal 600.0 server-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: grep across src/ found exactly three occurrences of the replayed-header spelling — the code read (fixed), the field docstring (fixed), and one prose reference at models.py:436 that was already correct — and no other reader of the header anywhere in src/. All MODEL_RUN_TIMEOUT consumers were checked; none assume the literal 600 except the one retry test corrected here.

Residual

  • Coordination with the credits-header work (separate, in-review PR). A separate in-review change surfaces X-Comfy-Credits-Used on RouterRunResult and edits this same header-reading block in _run_result. It has not landed on main (verified: no Credits-Used / credits_used in src/ at this base), so there is no conflict today, but whichever lands second will need a trivial rebase of that block. That change's author should also confirm the credits header name against spec/router-openapi.yaml directly, given this defect was a wrong prefix on a sibling header — I could not verify the credits header name here because that code is not in this branch.
  • Cross-repo artifacts not exercised (no access from this environment). The originating report cites two files that live in the Router service repo, not here: router-reference.mdx (the header docs) and routerdeadline/routerdeadline.go (DefaultDeadline = 10 * time.Minute). I could not open either. I verified the header name against this repo's vendored spec/router-openapi.yaml (synced from that service) instead, and encoded the 10-minute deadline as ROUTER_DEADLINE = 600.0 with a comment pointing at the Go constant; if Router's deadline is ever raised, this default must be raised to match.
  • Deliberately did not read COMFY_ROUTER_DEADLINE in 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-documented ROUTER_DEADLINE constant 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_result reads headers case-insensitively. It is annotated Mapping[str, str] and all four names it reads are mixed-case, so it worked only because post_model_run happens to hand it an httpx.Headers. Any plain mapping — dict(resp.headers), which httpx lowercases — would have missed all four and yielded replayed=False and request_id=None without raising, which is the same silent shape as the wrong header name this PR fixes. It now normalizes through httpx.Headers(headers) first.

Fixed — the header names are pinned to the contract. The four names are now constants (_RUN_RESULT_HEADERS) and tests/test_router_spec_contract.py::test_the_headers_a_run_result_reads_are_the_spec_s pins each against spec/router-openapi.yaml's runRouterModel 200, parametrized per name, reached by searching for the operationId rather than looking up the path. The assertion is one-way (everything read is declared) because the 200 also carries headers RouterRunResult does not surface. Plus test_the_replayed_header_carries_no_x_comfy_prefix as 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 504 arrives at "the same ten minutes as MODEL_RUN_TIMEOUT". retry.py's module docstring and collect_max_elapsed doc, models.py's run docstring, and README.md now point at ROUTER_DEADLINE — the constant that is actually ten minutes and the one collect_max_elapsed's "two windows" derivation depends on.

Partial — the positional httpx.Timeout argument. Correct that one positional value set write and pool as well as read. write is 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). pool is 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 to ROUTER_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 of PoolTimeout — 150 concurrent 2-minute runs succeed today and would fail inside ~70s under a 10s bound plus the 60s max_elapsed. The accepted cost is written on the constant: in the pathological case where the pool never frees, the caller waits eleven minutes and PoolTimeout surfaces without a retry, its fast-class budget long spent. All four bounds are now named explicitly so none is inherited again, and test_every_bound_of_the_run_timeout_is_chosen_rather_than_inherited asserts 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() at client.py:250/:414), not import-time, so doing it properly means threading a resolved value through every timeout=MODEL_RUN_TIMEOUT default — a design change, not an override. The supported lever for a longer-deadline deployment is the public run(..., timeout=...).

Deferred to a tracked follow-up. Comfy/AsyncComfy expose no limits=, so a caller cannot raise max_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.

Provenance

  • Authored by: agent-work loop
  • Verified: uv run pytest: 907 passed, 9 skipped; ruff check .: clean; ruff format --check .: 55 files already formatted; mypy src: no issues in 20 files; scripts/check_drift.py: in sync; scripts/check_public_repo_hygiene.py: no internal-only references. Falsified the two new guards by reverting each fix — removing the httpx.Headers normalization fails test_run_detailed_reads_the_headers_case_insensitively, and reinstating the X-Comfy- prefix on the header constant fails both spec-contract cases. The original replayed tests were falsified the same way in the first round.
  • Deviations: no acceptance criteria skipped. Two judgment calls, both argued on their threads and summarized under Review round: MODEL_RUN_TIMEOUT.pool is kept generation-scale rather than shortened as the panel suggested, because shortening it breaks fan-out past 100 concurrent runs; and COMFY_ROUTER_DEADLINE is still not read client-side, per the Residual note below. One finding was deferred to a follow-up (limits= passthrough on the clients) rather than fixed here.

…declares

RouterRunResult.replayed read X-Comfy-Idempotent-Replayed, but Router
sends Idempotent-Replayed (no X-Comfy- prefix), as spec/router-openapi.yaml
declares on every replayable response. The names could never match, so
replayed was False on every response including a genuine replay — the exact
signal a caller uses to avoid double-charging, wrong 100% of the time and
silently, since False is also correct for the common case.

Fix the header name (and the field docstring, which carried the same wrong
prefix). Add an end-to-end test through the fake server, which sends the real
Idempotent-Replayed header, and re-point the unit test at the contract name
rather than a literal copied from the implementation.
MODEL_RUN_TIMEOUT was 600s, exactly Router's DefaultDeadline (600s). At
equality the client can abort at the same instant Router is writing its
504 deadline_exceeded, so the caller gets a bare client-side timeout with
no X-Comfy-Request-Id, no Retry-After and no error body — the generation
billed and still running, the id needed to reason about it gone.

Derive the timeout from a named ROUTER_DEADLINE constant plus a minute of
headroom, and document the coupling at the constant so the next person
changing either number sees it.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for the next 21 days.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for the next 21 days. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 2 minutes for your next included review.

Check out review usage here.

Limit details: You’ve used the included review currently available. Your 138 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: Comfy-Org/comfy-python-sdk/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Team

Run ID: ca96d5ef-b942-469e-87d6-e1addb02da79

📥 Commits

Reviewing files that changed from the base of the PR and between 00b1f1d and f6865ac.

📒 Files selected for processing (7)
  • README.md
  • src/comfy_low/transport.py
  • src/comfy_sdk/models.py
  • src/comfy_sdk/retry.py
  • tests/test_models_run.py
  • tests/test_models_run_retry.py
  • tests/test_router_spec_contract.py

Comment @coderabbitai help to get the list of available commands.

@mattmillerai
mattmillerai marked this pull request as ready for review September 18, 2026 23:03
@mattmillerai
mattmillerai requested review from a team as code owners September 18, 2026 23:03
@mattmillerai mattmillerai added agent-coded Authored by the agent-work loop cursor-review Request an automated Cursor review labels Sep 18, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 5 finding(s).

Severity Count
🟡 Medium 1
🟢 Low 3
⚪ Nit 1

Panel: 6/6 reviewers contributed findings.

Comment thread src/comfy_low/transport.py Outdated
Comment thread src/comfy_low/transport.py Outdated
Comment thread src/comfy_low/transport.py
Comment thread src/comfy_sdk/models.py Outdated
Comment thread src/comfy_sdk/models.py Outdated
@mattmillerai mattmillerai added the full-autonomy Approved AI-brownfield: merges on machine gates alone, no human approver. Design doc + flag req'd. label Sep 18, 2026
robinjhuang
robinjhuang previously approved these changes Sep 19, 2026

@robinjhuang robinjhuang left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-approved under the full-autonomy policy.

Gates verified at 5ec2ad10b41b32869893cab6ad5c51aaf1316608:

  • full-autonomy label present
  • assigned to, or review requested from, @robinjhuang
  • not a draft
  • 8 required check(s) green — none failing, none pending

Issued by full-autonomy-approve.yml (run). This approval attests
that the machine gates above passed at this commit. It does not attest that a
human read the diff.

…ed names, sane write bound

Addresses the cursor-review panel on #168.

- `_run_result` normalizes through `httpx.Headers` before every lookup. It is
  annotated `Mapping[str, str]` and all four names it reads are mixed-case, so
  a plain mapping (`dict(resp.headers)`, which httpx lowercases) missed all of
  them silently -- `replayed=False`, `request_id=None`, no error. That is the
  same shape of silent miss the wrong header name in this PR produced.

- The four header names are named constants pinned against
  `spec/router-openapi.yaml` by a new parametrized test in
  test_router_spec_contract.py, one-way (everything read is declared; the 200
  also carries headers the SDK does not surface). A wrong name cannot fail at
  runtime, so only a comparison against the contract catches it -- this is the
  check that was missing when `Idempotent-Replayed` was read prefixed.

- `MODEL_RUN_TIMEOUT` names all four bounds explicitly. A single positional
  argument set `write` and `pool` to the generation wait as well; `write` bounds
  pushing the request body up and has no business getting eleven minutes, so it
  is now 120s -- still generous for a multi-megabyte inline base64 image. `pool`
  stays generation-scale deliberately (every pooled connection is held for a
  whole generation and `Comfy` exposes no `limits=`, so shortening it would turn
  a legitimate fan-out past httpx's 100 connections into a wall of PoolTimeout);
  it is now stated and asserted as a decision rather than inherited.

- Docs that asserted the old `MODEL_RUN_TIMEOUT == Router's deadline` equality
  now point at `ROUTER_DEADLINE`, which is the constant that is actually ten
  minutes and the one `collect_max_elapsed`'s "two windows" derivation depends
  on: retry.py's module docstring and `collect_max_elapsed`, models.py's `run`
  docstring, and README.md.

Both new guards were falsified by reverting their fix and confirming they fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mattmillerai

Copy link
Copy Markdown
Contributor Author

🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:

  • BE-15947 — Expose httpx connection-pool limits on Comfy/AsyncComfy so a model-run fan-out can raise max_connections — filed as agent-spike (premise unverified)

The following carry agent-spike instead of agent-ok because their reachability claim was not backed by evidence (BE-5378) — the claim is investigated before any code is written, and "the premise does not hold" is a valid, successful outcome:

  • Expose httpx connection-pool limits on Comfy/AsyncComfy so a model-run fan-out can raise max_connections — no reachability block in the proposal

@robinjhuang robinjhuang left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-approved under the full-autonomy policy.

Gates verified at f6865acedfd8edadba322979274b40ed710abb92:

  • full-autonomy label present
  • assigned to, or review requested from, @robinjhuang
  • not a draft
  • 8 required check(s) green — none failing, none pending

Issued by full-autonomy-approve.yml (run). This approval attests
that the machine gates above passed at this commit. It does not attest that a
human read the diff.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-coded Authored by the agent-work loop cursor-review Request an automated Cursor review full-autonomy Approved AI-brownfield: merges on machine gates alone, no human approver. Design doc + flag req'd.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants