mirror of
https://github.com/Comfy-Org/comfy-typescript-sdk.git
synced 2026-11-05 07:57:28 +00:00
[PR #95] [MERGED] feat: type the four newer Router error buckets against the vendored contract #97
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#97
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/95
Author: @mattmillerai
Created: 8/27/2026
Status: ✅ Merged
Merged: 8/27/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-9854-router-not-enabled-service-unavailable📝 Commits (1)
2178227feat: type the four newer Router error buckets against the vendored contract📊 Changes
12 files changed (+674 additions, -54 deletions)
View changed files
📝
AGENTS.md(+31 -9)📝
README.md(+37 -11)📝
spec/README.md(+12 -0)📝
src/index.test.ts(+1 -1)📝
src/sdk/index.ts(+1 -1)📝
src/sdk/models.test.ts(+107 -0)📝
src/sdk/retry.test.ts(+19 -0)📝
src/sdk/retry.ts(+24 -9)➕
src/sdk/router-spec-coverage.test.ts(+189 -0)📝
src/sdk/routerErrors.test.ts(+60 -3)📝
src/sdk/routerErrors.ts(+119 -18)📝
src/sdk/surface-parity.test.ts(+74 -2)📄 Description
ELI-5
The Router server can refuse a call for fifteen different reasons, and it says which one on a response header. This SDK only had a class for the first eleven of them. The two that matter right now are the ones nobody could catch:
not_enabled("Comfy Router is not switched on for you yet" — the answer every caller gets until the rollout reaches them) andservice_unavailable("something we depend on is down; try again in a bit"). This adds classes for those two plus the other two that were also missing, and adds a test that reads the vendored contract and fails if the list ever drifts from it again.What changed
RouterErrorsubclasses insrc/sdk/routerErrors.ts, each carrying itsmeaningfrom the contract:NotEnabled(not_enabled, 403),ServiceUnavailable(service_unavailable, 503),DeadlineExceeded(deadline_exceeded, 504) andRateLimited(rate_limited, 429). The last two were missing too, and the coverage test below requires every declared bucket to have a class, so half the set would not have satisfied it.TRANSPORT_ERROR_TYPESgoes from five entries to nine, in the contract's own order;REQUEST_ERROR_TYPESis unchanged.src/sdk/router-spec-coverage.test.ts.spec/router-openapi.yamlwas vendored by #94 but nothing read it, and nothing in this repo generates code from it, soscripts/check-spec-drift.mjs(which works by regenerating and diffing) has nothing to compare. The one thing that is coupled to that contract is hand-written, so the check is a test instead: it readscomponents.schemas.RouterErrorType.x-comfy-error-typesand asserts the buckets, their order, theirtiersplit, the class per bucket in both directions, the PascalCase naming rule, the runtimetoRouterErrorlookup, and that each new class's JSDoc actually came from the contract. It mirrorssrc/low/spec-coverage.test.ts, which does the same job for the hand-written pieces coupled to the v2 spec.ERROR_TYPE_BY_STATUSdeliberately untouched. Three of the four new buckets share an HTTP status with an older one (403 withforbidden, 429 withconcurrency_limit_exceeded, 504 withprovider_timeout), and that table only runs for a response carrying noX-Comfy-Error-Typeat all — i.e. the responses with the least evidence of which member of the pair they are. Guessing the newer one there would relabel failures the table has always classified. A header-less 503 stays unmapped for the same reason: far more often a load balancer with no Router behind it than Router's ownservice_unavailable.service_unavailableis retried by default, and already was.isRetryableStatusretries any 5xx that does not name a terminal bucket, so a 503 carrying it climbs out through the ordinary backoff. A test proves both attempts carry the sameIdempotency-Key, which is what makes the replay a replay rather than a second (billed) generation.not_enabledjoinsTERMINAL_ERROR_TYPES: the contract calls it TERMINAL and says not to treat it as an outage. On its own 403 that entry is a no-op —status < 500already refuses — and what it buys is that a 5xx carrying the bucket does not burn a full retry budget on an answer no replay changes.INTENTIONAL_ASYMMETRIESgains arouterErrorClassesAheadOfPythonfield: tolerated in one direction only (TypeScript may lead, never lag), and guarded by a rot check that fails the moment the Python snapshot grows the same name, with a message saying to delete the entry. Verified by simulation — addingNotEnabledto a scratch copy ofparity/python-surface.jsonproduces exactly one failure, the rot guard's. A lead cannot be used to smuggle in an invented class either:router-spec-coverage.test.tsonly passes for a bucket the vendored contract actually declares.README.mdnow lists the fifteen buckets, spells out the three status-sharing pairs, and says which one is retried;spec/README.mddocuments the second vendored contract and its check;AGENTS.md§1/§2/§6 gain the Router spec coupling and the lead-vs-asymmetry rule.No feature flag. This is additive pre-release surface: four new exported names, no existing name or behaviour removed or repointed.
The one behaviour change worth reviewing carefully
"not_enabled"added toTERMINAL_ERROR_TYPESis the only line that changes what an existing input does — a 5xx carryingnot_enabledwas retried before and is not now. It is safe because the contract states the bucket is terminal, because the contractual response is a 403 where the change is inert, and because a retry of it can only ever return the same answer. Every pre-existing retry test still passes unchanged.Second-order: widening
TRANSPORT_ERROR_TYPESwidens the exportedRouterErrorTypeunion, so a consumer with an exhaustiveswitchover it gets a compile error until they add the new cases. That is the intended signal, and the package is pre-1.0.Verifying a capability denial before shipping it
This diff tells a reader that
comfy.models.rundoes not raise these classes, so that claim was checked empirically rather than asserted, against every path the package exposes:comfy.models.run, through the stub server, 403 +X-Comfy-Error-Type: not_enabled→ throwsComfyError(prototype chainComfyError < Error < Object),code === "not_enabled",err instanceof routerErrors.NotEnabled === false.routerErrors.toRouterError(403, headers, body)on the identical response →NotEnabled,instanceoftrue. This path works, so the README redirects readers to it rather than leaving a dead end.Comfy, config, resolveBaseUrl, resolveCredentials, Asset, AssetFactory, Workflow, WorkflowFactory, Job, JobFactory, Outputplus theComfyErrorclasses. None classifies a Router response.toRouterErroris the only Router-classifying function in the public surface, and it is exported both asrouterErrors.toRouterErrorand from@comfyorg/sdk/errors.Residual
comfy.models.runstill cannot be caught withinstanceof NotEnabled. The acceptance this PR was written against asks for403 + not_enabled → NotEnabled,503 + service_unavailable → ServiceUnavailableandheader-less 403 → Forbiddenthroughmodels.run. Those three hold at therouterErrorslayer and are tested there, but not throughmodels.run, because the two layers are not wired together and this PR does not wire them:src/sdk/models.tshas its ownBY_ERROR_TYPEmapping four buckets onto theComfyErrorhierarchy (unauthorized → Unauthorized,forbidden → Forbidden,insufficient_credits → InsufficientCredits,model_not_found → NotFound) and dropping everything else into a plainComfyErrorwhosecodeis the bucket. Two concrete consequences a reader should not have to rediscover:models.runfailure for the new buckets is aComfyErrorwithcode === "not_enabled"/"service_unavailable"— branchable, and tested here through the stub server (detail preserved,httpStatusandrequestIdset) — but notinstanceof NotEnabled. The tests in this PR assert what that layer actually does; they do not assert the acceptance's wording, and no test here should be read as evidence that it holds.header-less 403 → Forbiddenis true oftoRouterError(viaERROR_TYPE_BY_STATUS) and false ofmodels.run, which yieldscode: "http_403"on a plainComfyErrorwhen neither header nor body names a bucket. A test in this PR pins that current behaviour explicitly so the difference is visible rather than assumed.Routing
models.runthroughRouterErroris a breaking change worth its own review — it movesUnauthorized,Forbidden,InsufficientCreditsandNotFoundout of theComfyErrorhierarchy that catches them today, andRouterErrordoes not descend fromComfyError, so everycatch (e) { if (e instanceof ComfyError) }in a caller's code would stop matching. Until that happens, the SDK exposes the buckets two ways at two layers, and the README now says so instead of the stale line it replaces (which claimedmodels.run"does not execute anything yet").The header-less fallback table is left half-aligned on purpose.
ERROR_TYPE_BY_STATUSstill maps 400 and 422 toinvalid_input, which separate in-flight work is removing to match the Python SDK's fallback, and this PR does not touch it in either direction. A header-less 503 therefore still resolves toInternalErrorrather thanServiceUnavailable— deliberate, argued above, and worth revisiting together with that alignment rather than piecemeal.The Python twin is not in this PR and
parity/python-surface.jsonis unchanged.pnpm check:sdk-paritypasses today because the Python surface has not moved. When the twin lands, the sequence is:pnpm sync:python-surface, then delete therouter-buckets-ahead-of-pythonentry fromINTENTIONAL_ASYMMETRIES— the rot guard fails with that instruction, so it cannot be missed. No merge-order coordination is needed in either direction.Artifacts named by the source request that were not exercised. The server-side sources of these buckets — the Router gate middleware and its resolver tests in the backend repo — are in a repository this change has no access to; the buckets, their statuses and their meanings were taken from the vendored
spec/router-openapi.yamlin this repo, which is the contract the SDK is supposed to follow anyway. Nothing was run against a live Router:COMFY_ROUTER_ENABLEDis off in every environment, and this SDK's tests run againsttest/support/router-stub-server.tsby design. Two linked issues describing the upstream contract change and the spec-pipeline work were likewise not readable from here. Consequently the wire claim — that a real server sendsnot_enabledon a 403 andservice_unavailableon a 503 with those exact header values — rests on the vendored contract, not on an observed response.No
TODOmarker naming the spec-pipeline work. The hand-written table would normally carry one pointing at the generation work that is meant to replace it, butscripts/check-public-repo-hygiene.mjsfails the build on any[A-Z]{2,6}-\d{2,6}token in a tracked file. The pointer is prose instead, in the module header and inAGENTS.md§2.Provenance
pnpm lint: clean;pnpm format:check: all matched files correct;pnpm typecheck: clean;pnpm test: 396 passed, 0 failed (22 files);pnpm check:spec-drift: in sync;pnpm check:sdk-parity: snapshot matches the Python SDK's default branch;node scripts/check-public-repo-hygiene.mjs: no internal-only references;pnpm build+pnpm pack: tarball carriespackage/dist/index.jsandpackage/dist/index.d.ts. Mutation-checked the new gates rather than trusting a green run: deletingnot_enabledfrom the closed set fails 3 of the coverage tests, removing theNotEnabledclass fails 4, and addingNotEnabledto a scratchparity/python-surface.jsonfails exactly 1 — the rot guard, with its "delete its entry" message.models.run() → NotEnabled/→ ServiceUnavailable/header-less 403 → Forbiddenassertions are met at therouterErrorslayer and not throughmodels.run, which raisesComfyErrorwith the bucket ascode; wiring the two layers together is a breaking change and was not attempted. See## Residual.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.