mirror of
https://github.com/Comfy-Org/comfy-typescript-sdk.git
synced 2026-11-04 23:47:27 +00:00
[PR #185] feat(models): return the binary arm from a queued result with a non-JSON Content-Type #185
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#185
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/185
Author: @mattmillerai
Created: 9/25/2026
Status: 🔄 Open
Base:
main← Head:matt/be-17267-queued-binary-result📝 Commits (10+)
fb1451ffeat(models): branch the queued result on Content-Type and return the binary arm028161afix(models): give the queued cap breach advice it can act on, and keep invalid_response's causebeb0623fix(models): drop a retryable queued response unread, and keep the cap advice honest06168a5docs(changelog): note the headerless non-JSON and cap outcome changes on queued resultsa0a7470feat(models): take a per-call maxBytes on the queued result read115eb07fix(models): read only a queued 200 under the cap, and hand subscribe's cap breach the id to retry with39376bffix(models): keep a small cap from cutting error envelopes, and drop a non-200 2xx body unreadb84db14test(models): prove collect() applies the default cap, and assert the error class before reading it4fc3e1ffix(models): keep the identifiers on a non-shape JSON parse failure, and report the cap actually appliedaab199aMerge origin/main into matt/be-17267-queued-binary-result📊 Changes
8 files changed (+1333 additions, -91 deletions)
View changed files
📝
CHANGELOG.md(+42 -0)📝
README.md(+2 -0)📝
src/sdk/index.ts(+1 -0)📝
src/sdk/modelRequests.test.ts(+494 -4)📝
src/sdk/modelRequests.ts(+310 -43)📝
src/sdk/models.test.ts(+241 -0)📝
src/sdk/models.ts(+225 -41)📝
test/support/router-stub-server.ts(+18 -3)📄 Description
ELI-5
When you queue a model with
submitand collect it later, the SDK used to assume the result was always JSON. The Router contract says a queued result can also be raw bytes, such as an audio file, and that clients should look atContent-Typeto tell which one they got.models.runalready does that. With this changeRequestHandle.get()andmodels.subscribe()do it too: a non-JSON result comes back as{ kind: "binary", data: Uint8Array, contentType }.Honest scope: today's Router can't produce this. It refuses binary models at submit time, and its result handler only ever writes JSON. So this doesn't recover any bytes that are being lost now. It brings the SDK in line with the contract, and it matches the Python SDK change that makes
RequestHandle._collectreturndict | BinaryResult.What changed
src/sdk/models.ts:readBodyWithin,isTooLarge,mediaTypeOf,isJsonMediaType,decodeUtf8andUTF8_STRICTare now exported from the module.src/sdk/index.tsdoesn't re-export them, so the public surface is unchanged.readBodyWithin/tooLargenow take a preformattedsubjectinstead of the model id, andresolveMaxBytestakes the calling surface's name for its rejection message, so the cap-breach message names the queued request (the result of model request <id> response body …).idempotencyKeyis widened tostring | nullbecause the result route doesn't send one.run's behaviour is unchanged: it passesmodels.run("<model>")as before.src/sdk/modelRequests.ts:send()takes two new optional fields,acceptandbytes. Whenbytesis set, the body is read withreadBodyWithininside the sametryas the fetch, so the deadline/abort translation still covers reading the body. Aresponse_too_largeerror is thrown straight away and not retried, just as inrun. As inrun, a retryable status is detected before the body is read, and that body is cancelled rather than downloaded on every attempt. Only a non-2xx body gets UTF-8 decoded, because the error envelope is the only thing that needs text. That decode happens after the retrytry. If it fails (an error page too long to hold as a string), the typed error comes from the status line andX-Comfy-Error-Typealone, and nothing is retried (review round 10).decode()keeps the JSON parse error as thecauseof itsinvalid_response, and only aSyntaxErroris reported as "not JSON" (review round 9).invalidResponsesetscauseonly when one was given.A
request_timeoutraised while the result body is streaming carriesdetails.queuedRequestId(review round 9). Every queuerequest_timeoutalso carries the call'sidempotencyKey(review round 10).collect()now:Accept: application/json, */*;q=0.9and reads the result within the caller'smaxBytes(new onGetOptions, which isWaitOptionsplusmaxBytesand is taken byget()only, and onSubscribeOptions; same semantics asRunOptions.maxBytes, defaultDEFAULT_MAX_RESPONSE_BYTES, validated before any request and again insidecollect()). Only a result200is refused past the cap. A202(or any other non-200 2xx) is diagnosed from its status line with the body dropped unread, and an error answer is truncated rather than refused, never below 1 MiB, so it still reports its own diagnosis. A cap breach carries the queued request's id asdetails.queuedRequestId, becausesubscribe()returns no handle on that path.invalid_responseit raises after the result fetch now carriesdetails.queuedRequestId.invalid_response.binaryarm.Content-TypeasbinarywithcontentType: ""if it isn't valid JSON. The decode in this case is strict UTF-8. Any throw falls through tobinary: invalid UTF-8, a body too long to hold as a string, or a parseRangeErrorfrom nesting too deep for the parser. Nothing declared a document, and a re-collect would fail the same way, so the bytes are the only lossless answer. This is how the base already behaved forrun(review round 10).invalid_response. If the failure is not aSyntaxError, the message says whether decoding or parsing ran out of resources, and the error carriesdetails.queuedRequestIdwith the original error ascause.raiseForCompletionexactly as before.The comment that said the queued path "has no binary branch" is gone. The
get()doc comment now describes the binary arm and the cap.test/support/router-stub-server.ts:ScriptedResponse.bodyaccepts aUint8Arrayand sends it byte for byte, and a new per-responsecontentTypeoption overrides the global one (nullomits the header).RecordedRequestalso records the request'sacceptheader.Judgment calls / behaviour changes for JSON results
The ticket also asks for "no behaviour change for any JSON result". Three edge cases do change, all on purpose, to match
finishand the ticket's own test list:{ kind: "json", data: {} }. It now rejects withinvalid_response.response_too_largeby default, as the ticket asks.get()/subscribe()takemaxBytes(nulldisables the cap), and the request stays collectable after a breach.Content-Typeand invalid UTF-8. This used to be decoded lossily and parsed. It is now decoded strictly and, if that fails, returned asbinary.For every ordinary JSON result with
Content-Type: application/json, nothing changes: same parse, sameraiseForCompletioncall, same error codes, same retry and timeout budget.Tests
There's a new
describe("RequestHandle on a binary result")block insrc/sdk/modelRequests.test.ts. It uses the MP3-head fixture frommodels.test.ts, including its invalid-UTF-8 bytes, and covers:get()resolving tobinarywith the exact bytes and the request idsubscribe()resolving tobinaryaudio/mpeg; rate=44100keeping the fullcontentTypestringContent-Typethat isn't JSON →binarywithcontentType: ""Content-Type→jsonAcceptsent asapplication/jsonon the status request andapplication/json, */*;q=0.9on the result requestinvalid_responseContent-LengthoverDEFAULT_MAX_RESPONSE_BYTES→response_too_largewithdetails.maxBytes, the request id in the message, "raise maxBytes" advice, and no retrymaxBytesonget()andsubscribe()is honoured, the breach carriesdetails.queuedRequestId, a laterget({ maxBytes: null })collects the same request (reached fromsubscribe's error through that id), and an invalidmaxBytesrejects before any request202from the result route withmaxBytes: 4→invalid_response"answered 202" carryingdetails.queuedRequestId, notresponse_too_large422whoseerror_typeanddetail[]are in the body only, undermaxBytes: 4→InvalidInputwith itsdetail[]intactcollect()called directly rejects aNaNcap and applies the default cap whenmaxBytesis omittedinvalid_responsewith theSyntaxErroras itscauseRangeErrorparsing a declared-JSON result →invalid_responsewithdetails.queuedRequestIdand theRangeErrorascause(review round 7)RangeError→binary; a 403not_enabledwhose error body cannot be decoded →NotEnabledafter exactly one requestRangeError→binary; a status body whose parse throws aRangeError→ no "not JSON" message; a deadline that expires while the result streams →request_timeoutwithdetails.queuedRequestId; and the202error has no owncauseIn
src/sdk/models.test.ts(review round 5), two tests coverrun: a body-only422envelope undermaxBytes: 64keepsinvalid_inputand itsdetail[], and an oversized202undermaxBytes: 16reportsunexpected_responserather thanresponse_too_large. Review round 7 adds a third: aRangeErrorparsing a declared-JSON 200 →unexpected_responsewithrequestId,idempotencyKeyand theRangeErrorascause. Review round 8 adds a gzip test (a body declared past the cap that decodes inside it resolves; one declared inside it that decodes past it is still refused), a fractionalmaxBytesrejection onrunandget(), and assertions on theRangeErrormessage on both paths. Review round 9 adds a headerless body whose decode throws aRangeError(→binary) and aContent-Encodingthatfetchdoes not decode (x-unknown,gzip, x-unknown,identity,identity, identity), which is still refused onContent-Lengthbefore the body is read. Review round 10 adds two more: a headerless 200 whose parse throws aRangeError(→binary), and a declared-JSON 200 whose decode throws, which reports "decoding".The existing test "rejects when the result body is not JSON at all" (
application/json+<html>) is unchanged and still passes. Apart from that, the only edit to existing tests is the comment about thejsondiscriminant.Residual
mainis merged into this branch. #152 (error-path idempotency stamping) wraps themodels.runloop and stamps the queuerequest_timeout; the merge keeps both sides (see Provenance). From #157 (credits-used) and #164 (replayed):creditsUsedandreplayedare on all three returns fromcollect()(bothbinaryreturns andjson), each typed as the internalBuiltRunResultso a missing field failstsc.maxBytesonGetOptions(new exported type,WaitOptionsplusmaxBytes) /SubscribeOptions. It is not onWaitOptionsitself, becauseevents()fetches no result and would ignore it. I first left this out as a separate API decision. Review round 3 showed the cap also applies to JSON results, so without it a result over 64 MiB could never be collected. The option mirrorsRunOptions.maxBytesexactly. The Python queued surface doesn't have an equivalent yet, and the parity test compares names, not options.readBodyWithin(shared withrun) now pre-sizes from a declaredContent-Lengthonly up to 1 MiB, so a stalled peer can't commit a 64 MiB allocation up front. Past that it grows by doubling, in step with the bytes received, and a vetted declared length only clamps the last step to an exact fit.models.run(review round 5).readBodyWithinreads an error body to at least 1 MiB before truncating it, so a smallmaxBytesno longer cuts the envelope that a body-onlyerror_typeordetail[]is parsed from.rundrops the body of a 2xx other than 200 unread, so an oversized202reportsunexpected_responseinstead ofresponse_too_large. For a declared-JSON body, a parseRangeErrorleavesfinishas anunexpected_responseComfyErrorthat keepsrequestIdandidempotencyKey. Its message says resources ran out while decoding or parsing; it does not say the body "is not JSON". A headerless body goes back to the bytes arm on any throw, as onmain. The queued path works the same way. An allocation failure on a truncated error body reports the 1 MiB-floored limit it applied, andMIN_ERROR_BODY_BYTESis its own 1 MiB literal. A declared-JSON body is still decoded leniently on both paths, asResponse.json()does. Review round 8 adds three more that reachrun: aContent-Lengthsent with aContent-Encodingthatfetchdecodes is ignored (it counts encoded bytes, while the cap counts the decoded bytesfetchhands over, and the streamed count still enforces it). Since review round 9 that applies only when every listed coding is onefetchdecodes (gzip/x-gzip/deflate/br/zstd); any other token makesfetchpass the encoded bytes through, so the header is still honoured; a fractionalmaxBytesis a call-siteTypeError; and aRangeErrorwhile parsing no longer says the body "is not JSON", on either path.response.text(). This came before this PR and is recorded as a follow-up.models.runand the queuedsend()raise the raw reader error rather than the typed HTTP error.mainbehaves the same way, and this is recorded as a follow-up.maxBytes: null, a body too large forarrayBuffer()throws a bareRangeErrorthat is retried instead of being terminal.mainbehaves the same way, and this is recorded as a follow-up.retry: falseafter a COMPLETED status, and status-poll failures, still do not carryqueuedRequestId. This is recorded as a follow-up.RouterError(for example a final 5xx), the error does not carryqueuedRequestId, becauseRouterErrorhas nodetails.mainbehaves the same way. Fixing it needs a cross-SDK surface decision, so it is recorded as a follow-up.src/sdk/router-spec-contract.test.tsdoesn't currently pin the queued 200's media types, so I didn't add an assertion there, per the ticket's "if it already pins" condition. A pin that readsgetRouterModelRequestResult's 200contentkeys fromspec/router-openapi.yamland checks for bothapplication/jsonand*/*would be a small follow-up.parity/python-surface.json. The Python change hasn't merged, and the parity test compares method, class anderror_typenames, not return types, so it shouldn't flag this change.Provenance
mainatccae1fe, then oxfmt 0.68 reformat of two test files):pnpm typecheck,pnpm lint,pnpm format:check,pnpm buildandpnpm check:spec-driftpass.pnpm test: 818 passed, 5 skipped, 0 failed.mainmerge (oxfmt 0.68 bump, Router spec sync) applied cleanly; under oxfmt 0.68 the merged tree failedformat:checkonmodelRequests.test.ts/models.test.ts, so those two files carry formatter-only output.stampingwrapper around themodels.runloop keeps this PR's non-200 2xx body drop and labelled capped read, and the queuerequest_timeoutcarries both #152'sidempotencyKeyand this PR'sdetails.node scripts/sync-python-surface.mjsand the public-repo hygiene check were not run locally on this head (CI runs both).Content-Typechange three JSON edge cases (listed above).maxBytesoption to the queued surface on a newGetOptionstype.models.run: 1 MiB error-body floor, a non-200 2xx body dropped unread,Content-Lengthignored only for a codingfetchdecodes, a fractionalmaxBytesrejected, and a declared-JSON parseRangeErrorno longer reported as "not JSON".🤖 Generated with Claude Code
Summary by CodeRabbit
get(),subscribe(), andcollect()accept a per-call response-size limit, defaulting to 64 MiB; the cap can be disabled. Oversized results can be collected later with a higher limit.invalid_responseerror.response_too_large; retryable responses are retried without reading their bodies.invalid_responseerror, while undeclared non-JSON content is treated as binary.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.