mirror of
https://github.com/Comfy-Org/comfy-typescript-sdk.git
synced 2026-11-04 23:47:27 +00:00
[GH-ISSUE #62] Error translation, SSE error handling, and 429 retry: three verified defects plus the test gaps that let them through (cross-SDK parity audit) #7
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#7
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?
Originally created by @christian-byrne on GitHub (Aug 21, 2026).
Original GitHub issue: https://github.com/Comfy-Org/comfy-typescript-sdk/issues/62
Originally assigned to: @wei-hai on GitHub.
Cross-SDK parity audit against
comfy-python-sdk(whose equivalent audit is Comfy-Org/comfy-python-sdk#62). Two of the Python defects have a sibling here, one does not, and the audit turned up a third defect that is TS-specific in impact. Everything below is verified at runtime against this repo's owntest/support/stub-server.ts, each with a positive control on a code path that behaves correctly.Ref audited:
54c0b40(origin/main).D1 —
outputs.tsnever callstranslate(), so all three public download methods leak the rawlow.ApiErrorsrc/sdk/outputs.tsis the only file undersrc/sdk/that toucheslowwithout importingtranslatefrom./exceptions.js.assets.tsandjobs.tsboth wrap (including both factoryget()s — the spot the Python SDK missed). Outputs does not:ComfyLow.getAssetContentandgetAssetContentUrlboth raise viaerrorFromEnvelope(transport.ts:262parseOrRaise, reached fromgetAssetContentand fromgetAssetContentUrl:416), so those errors reach the caller untranslated.Repro — the stub already 404s
GET /assets/{id}/contentfor a deleted asset (test/support/stub-server.ts:299-303), so no new fixture is needed:Why this is worse than it looks:
src/low/errors.tsandsrc/sdk/exceptions.tsboth export a class namedNotFound, and they are unrelated.err.nameanderr.constructor.nameare"NotFound"in both rows above, so a consumer debugging this sees the class name they expected and concludes their owninstanceofis broken.err instanceof ComfyErrorisfalse, which contradicts the README's "All extend a sharedComfyError(code,httpStatus,details)" — the documented escape hatch for catching everything doesn't catch the download path either.Output.getDownloadUrl's JSDoc says "Works on every backend and never throws." It does throw. The correspondinglowJSDoc onComfyLow.getAssetContentUrlwas already corrected to "...never throws for either shape — only a genuine failure maps to the usual typed error", andtransport.test.tshas a test literally named "getAssetContentUrl never throws for a real 404 — no, it maps to the usual typed error". Thelowlayer knows; thesdkJSDoc above it was not updated.Downloading an output is the most-used consumer call after
run().Fix: import
translateinoutputs.tsand wrap the threethis.low.*calls; correct thegetDownloadUrlJSDoc.D2 —
Job.events()swallows everyApiError, turning a contract-legal 501 into a 100 ms reconnect loopsrc/sdk/jobs.ts:174-179:That catch is meant for transport drops, but it also catches every
ApiErrorgetJobEventsraises.spec/openapi.yaml:587documents501 not_implementedonGET /api/v2/jobs/{id}/events— "this deployment does not yet serve live event streaming. GET /api/v2/jobs/{id} remains available as a plain poll in the meantime" — and429 too_many_streamswith aRetry-Afterfor the concurrent-stream limit. There is no reference to501anywhere insrc/ortest/. The Python SDK handles it explicitly in both its sync and asyncevents()(comfy_sdk/jobs.py:166,:278):if exc.http_status == 501: return.Repro, on a job that takes just 2 seconds:
10 SSE connection attempts + 10 polls per second, sustained, for the whole life of the job. A realistic 5-minute generation is ~3000 SSE attempts and ~3000 polls where one attempt would do. On the 429 path this is self-inflicted: the server is publishing a
Retry-Afterto shed the exact load the client then reconnects into at a fixed 100 ms.Note the outcome is semantically fine —
events()does yield a correct terminalstatusChangeoff the poll backstop, which is arguably better than Python's barereturn. This is purely a request-amplification bug, and it targets the self-hostedcomfy-api-proxysurface the README advertises, which is precisely the deployment most likely to answer 501.Fix: in the catch, re-check
exc instanceof ApiError;returnon 501, and on a 429 honourretryAfterinstead ofRECONNECT_PAUSE_MS. Everything else keeps the existing reconnect behaviour.D3 —
submit()retries onlyqueue_full, but the spec requires backing off on any 429 +Retry-Afterspec/openapi.yaml:994-1018(ErrorEnvelope) states:client.ts:194gates the retry onerr instanceof QueueFull, i.e. oncode === "queue_full"alone.deployment_not_readyis not inlow/errors.ts'sBY_CODE, so it falls through to a bareApiError→ bareComfyError, and theretryAfterthe transport already parsed is discarded.Repro (real
Comfyclient, injectedfetch, 429 +Retry-After: 0four times then success):A cold serverless deployment answering
429 deployment_not_readyfails the submit outright instead of waiting out the cold start — on the serverless surface the README explicitly targets.comfy-python-sdkhas the identical gap (comfy_sdk/client.py:170,:253), so this is a shared contract gap rather than a TS regression; worth fixing in lockstep.Fix: retry on
httpStatus === 429 && retryAfter != nullrather than on theQueueFullclass, or add the deployment-scoped codes toBY_CODEand widen the predicate.Test gaps
These are what let D1–D3 through. Assertion density was counted per file rather than per test block; no file in the suite asserts nothing, and the suite is strong overall (see "clean" below).
Outputmethod.outputs.test.tshas 7 tests / 19 assertions, all happy-path. The stub already has the 404 branch, so this is two lines and it fails today:GET /jobs/{id}/eventsreturns a non-200.StubServer.state.sseModeis"normal" | "reconnect"— there is no way to make the events endpoint answer 501/429/401 at all. The reconnect tests are genuinely good (monotonic progress, no-replay, poll backstop, abort-during-pause); the whole error dimension is simply unreachable from the fixture. Add aneventsStatusknob.src/sdk/hashing.tshas no direct test, andassets.test.ts:36-37seeds the stub from the SDK's own output (server.state.knownHashes.add(await asset.hash())). There is not one 64-hex digest literal anywhere in the suite (grep -roE '[0-9a-f]{64}' --include='*.test.ts'→ 0; control: the same regex at 40 chars finds all 8 pinned action SHAs inci.yml). AhashBytesthat returned a wrong-but-consistent value — ahash-wasmAPI change, a wrong prefix, hex vs base64 — passes every test in this repo while making the server rejectexpected_hashon every upload and never hitting the dedup fast path. Also nothing assertshashFile(p) === hashBytes(readFileSync(p)), so the streaming path could disagree with the buffered one undetected. Add one known blake3 vector.BY_CODEtables.low/errors.tsandsdk/exceptions.tseach hand-maintain a code→class map, and each test file hand-repeats the list. The tables are currently consistent (low \ sdk = {queue_full}, handled specially intoSdkError;sdk \ low = {}), so this is a guard gap, not a live bug — but adding a code tolowalone silently degrades it to a bareComfyError, which is exactly how D3 reads today.spec-coverage.test.tsalready does this beautifully for operationIds; copy the pattern.vitest.config.tshas a well-consideredcoverageblock (notablyinclude: ["src/**/*.ts"]so an untested module shows as 0% instead of vanishing) but nothresholds, and CI runspnpm test, notpnpm test:coverage, so nothing reads the numbers.Zero-direct-coverage modules judged not worth filing (adequately exercised end-to-end and pure):
core.ts'snewIdempotencyKey/looksLikeUiFormat,Job.refresh(covered viawait),ComfyLow.request(covered via every typed method).Checked and clean — please don't re-audit these
scripts/check-spec-drift.mjsregenerates into a tempdir and compares the three generated files by exact content, catching both a spec edit without a regen and a hand-edit of generated output. It runs as a step in thetestjob, andTest (node 22)/Test (node 24)are required status checks onmain— verified at the API, on classic branch protection, not inferred from the workflow. The repository's only ruleset is "CLA Check" (cla-assistantonly), so checking rulesets alone would have produced a false "unguarded" finding.@hey-api/openapi-tsis pinned exactly (0.99.0), matching the Python SDK's rationale that a byte-exact comparison needs a pinned generator. As in the Python SDK, the one unguarded link is upstream:spec/openapi.yamlis a one-way vendored copy with a hand-maintainedspec/VERSION, so canonical-vs-vendored drift is not detectable from inside this repo.Outputis identical to Python'sAsyncOutput. Python's missing-to_streamdefect is a sync-only method thatAsyncOutputnever had; because this SDK collapses to one async surface there is nothing to drift out of parity with.AssetFactory/JobFactoryidentical;Comfy's only difference is Python'saclose, which is N/A for afetch-based client with no pooled client to close;fromStringvs Python'sfrom_stris a deliberate idiomatic rename. The only real surface nit:Asset.adopt()ispublicand ships in the emitted.d.tseven though its own JSDoc says "Not part of the public surface" — Python's equivalent has no public counterpart at all. Worth aprivate/@internal, but not a defect.Authorizationheader at all; key →Bearer <key>observed server-side. Cross-origin non-leak is covered for server-suppliedurls.self/events/canceland for a content-download 302, plus CRLF header injection viaclientInfoand aSDK_VERSION-vs-package.jsondrift guard. Stronger than most SDKs.queue_full→Retry-After→ retry → success is covered end to end, as isRetry-Afterin HTTP-date form falling back tonullrather thanNaN. (The gap is which codes trigger it — D3 — not the mechanism.)assets,jobstags andRange) and nopage/cursor/limit/offsetanywhere.data:, comment/keepalive, name-without-data, mid-UTF-8 chunk splits, non-object and non-JSON payloads, defaultmessagetype, and a stalled-"zombie"-stream timeout.extra_datapresent/absent/empty-object all asserted on the wire.node:httpstub server. Novi.mock, no spies on the code under test./cc @wei-hai —
git blameputsoutputs.ts's three download methods, theevents()catch, and theQueueFullretry predicate all on you (dca70f88,01ac0c79,#16), and you're the one who addedtranslate()correctly toassets.tsandjobs.ts, so the pattern is clearly known and outputs was just missed.@christian-byrne commented on GitHub (Aug 21, 2026):
Follow-up sweep on the same ref (
54c0b40), different lens: which merges never got a human read, and what came in through them. Two more verified defects, both in the same files this issue already covers, both the same shape as D1 — a guard that exists at one call site and not at its sibling. Adding them here rather than opening new issues.Runtime-verified the same way as D1–D3: real source under
node --experimental-strip-types, stubs only for the three modules with uninstalled deps (hash-wasm,zod,eventsource-parser), every claim with control arms that are known-non-zero.D4 —
Output.toFile/toBytesinherit a 30-second whole-transfer deadline, and no caller can raise or disable itComfyLow.getJobEventsdeliberately disables the client's default timeout for a long-lived stream:with the JSDoc right above it at
:477-479: "No default timeout: an idle stream must not time out mid-job."The other long-lived streaming path in the same class has no such guard.
getAssetContent(transport.ts:363-378) takes{ range?, signal? }and notimeoutMsat all, sorequest()appliesDEFAULT_TIMEOUT_MS = 30_000(transport.ts:41) throughresolveSignal(:230-239).AbortSignal.timeout(30_000)is a wall-clock deadline covering the entire request including body consumption — it is not an idle timeout.The sdk layer forwards nothing.
outputs.ts:76toFile(path, { range? })and:90toBytes({ range? })accept nosignaland notimeoutMs. And the client-level knob cannot rescue it:ComfyOptions.timeoutMsis typednumber(client.ts:94),ComfyLowOptions.timeoutMslikewise (transport.ts:61), andthis.defaultTimeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS. A TypeScript caller can raise the global deadline but never disable it, and cannot scope the change to downloads. The only route totimeoutMs: nullis the untypedComfyLow.requestescape hatch.Repro — real
ComfyLow+ realOutputagainst anode:httpserver streaming 6 chunks 250 ms apart (~1.5 s total). ClienttimeoutMs: 400stands to that 1.5 s stream as the shipped 30 s default stands to a >30 s download.Both controls are known-non-zero successes — A proves the slow body is readable once the timeout is off, B proves
toFileworks at all — so C and D are attributable to the deadline, not the harness.Impact. Any output download exceeding 30 s wall-clock aborts mid-stream. Because it is
outputs.ts, D1 applies on top: theTimeoutErrorarrives untranslated, not as aComfyError. This contradicts the JSDoc atoutputs.ts:71— "Bytes are piped straight to disk, so an output larger than memory is fine." An output larger than memory is precisely the one that takes longer than 30 seconds. On Cloud the request 302s to signed object storage and undici follows it, so the 30 s covers the redirect chain plus the whole body.Coverage.
timeoutMsappears 0 times inoutputs.test.tsandtransport.test.ts. Control: the same search across allsrc/**/*.test.tsreturns 2 hits, both inclient.test.ts— so the zero is a real absence, not a broken query.Suggested shape, matching what
getJobEventsalready does: givegetAssetContentatimeoutMsparameter, default it tonull(or to an idle-based deadline), and lettoFile/toBytesacceptsignalandtimeoutMs. Worth notinggetJobEventsalso acceptssignaland theOutputmethods do not, so a caller currently cannot cancel a large download at all.D5 —
AssetFactory.fromUrlbypasses the injectedfetchand has no timeout or abortThis is the only raw
fetch(in the whole hand-written source — searchingsrc/sdkandsrc/lowwith tests excluded returns exactly this one line, against a control search forfetchImplthat returns three known-non-zero hits attransport.ts:162,169,254.Three consequences, none of which apply to any other network call in the SDK:
new Comfy({ fetch })is silently ignored here.ComfyOptions.fetch(client.ts:95) is a documented public option and is threaded intoComfyLowatclient.ts:136, butAssetFactoryreceives onlylowandfetchImplisprivate. A caller injecting a proxying, instrumenting or offlinefetchgets unexpected direct egress from this one method.DEFAULT_TIMEOUT_MS = 30_000; this one gets none.signal, unlike its siblingsAsset.commit(signal),Asset.delete(signal)andAssetFactory.delete(id, signal).Repro — real
Comfyclient,{ fetch: countingFetch, timeoutMs: 300 }, two local origins:ARM D was still hanging when the 2 s race timer cut it off — it has no deadline to hit.
The earlier audit checked
fromUrlthrough a coverage lens and correctly found it tested (rebutting the Python SDK's G3). The option-plumbing and deadline gap is orthogonal to that.Provenance, and why nobody caught either one
Both defects trace by
git blameto01ac0c79("Build the full two-layer Comfy API v2 SDK"), which merged as PR #3 — 6122 additions across 45 files with zero reviews of any kind. ThegetJobEventsguard in D4 arrived later still, in PR #15, also with zero reviews. The timeout lesson was learned on the SSE path and never carried across to the download path, and no reviewer was ever in a position to notice the asymmetry.That is the general pattern rather than a one-off: of 34 merged PRs in this repo, 7 carried a standing human approving review at merge. One (#27) had a real human approval that a push dismissed 33 seconds before it merged. Three (#39, #43, #51) merged on a CodeRabbit approval alone, which GitHub counts toward
required_approving_review_countwith no bot filter — including #43, a declared breaking change to the public constructor. The remaining 23 had no approving review at all. By volume that is 8682 of 9052 added lines (95.9%) merged with no standing human approval.Worth being precise about the mechanism, because the obvious reading is wrong: branch protection here is correctly configured.
/branches/main/protectionrequires 1 approval, code-owner review, stale-review dismissal and 4 status checks. (/rulesetscarries only "CLA Check" — reading that alone would give a false "wide open" answer.) The gate is real; it simply hasenforce_admins: false, and the author of ~92% of the code is an admin. Nothing to fix in the diff — flagging it as the reason two guard-sibling gaps sat unnoticed, and as an org-level posture question rather than a repo one.Two other things checked under this lens and cleared, recorded so nobody re-derives them:
getAssetContent— looked like a textbook instance (its siblinggetAssetContentUrlusesredirect: "manual"explicitly so the token is never attached to object storage, whilegetAssetContentfollows redirects). It does not leak: undici implements the WHATWG rule strippingAuthorizationacross an origin change, verified with both same-origin control arms showing the header present and the cross-origin hop showing it absent.jobBasePath()attransport.ts:100-103), and #44'sembedWorkflownever landed at all (0 hits forembedWorkflow/extra_pnginfoonmain, against a control of 7 files hittingextra_data).