mirror of
https://github.com/Comfy-Org/comfy-swift-sdk.git
synced 2026-11-04 23:47:26 +00:00
[PR #66] [MERGED] feat(router): vendor the Comfy Router spec, add the error taxonomy and a contract drift check #66
Labels
No labels
enhancement
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-swift-sdk#66
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-swift-sdk/pull/66
Author: @mattmillerai
Created: 9/5/2026
Status: ✅ Merged
Merged: 9/11/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-10179-router-error-contract📝 Commits (2)
eddc252feat(router): vendor the Comfy Router spec, add the error taxonomy and a drift gate47384b5fix(router): address bot review findings on the error taxonomy and drift gate📊 Changes
13 files changed (+2069 additions, -4 deletions)
View changed files
📝
.github/workflows/openapi-contract.yml(+38 -3)📝
README.md(+9 -0)➕
Scripts/contract/check_router_contract.py(+357 -0)📝
Sources/ComfySwiftSDK/Internal/PollingFallback.swift(+9 -0)➕
Sources/ComfySwiftSDK/Internal/RouterConstants.swift(+22 -0)➕
Sources/ComfySwiftSDK/Internal/RouterErrorMapping.swift(+230 -0)📝
Sources/ComfySwiftSDK/Internal/SDKLog.swift(+1 -0)📝
Sources/ComfySwiftSDK/Public/ComfyError.swift(+7 -1)➕
Sources/ComfySwiftSDK/Public/RouterError.swift(+270 -0)➕
Sources/ComfySwiftSDK/Public/RouterJSON.swift(+122 -0)➕
Tests/ComfySwiftSDKTests/RouterErrorMappingTests.swift(+400 -0)➕
spec/README.md(+7 -0)➕
spec/router-openapi.yaml(+597 -0)📄 Description
ELI-5
Comfy Router lets you run a partner model by its ID, like
bfl/flux-2-pro. Before the SDK can call it, it needs to agree with the server on two things: the list of ways a call can fail, and the URL the call goes to. This PR copies the server's contract into the repo, writes those two things down in Swift, and adds a CI check that fails the build if the Swift and the contract ever stop matching. No networking is added — the call itself lands next.What's in it
spec/router-openapi.yaml— a one-way vendored copy of the Comfy Router contract, byte-identical to upstream (sha25699f5ddc8…20f8), with itsGENERATED ONE-WAY — DO NOT HAND-EDITheader intact. It sits at the samespec/path the Python and TypeScript SDKs use. Confirmed against it:/v2/models/{provider}/{model}withoperationId: runRouterModel, andservers[0].url: https://api.comfy.org.spec/README.mdsays what it is and what checks it.Public/RouterErrorType— the spec's fifteen buckets in the spec's declaration order (six request-tier, then nine transport-tier), plusunknown(String)so a bucket added upstream reaches the caller verbatim rather than failing to decode. A singlewiretable drivesinit(rawValue:),rawValueandknown, so there is one source of truth in both directions.Public/RouterError/RouterValidationErrorDetail— the typed error, including the FastAPI-shaped422detail[]with itslocpath (["body","images",0]→[.key("body"), .key("images"), .index(0)]) and dottedlocation("body.images.0").Public/RouterJSON— aSendable, value-typed JSON box for the two provider-owned payloads (ctx,input). They cannot be narrowed to a fixed field list without losing the branch a caller reads, and they cannot beAny, becauseRouterErrorisSendable.Internal/RouterErrorMapping—routerError(status:headers:body:idempotencyKey:), a pure function with no transport. Header (X-Comfy-Error-Type) over body (error_type) over status; case-insensitive header names;Retry-Afteras delta-seconds only;X-Comfy-Request-Idtrimmed and capped at 128;replayed= presence ofIdempotent-Replayed. It never throws — a malformed error body degrades to the next-best source of the same fact.Internal/RouterConstants— the run path template and the default base URL, both pinned to the spec by the drift check.ComfyErrorgainscase router(RouterError), and both exhaustive switches over the taxonomy (PollingFallback.isTransient,SDKLog.comfyErrorCaseName) handle it. There are no other switches over aComfyErrorvalue in the tree — the remainingdefault:branches switch overURLError.Code, an HTTP status, anErrortype, or a string, so none silently swallows the new case.Scripts/contract/check_router_contract.py+ a secondrouter-contractCI job (checkout, Python 3.12, PyYAML, run), andspec/**added to the workflow'spull_request.paths. The job needs no network: the spec is vendored, so it compares the checked-out tree against the checked-out contract.Two bugs the self-review caught, and their fixes
Both were in code this PR introduces; both now have regression tests.
A crash reachable from a server-controlled response body.
RouterJSON.intValuebounded withvalue <= Double(Int.max).Double(Int.max)rounds up to 2^63, so a JSON9223372036854775808passed the guard and then trapped inInt(_:)— verified as a realFatal error: Double value cannot be converted to Intbefore fixing. The reachable path is a validation error'sloc, which the mapper reads withintValue, i.e. inside the error builder that is documented never to fail. The bound is now exclusive;Double(Int.min)is exactly -2^63 and representable, so the lower bound stays inclusive. Near the boundary the JSON integer is already lost toDoublebefore this type sees it (-9223372036854775809rounds to exactlyInt.min) — that isDoubleprecision rather than a range check to tighten, and the test pins the actual behaviour rather than a wish.A doc comment that contradicted the code.
RouterValidationErrorDetail.inputclaimed an explicit JSONnullstayed distinct from the property beingnil, while the mapping collapses both tonil. The mapping is right — the contract's way of saying "no bound" is to omit the field — so the doc was corrected and a test pins it.Verification
The drift gate was exercised against nine deliberate failures, not just the happy path. All nine exit 1 with an actionable sentence; the tenth line is the restored tree passing.
not_enabled)runPathTemplatemoved to/v1/...defaultBaseURLchangedrunRouterModelrouter-error-typesmarkers removedNegative-claim falsification
The README addition states there is no Router run method yet, which is a capability denial, so it was falsified empirically rather than assumed.
git grep -iE "v2/models|runRouterModel|api\.comfy\.org|router"overorigin/mainacrossSources/,Tests/andREADME.mdreturns zero hits — there is no Router surface on the default branch by any spelling. The public API onmainisComfyCloudClient(submit/events(for:)/reattach/ auth) andComfyAuth; neither exposes a model-run-by-ID call under any name. Nothing in the tree constructsComfyError.routereither, which is what makes the "not reachable today" comment on the newPollingFallback.isTransientbranch accurate rather than aspirational.Residual
Nothing in this repo re-syncs the vendored spec. The path was chosen to match the Python and TypeScript SDKs so the spec-push pipeline row can be identical, but adding this repo to that upstream push is a change in the pipeline's own repo and is not in this diff. Until someone makes it,
spec/router-openapi.yamlonly moves when a human copies it, and therouter-contractjob will keep passing against a spec that has silently gone stale — the gate catches SDK-vs-vendored-copy drift, never vendored-copy-vs-upstream drift.The mapping is verified against the contract, never against a live response. Every classification test is a synthetic
(status, headers, body)triple built from the vendored spec's declared shapes. No call was made to the real Router service — there are no credentials here, and doing so would be a billable network call — so if the deployed service's error bodies differ from the contract in a way the contract does not describe, nothing here would have noticed. Worth one live 4xx capture when the run method lands.Two status codes are classified by the letter of the plan rather than by judgment.
413(payload too large) falls into theinternalErrorcatch-all rather thaninvalidInput, because the specified fallback table lists400/409/422 → invalid_inputandanything else → internal_error, and413is declared on the run route in the spec.403resolves toforbiddenrather thannot_enabledand504toproviderTimeoutrather thandeadlineExceeded, since each status carries two buckets and only the header separates them. In practice Router setsX-Comfy-Error-Typeon every error response, so the fallback is a last resort — but if a proxy strips that header, these are the three answers that would be wrong.ComfyError.routeris classified as non-transient inPollingFallback.isTransient. It is unreachable today (nothing constructs the case), and the real retry policy is per-bucket and depends on idempotency-key semantics that belong with the run method, not with a job-status poller's backoff ladder. If the Router surface ever routes through this poller, that branch needs revisiting rather than inheriting.The parent epic was named to me but not readable. I had its identifier, state and title only — not its body or comments — so this PR is built against this ticket's own text and the vendored contract, and any acceptance criterion living only in the epic's description is unexercised. The ticket's comment thread carried no substantive comments.
One deliberate deviation from the specified shape. The wire table was asked for as
.invalidInput: "invalid_input",lines and asprivate static let wire: [(RouterErrorType, String)]. Those two are mutually exclusive in Swift — dictionary-literal syntax needs aDictionary/KeyValuePairs, not an array of tuples. I kept the specified type and wrote the rows as(.invalidInput, "invalid_input"),, which preserves the property the shape existed for: exactly one quoted snake_case value per line, so the checker's"([a-z_]+)"regex is unambiguous. The checker rejects a line carrying two quoted values rather than silently taking the first.Provenance
swift build: clean;swift test --no-parallel: 269 tests in 31 suites passed, 0 failed (12 of themImportBoundaryTests, green — the new files import onlyFoundation);python3 Scripts/contract/check_router_contract.py:OK: 15 error types, POST https://api.comfy.org/v2/models/{provider}/{model} bound, exit 0, plus the nine injected-drift cases above each exiting 1; vendored spec sha256 compared byte-for-byte against upstreammain.## Residualabove.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.