mirror of
https://github.com/Comfy-Org/comfy-typescript-sdk.git
synced 2026-11-04 23:47:27 +00:00
[PR #145] [MERGED] feat(models): cap the response body models.run will buffer #148
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#148
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/145
Author: @mattmillerai
Created: 9/14/2026
Status: ✅ Merged
Merged: 9/14/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-14155-models-run-max-bytes📝 Commits (2)
1ab5cfefeat(models): cap the response body models.run will buffer58507d2Merge main into the response-size-cap branch, and answer the review📊 Changes
6 files changed (+873 additions, -51 deletions)
View changed files
📝
CHANGELOG.md(+35 -0)📝
README.md(+19 -1)📝
src/sdk/index.ts(+1 -0)📝
src/sdk/models.test.ts(+317 -0)📝
src/sdk/models.ts(+434 -47)📝
test/support/router-stub-server.ts(+67 -3)📄 Description
ELI-5
comfy.models.runreads the whole response into memory before it hands you anything, and until now nothing said how big "the whole response" was allowed to be. This adds a ceiling:maxBytes, defaulting to 64 MiB. If the response says up front that it is bigger, the SDK hangs up before downloading it; if it never says, the SDK counts the bytes as they arrive and stops when they go past the limit. Either way you get a clear error you can branch on — and it is deliberately not retried, because asking again would just download the same too-big body all over again.The ceiling never changes what a response means, though. If the SDK was going to ask again anyway (a retryable
503, a409where the server is still finishing your generation), it does not read the body at all, so a giant error page from some proxy in the middle cannot turn "try again" into "give up". And an error the SDK does hand you keeps its identity — an oversized402is stillInsufficientCredits, just with a shortened message.What changed
RunOptions.maxBytes?: number | null— the largest response body one call will buffer. Omitted, it is the new exportedDEFAULT_MAX_RESPONSE_BYTES(64 MiB);nulldisables the cap entirely. Documented on the option, on the constant, in the module header, and in the README next totimeoutMs.Content-Lengthover the cap is refused before the body is read at all, and the body stream is cancelled so the oversized response is never downloaded — that is the cheap exit. The bytes actually read are then counted against the same cap, since a chunked response declares no length, a declared length is the encoded length when a body arrives compressed, and in any case it is a claim by the sender rather than a bound on it.ComfyErrorwithcode: "response_too_large"rather thanunexpected_response, carrying{ maxBytes, contentLength }or{ maxBytes, bytesRead }ondetails, plus the usualrequestId/idempotencyKey/httpStatus/retryAfter.details.maxBytesis the discriminator, not the code —errorFromResponsederivescodeverbatim from the server-controlledX-Comfy-Error-Type, so an upstream can mint the same string; only a local breach carriesmaxBytes.tryas thefetch, so without this the loop re-downloads the same oversized body on every attempt until the budget expires.maxBytesis validated at the call site, before a request goes out, so a process that asked for a cap it cannot have finds out before a model runs and is billed.NaNis the case worth the explicit check — every comparison against it is false, so an uncheckedNaNwould read as "no cap" and silently undo the ceiling the caller thought they set.runwill retry or collect is decided from its status line and headers, and its body is dropped unread — so an oversized upstream error page cannot make a retryable502fatal with the budget unspent, or abandon a generation the server is still holding behind a collectable409/504. An error responserundoes surface is truncated at the cap rather than refused, because the bucket a caller branches on lives in the status andX-Comfy-Error-Typeand the body only enriches the message. Only a200result past the cap raises.Content-Lengthwhen there is one and doubling (clamped to the cap) when there is not, so each chunk is copied in and dropped as it arrives. A chunk list would bound the payload but not the per-chunk object overhead nor the socket-read buffer each view pins, and its final concatenation would hold every chunk and the copy at once. A failed allocation inside the read leaves by the same non-retryable door the cap breach does, rather than as a bareRangeErrorthe retry branch would amplify.finish, on the JSON branch only, since #139 made the run route's200sometimes binary — so a multi-byte character split across a chunk boundary survives where a per-chunk decode would replace it with U+FFFD. That is pinned by its own test.Test fixtures:
test/support/router-stub-server.tsgainsstallBodyContentLength(the stall fixture's declared length, default unchanged at 4096) and achunkedBodyfixture that writes N chunks with noContent-Length, respecting backpressure so a client that stops reading actually stops the transfer.Judgment calls
maxBytesor passesnull; both are documented in the README with examples.runis going to re-ask about is never read. The alternative — one cap check over a shared read — is what the first round of review rejected, on three counts that are all real: a retryable502behind a CDN error page became permanently fatal, a collectable409/504abandoned a billed generation, and an oversized401/402body lostUnauthorized/InsufficientCreditsfor a bareComfyError. Stated on the option's JSDoc and in the README.response_too_largeis a client-side code, not a Routererror_typebucket, so it is deliberately not added tosrc/sdk/routerErrors.ts— that set is closed againstspec/router-openapi.yamland only the contract may grow it. No new exception class either: the ticket asked for a code to branch on, and a new class would be a cross-SDK surface change the Python twin has not made.Content-Lengthheader this cannot parse is treated as absent (a header sent twice reachesHeaders.getas"n, n"; a proxy can send something that is not a number). Refusing a response over an unparseable length would be guessing; the read-side count is what covers it.Verification of the denial path
This diff adds a
throwthat denies a capability (buffering a body past the ceiling), so before shipping it I went and confirmed the capability is still reachable rather than dead-ended, through every path this SDK offers:maxBytes: <larger>andmaxBytes: null. Both were exercised empirically, not asserted —disables the cap entirely on maxBytes: nullruns the exact fixture the cap rejects and resolves it successfully, andadmits a body of exactly maxBytes and refuses one byte morepins the boundary from both sides. The denial is conditional and caller-configurable, with its escape hatch in the same change, tested and documented.I also mutation-checked the two claims this PR makes that a green suite alone would not prove:
Content-Lengthpre-check makes both pre-check tests time out rather than fail an assertion — proving they reject before the body is read, not after.Test plan
pnpm test— 620 passed, 0 failed (26 files); 15 tests undercomfy.models.run response size cap, including the four that pin the classification order (retryable status kept retryable behind an oversized body, a collect that completes behind an oversized409, an oversized402that staysInsufficientCredits, and a200result that still raises).pnpm typecheck,pnpm lint,pnpm format:check,pnpm check:spec-drift,pnpm check:sdk-parity,node scripts/check-public-repo-hygiene.mjs,pnpm build— all clean.Residual
Not fixed here, and worth its own follow-up:
src/for.text()/.json()/.arrayBuffer()outside tests returns four sites; this PR caps one. The other three:src/low/transport.ts:327,332(the v2 JSON envelope — documents, not generated media),src/sdk/outputs.ts:119(Output.toBytes()— genuinely large by design, and the caller explicitly asked for the bytes), andsrc/sdk/assets.ts:301(AssetFactory.fromUrl— buffers an arbitrary caller-supplied URL into memory withtimeoutMs: null, which is the one of the three most worth a cap of its own).Provenance
## Residual, which is scope the change deliberately does not cover rather than criteria it missed.mainmerged in (#133, #139, #138 all landed insrc/sdk/models.tsafter this branch cut) and all seven bot review findings addressed — the classification-before-read restructure, the growable buffer, the non-retryable allocation failure,details.maxBytesas the discriminator,retryAfteron the breach,got NaN, and the abandoned-read wording.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.