mirror of
https://github.com/Comfy-Org/comfy-typescript-sdk.git
synced 2026-11-04 23:47:27 +00:00
[PR #196] fix(models): raise the typed error when a non-2xx body read fails and no retry remains #198
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#198
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/196
Author: @mattmillerai
Created: 9/29/2026
Status: 🔄 Open
Base:
main← Head:mattmiller/be-17634-typed-error-on-unread-body📝 Commits (8)
df14fa2fix(models): raise the typed error when a non-2xx body read fails and no retry remains268799dfix(models): own-property bucket lookup, and a non-enumerable queue causec0f95bdfix(errors): own-property code lookup in the low layer, and an honest message for a cut-off queue 42253aec07fix(errors): own-property lookup in toSdkError, a string-only envelope code, and a typed error for a cut-off discovery body830cb26fix(errors): string-only envelope message, retryAfter on every sdk error, and a non-throwing validation summary6ec46e7Merge remote-tracking branch 'origin/main' into mattmiller/be-17634-typed-error-on-unread-bodyf9b5a51Merge remote-tracking branch 'origin/main' into mattmiller/be-17634-typed-error-on-unread-bodyc7413f2Merge remote-tracking branch 'origin/main' into mattmiller/be-17634-typed-error-on-unread-body📊 Changes
12 files changed (+444 additions, -14 deletions)
View changed files
📝
CHANGELOG.md(+23 -0)📝
src/low/errors.test.ts(+32 -0)📝
src/low/errors.ts(+12 -3)📝
src/sdk/exceptions.test.ts(+20 -0)📝
src/sdk/exceptions.ts(+4 -1)📝
src/sdk/modelRequests.test.ts(+62 -0)📝
src/sdk/modelRequests.ts(+36 -1)📝
src/sdk/models-discovery.test.ts(+28 -0)📝
src/sdk/models.test.ts(+130 -0)📝
src/sdk/models.ts(+55 -5)📝
src/sdk/routerErrors.ts(+7 -2)📝
test/support/router-stub-server.ts(+35 -2)📄 Description
ELI-5
When Router answers with an error (say a 422 or a 402) and the connection drops while the SDK is still reading the error's body, the SDK used to give up with a bare
TypeError: terminated— even though the status line and headers had already told it exactly what went wrong. Now, once retries are used up, it throws the proper typed error (InsufficientCredits,InvalidInput, …) built from that status and those headers, and keeps the network failure ascause.What changed
src/sdk/models.ts(models.run) — the retry loop remembers a non-2xx response whosereadBodyWithincall failed (unreadError). Only that read is wrapped;fetchand the classification are not. In the outer catch the order stays as it was (isTooLarge→isTimeout→ caller abort →nextDelayMs()). Only whennextDelayMs()returnsnullandunreadErroris set does it throwerrorFromResponse(unreadError, "", idempotencyKey, exc). Because that check comes after the three early exits, a cap breach, a deadline or a caller abort is never re-dressed.errorFromResponsegets an optional 4thcauseparameter, passed into the options only when it is provided, so existing call sites are unchanged. It now looks up the bucket class withObject.hasOwn, liketoRouterErroralready did, because on this path the header is the only input and aconstructor/toStringheader must not resolve offObject.prototype.src/sdk/modelRequests.ts(send()) — the same pattern aroundresponse.text():toRouterError(status, headers, null), withcauseset throughObject.definePropertyas a non-enumerable property. That is the shape ES2022'snew Error(msg, { cause })gives themodels.runtwin.toRouterError's signature and the publicRouterErrorOptionsare unchanged.test/support/router-stub-server.ts— a newcutBodyTimesknob. It sends the ordinary status and headers with noContent-Length, writes{"detail":[, then destroys the socket. This sits betweenresetTimes(no status line at all) andstallBody(never drops).ScriptedResponsealso gets acutBodyflag, so one scripted route can drop its body, and theresponddoc now listscutBodyTimesamong the checks that run before it.src/low/errors.ts(errorFromEnvelope) — review round 2. The Comfy API v2 code-to-class lookup now usesObject.hasOwn(BY_CODE, code)too. Before this, an envelopecodeofconstructorortoStringresolved offObject.prototype.send()passestoRouterErrora stringdetailofHTTP <status>. A 422 whose body was never read now readsHTTP 422, like themodels.runtwin, rather thanInvalidInput's summary of an emptydetail[]. TheInvalidInput.detaildoc insrc/sdk/routerErrors.tsnow covers the unread-body case, wherecauseis set.53aec07).src/sdk/exceptions.ts(toSdkError): thesdk-layer code-to-class lookup now usesObject.hasOwn(BY_CODE, exc.code). Before, theApiErrorthaterrorFromEnvelopebuilt safely was looked up by prototype chain again one hop later.src/low/errors.ts: acodethat is not a string is treated as absent, and the status-derived code is used instead.src/sdk/models.ts(discoveryFetch): the same unread-error latch aroundresponse.text(), somodels.schemaandmodels.listraise the typed error on a cut-off non-2xx body. It is checked after the deadline and caller-abort exits.830cb26).src/low/errors.ts: amessagethat is not a string is treated as absent too, and falls back toHTTP <status>. An object with a nulltoStringno longer throws a bareTypeErrorfromerrorFromEnvelope.src/sdk/exceptions.ts(toSdkError):retryAfteris forwarded for every code, not onlyqueue_full.mainhas since shipped the same change (#200), so after mergingmainthis is no longer part of this PR's diff; its extraexceptions.test.tscases forrate_limited/service_unavailable/not_foundstay.src/sdk/models.ts(describeValidationFailures): only string/numberlocsegments and a stringtypeare stringified, so a hostiledetail[]entry still yields the typed error.CHANGELOG.md—Fixedentries under[Unreleased]: the unread body (run,schema,listand the queue calls), the code-to-class lookups in both layers (plus the non-stringmessage), and the non-throwing validation summary. (TheretryAfterentry was dropped when mergingmain, whose ownFixedentry already covers it.)src/sdk/retry.tsis untouched.Judgment calls
let unreadError = null as Response | null;is asserted, not annotated. With: Response | null = null, TypeScript narrows it tonullin the outer catch, because it does not see the assignment made in the inner catch.tscthen typedunreadError.statusasneverinsend(). A comment in the code explains this.runLoop/stampingrefactor ofmodels.run(#152) has since landed onmain; merging it moved the unread-body latch intorunLoop()unchanged. It sits just before the existing out-of-budgetstampIdempotencyKeyexit, and the typed error it throws already carriesidempotencyKey. On the queue path,send()runs insidesubmit'sstampingwrapper, so the unread-bodyRouterErrorgets the key there too. The queued-result rewrite ofsend()is still open and will need the same small rebase.Tests
models.test.ts, new describe block "comfy.models.run when an error body is cut off mid-read":invalid_input, body cut,retry: false→ComfyErrorwithhttpStatus422,codeinvalid_input,requestIdandidempotencyKeyset, andcauseaTypeError.insufficient_credits, body cut →InsufficientCredits, withcauseset.detail, has nocause, and the server saw 2 requests.TypeError.resetTimes(no response) withretry: false→ still the rawTypeError.constructor,toStringand__proto__asX-Comfy-Error-Typewith the body cut → aComfyErrorcarrying that code.errors.test.ts(low layer): an envelopecodeofconstructor,toString,__proto__orhasOwnProperty→ a plainApiErrorcarrying that code. A non-stringcode(an array, an object with a nulltoString, a number) → a plainApiErrorwith the status-derived code.A non-string
message(an array, an object with a nulltoString, a number) →HTTP <status>.exceptions.test.ts:toSdkErroron anApiErrorwhose code is one of those four names → a plainComfyErrorcarrying that code.retryAfter: 120on arate_limited,service_unavailableornot_founderror → carried onto theComfyError.models.test.tsalso covers a 422 whosedetail[]entries have a{ toString: null }typeandlocsegment → the typedinvalid_inputerror with message2 validation errors: body: invalid; body.seed: too small.models-discovery.test.ts:models.schemaanswering 404 +model_not_foundwith the body cut →NotFoundwith the request id and aTypeErrorcause, after one request. A 200 with the body cut → still the rawTypeError.modelRequests.test.ts:submitanswering 422 +invalid_inputwith the body cut →routerErrors.InvalidInputwithhttpStatus422,errorTypeinvalid_input,requestIdandcauseset, messageHTTP 422anddetail[].handle().status()answering a scripted 404 +request_not_foundwith the body cut →RequestNotFoundwithrequestIdandcause, andcausenot amongObject.keys(err).TypeError.I stashed the two source changes and re-ran the tests: the three typed-error tests fail without the fix and pass with it. In the same way, the
hasOwntest and the non-enumerablecausetest fail when their fixes are reverted.Residual
withInactivityLimitssets undici'sbodyTimeoutfrom the remaining budget. If undici's body timer fires on a non-2xx before the composedAbortSignaldoes, the read failure is not aTimeoutError, soisTimeoutdoes not catch it. Once retries run out, it now surfaces as the typed HTTP error with the undici error ascause, where before it was the raw error. The two timers come from the same budget, so this is an edge case, and the ticket's "deadline is never re-dressed" is about the signal deadline, which is still checked first. I did not write a test that forces undici's timer to win.send()rewrite (classify-before-read plusreadBodyWithinon the result route) is not onmain, so this PR does not touch it. When that PR rebases, it needs the same innertry/catcharound itsreadBodyWithinread, andisTooLargemust stay ahead of the new check.response.text()with no size cap. That predates this PR and is proposed as a follow-up.Provenance
c7413f2merges currentmain(1d6b3cd, #152). Resolved conflicts:models.ts(re-applied the latch insiderunLoop()),modelRequests.test.ts(kept both describe blocks) andCHANGELOG.md(kept bothFixedentries).pnpm testatc7413f2: 814 passed, 5 skipped, 0 failed.pnpm lint,pnpm format:check,pnpm typecheck,pnpm check:spec-driftandpnpm build: clean atc7413f2.c7413f2: all checks green, including the five required ones (Test node 22/24, build-check, cla-assistant, public-repo-hygiene) and sdk-parity.retry: false.unreadErroris declared with a type assertion instead of an annotation (see Judgment calls).causeon the queue path is set throughdefinePropertyrather than added to the publicRouterErrorOptions.InvalidInput.detailstays[]on an unread body (documented) rather than becoming nullable.models.runfor header-less errors; the duplicatedX-Comfy-Error-Typefinding was declined (Router sends that header once).🤖 Generated with Claude Code
Summary by CodeRabbit
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.