mirror of
https://github.com/Comfy-Org/comfy-typescript-sdk.git
synced 2026-11-04 23:47:27 +00:00
[PR #123] [MERGED] fix: derive undici's inactivity limits from the caller's timeout #125
Labels
No labels
audit:ai-readiness
audit:community
audit:github
audit:health-scoring
audit:policy
audit:release
audit:supply-chain
bug
cursor-review-pin
effort:medium
effort:small
effort:trivial
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-typescript-sdk#125
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-typescript-sdk/pull/123
Author: @mattmillerai
Created: 9/3/2026
Status: ✅ Merged
Merged: 9/4/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-11864-transport-headers-timeout📝 Commits (2)
6cf1957fix: derive undici's inactivity limits from the caller's timeout9c92fb7fix: delegate to a carried dispatcher, and leave an injected fetch alone📊 Changes
7 files changed (+759 additions, -11 deletions)
View changed files
📝
CHANGELOG.md(+16 -0)➕
src/low/dispatcher.test.ts(+343 -0)➕
src/low/dispatcher.ts(+193 -0)📝
src/low/transport.ts(+47 -8)📝
src/sdk/models.test.ts(+42 -0)📝
src/sdk/models.ts(+16 -3)➕
test/support/dispatchers.ts(+102 -0)📄 Description
ELI-5
You can ask this SDK to wait ten minutes for a slow model. It wasn't waiting ten minutes. Node's
fetchhas a second, hidden stopwatch that stops at five, and no amount of asking politely (anAbortSignal) can move it — it lives on the connection pool, not on the request. So anything slower than five minutes died withTypeError: fetch failedand nothing else: no HTTP status, no server request id to quote in a support ticket. This PR sets that hidden stopwatch from the deadline you actually asked for.The defect
AbortSignal.timeout(effective)is not the only clock on afetch. On Node — the only runtime this package supports (engines.node >= 22, nobrowserfield, no browser export condition) —fetchis undici, and undici keeps two timers of its own on the dispatcher, where no signal can reach them:headersTimeout(how long it waits for response headers, default 300 s) andbodyTimeout(the gap it tolerates between body chunks, default 300 s). Anything above 300 s was therefore silently capped at 300 s, and it failed in the worst possible shape for the caller:TypeError: fetch failed,cause.code = UND_ERR_HEADERS_TIMEOUT, no status, no request id — while the generation itself had already been dispatched and billed.Two things sharpen this beyond "callers who ask for a long timeout":
comfy.models.runis made entirely of long requests. The server polls the provider inside the call, so nothing at all arrives on the connection — not even response headers — until the generation finishes. Its own default deadline isDEFAULT_RUN_TIMEOUT_MS = 600_000, so the default path was already capped at half its own default; you did not have to ask for anything unusual to hit this.bodyTimeoutis the second half. Raising onlyheadersTimeoutrelocates the wall onto the response body rather than removing it, so both are set.The fix
src/low/dispatcher.ts(new) derives both limits from the same effective deadline that builds the abort signal —deadline + 30 s, so the signal always wins the race and the caller gets their ownTimeoutErrorrather than an opaque headers timeout — and handsfetcha dispatcher carrying them.null(no deadline) disables both, which is whattimeoutMs: nullalready means for the signal.The dispatcher is a delegate, not an
Agent:setGlobalDispatcher. A library must not change the behaviour of the host application's own unrelated fetches. The delegate is created per request and dies with it.Agentis constructed at all, so there is no connection pool to build, cache or churn, and no cache to bound. The delegate forwards to the dispatcher the process is already using, adding the two limits to the per-request dispatch options (undici reads them there, and they take precedence over the dispatcher's own defaults). A host that installed a proxy agent keeps it, untouched.Applied at both of the package's
fetchcall sites. I sweptsrc/for call sites: there are exactly two —src/low/transport.ts(the choke point every typedlowmethod wraps) andsrc/sdk/models.ts(the routerrun, which does not go throughComfyLow.requestand would have kept the bug had only the transport been fixed). Both are fixed; zero unfixed call sites remain.Verification
Two scaled reproductions, run against the built
dist/, with the ambient dispatcher's default set to 250 ms standing in for undici's 300 s and a 3 000 ms deadline standing in for a 660 s budget (a 1/1200-scale model of the reported failure — a server that withholds headers behaves identically at either scale):That is the reported signature before, and the caller's own deadline after. Those scripts were throwaway and are not committed; what is committed is the equivalent as tests.
Incidental confirmation of the reported shape, from the
models.runreproduction: with the default retry budget (DEFAULT_RETRY_BUDGET_MS = 120_000) the headers timeout is retryable, so it is retried and eventually surfaces as this SDK's ownrequest_timeout. A first attempt that fails at ~300 s is already past that 120 s budget, so no retry is started and the bareTypeError: fetch failedis what reaches the caller — which is exactly what the long-running failures reported.Committed regression tests (
src/low/dispatcher.test.ts, plus two insrc/sdk/models.test.ts), none of which wait five real minutes:timeoutMsaborts on schedule, and the failure is the caller'sTimeoutError— asserted to not beUND_ERR_HEADERS_TIMEOUT.UND_ERR_HEADERS_TIMEOUTback. If a future Node stops accepting this delegate, or stops honouring per-request limits, that test reddens instead of the 300 s cap quietly returning.ComfyLow.request:120 000and400 000/660 000ms, plus the client default andnull. The rows above 300 s assert the limit the request is actually handed (> 300 000 and > the deadline) rather than spending 300 s of wall clock proving it fires.NaN/Infinity, and a deadline past the timer ceiling (where a clamped limit would sit below the deadline — the defect again — so no limit is imposed instead).Gates:
pnpm typecheck,pnpm lint(oxlint),pnpm format:check(oxfmt),pnpm check:spec-drift,node scripts/check-public-repo-hygiene.mjsandpnpm buildall clean;pnpm test430 passed / 0 failed, run on Node 22, 24 and 26 (CI covers 22 and 24).Judgment calls
Symbol.for("undici.globalDispatcher.1")is the riskiest line here. It is undocumented Node internals. It is also the key undici's owngetGlobalDispatcher()reads — the contract that lets a separately-installed undici share Node's built-in one — and it resolves on Node 22, 24 and 26. It is duck-typed on.dispatchbefore use, its absence degrades to today's behaviour rather than throwing, and CI asserts on every run that it resolves and that the limits it carries are the ones undici enforces. The alternatives were worse:setGlobalDispatcheris forbidden for a library, and constructing anAgentmeans addingundicias a runtime dependency, duplicating the copy Node already ships, discarding the host's proxy configuration, and pooling connections per distinct timeout. It also does not work: anAgentfrom the npm package is rejected by Node's built-infetch(InvalidArgumentError: invalid onRequestStart method— the two copies speak different handler protocols), which I confirmed before choosing this design.timeoutMs: nullnow disables undici's inactivity timers entirely. This is a deliberate behaviour change beyond the reported defect and it is the one thing worth a second opinion.nullis documented as "disables the default timeout entirely" and is what the SSE stream (getJobEvents) and asset downloads pass, so this makes the code match the contract — a stream must not die on its own while idle mid-job, and today one silently could at 300 s. The flip side: atimeoutMs: nullcaller on a connection that goes silent forever now hangs until their ownsignalfires, where undici's 300 s default used to end it. Raising it to some other constant instead would just be a new wall, which the whole change is about not doing.fetch, because a wrapper around the platformfetchshould get the fix rather than silently lose it, and an implementation that does not understand the key ignores it. The one case that breaks is afetchfrom a separately installed undici, which cannot be handed a delegate for Node's copy; that caller wantssetGlobalDispatcheron their own undici, which this delegates to untouched. Documented at the call site.Residual
Not fixed here, and not derivable from this diff:
beeble/switchx,luma/ray-flash-2,kling/kling-v1andrunway/aleph2, each failing at 300.75–300.78 s while the Python leg passed — are unexercised artifacts for this PR. They live in a separate end-to-end harness repo, need live Router credentials and would spend real generation budget to run, so exercising them here would be both out of reach and a costly mutation; the scaled reproduction above stands in for them. They are expected to go green on their own once that harness's pinned SDK commit moves past this change. Nothing in the harness needs editing — it already passes the correct 660 s budget through totimeoutMs. Someone has to move that pin, and it is not moved by this PR.beeble/switchx(and, in the same finding,luma/ray-flash-2) to an environment cause — a staging rollout cutting in-flight calls. That attribution is wrong for these two and is superseded by this diagnosis: four independent network events do not land inside a 25 ms band, and the clean re-run ofswitchxpassed because a single uncontended case finished under 300 s, not because a rollout was the cause. The rollout explanation still holds for the two failures it was originally drawn from (at 377.0 s and 257.1 s), neither of which sits at the wall. That report's stored attribution is in another repo and is not corrected by this PR.kling/kling-v1is untouched: an earlier run failed that same case at 106.6 s with an HTTP 409concurrency_limit_exceededcarrying a request id, which this SDK treats as terminal rather than retrying the same-key "already in progress" answer. A 409 with a request id is the service answering; a headers timeout is the connection dying before it answers anything. Same case, two defects — they should not be merged.Provenance
pnpm test430 passed / 0 failed (Node 22, 24 and 26);pnpm typecheck,pnpm lint,pnpm format:check,pnpm check:spec-drift,node scripts/check-public-repo-hygiene.mjs,pnpm buildall clean; two scaled pre/post reproductions against the builtdist/(output quoted above).pnpm check:sdk-paritynot run — it is the one gate that reaches the network and this change touches no public surface.Agentcached per distinct timeout is met by constructing noAgentat all, and the above-300 s half of the table-driven regression test asserts the limit the request is handed rather than spending 300 s of wall clock — the criterion's own "without waiting five real minutes" constraint.Summary by CodeRabbit
New Features
timeoutMs: nulldisables both the request deadline and inactivity limits.Bug Fixes
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.