mirror of
https://github.com/Comfy-Org/comfy-swift-sdk.git
synced 2026-11-04 23:47:26 +00:00
[PR #69] [MERGED] fix(router): resolve the four deferred review findings on the error surface #70
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#70
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/69
Author: @mattmillerai
Created: 9/11/2026
Status: ✅ Merged
Merged: 9/12/2026
Merged by: @mattmillerai
Base:
main← Head:matt/router-error-surface-review-followups📝 Commits (8)
e814bbdfix(router): resolve the four deferred review findings on the error surface912f631fix(router): clamp a long Retry-After, require the idempotency key, harden the unsigned guard97d806efix(router): revert the Retry-After clamp, gate the 409 guess on a keyf1db3cefix(router): withhold keyless retry advice, reject blank key/header, guard .int by valuebacad3ffix(router): scope retry suppression to the collect statuses, exclusive ceiling, normalise at the public init12bf5bcfix(router): key retry advice on the wire answer, parse Idempotent-Replayed, trim OWS onlyd689565fix(router): break the collect-gate circularity, bound ordinary backoff, refuse control-char keys5034fe4fix(router): correct three defects introduced by the round-6 collect gate📊 Changes
4 files changed (+865 additions, -61 deletions)
View changed files
📝
Sources/ComfySwiftSDK/Internal/RouterErrorMapping.swift(+238 -21)📝
Sources/ComfySwiftSDK/Public/RouterError.swift(+110 -13)📝
Sources/ComfySwiftSDK/Public/RouterJSON.swift(+128 -16)📝
Tests/ComfySwiftSDKTests/RouterErrorMappingTests.swift(+389 -11)📄 Description
ELI-5
The Router error types landed in #66. Bot review raised four problems with their public shape that were held back for a decision rather than fixed inline, because changing a type's shape after callers exist is a breaking change. #66 merged before they could be applied, so they land here instead — still before anything calls these types, which is the whole point of the timing.
No behaviour is added. Four types get more honest.
What's in it
RouterJSONgainscase int(Int). Every non-booleanNSNumberwent throughdoubleValue, so an integer above 2^53 rounded —9007199254740993became…992— andintValuethen returned the rounded value as if it were exact. That contradicts the documented promise thatctxandinputare carried verbatim. In this domain the lost bit is a 64-bit generation seed, and a silently rounded seed produces unreproducible output from a call that looked like it succeeded.init(any:)asks the Core Foundation type in order — boolean first, then the number's declared float type, then an exactInt. The declared type decides, never a value heuristic, so2.0stays a.number.intValueanddoubleValueboth answer for either numeric case, and.int(1) != .number(1.0)is deliberate and documented: they are distinct wire shapes.RouterError.idempotencyKeybecomes optional. The contract attaches the key to the run route alone and marks it optional there, while the three catalog reads answer with the same error body and carry no key at all. A non-optionalStringforced"", which a consumer cannot tell from a real key — and the field's own doc tells them to re-send it. The type doc now covers catalog-read failures too, not only model runs.The
409fallback readsRetry-After. A409carries two buckets that are acted on in opposite ways: re-send the same key, or mint a new one. Collapsing it into.invalidInputalongside400/422pointed a caller at a new key and a second billable generation. The contract separates them on the wire, so the fallback now does too, and409joins the ambiguous statuses the doc comment enumerates instead of being silently omitted from it.Header presence is the test, not a value this SDK can parse. The two wrong guesses are not symmetric: guessing concurrency wrong costs one wasted re-send, guessing the other way costs a generation. Reachable only when neither the header nor the body named a bucket.
Retry-Afteris honoured over1...86400. The ceiling is the contract's, not an invented one: a key is held for 24 hours, so advice to wait longer than the key's own life is self-defeating — past it there is nothing left to collect. Outside the window the value is dropped rather than clamped, which would invent a number Router never sent, and which matches this file's existing rule that an unusable value yields no advice rather than bad advice.The one that nearly went wrong
The
.intpath was specified to route on the number's declared float type. Probing the runtime first showed that is not sufficient:JSONSerializationhands9223372036854775808back as an unsignedNSNumber(objCTypeQ) — not a float — whoseint64Valuesilently wraps toInt.min. Routing on float-type alone would have produced.int(Int.min): precisely the silent corruption this change exists to prevent, and it would have falsified an existing assertion.The
.intpath is therefore guarded by a round-trip comparison,NSNumber(value: exact).isEqual(to: number). Integral-but-too-wide falls back to.number— lossy, but honest, andintValuestill answersnilthere. That behaviour is measured, not assumed, and it is pinned by a test.Verification
swift build: clean.swift test --no-parallel: 283 tests in 31 suites, 0 failed.python3 Scripts/contract/check_router_contract.py:OK: 15 error types, POST https://api.comfy.org/v2/models/{provider}/{model} bound — in sync with spec/router-openapi.yaml, exit 0.9007199254740993round-tripping exactly throughintValue; a genuine floating value still landing on.number;9223372036854775808still readingnilrather thanInt.min; a present-but-unparseableRetry-Afteron a bare409still reading as concurrency.#expect(json["ok"].doubleValue != nil) // still a number, just not an exact Intis now#expect(json["ok"].intValue == Int.max) // integral and in range: exact. That is the point of the change, not a test bent to fit it.spec/router-openapi.yamlis untouched. It is a one-way vendored copy and nothing here hand-edits it.Residual
These four were reviewed as findings, not as this diff. They were raised against #66's code and answered there in the thread; the code that implements the answers — this PR — has had no bot review of its own yet. Worth a real pass rather than inheriting #66's.
CodeRabbit never reviewed #66. It reported
passwith "Review rate limited" on both of that PR's commits, so #66 merged on Cursor review alone. This is not a consequence of this PR, but it is the reason the error surface has had less scrutiny than the check count suggests.An upstream contract defect is recorded but not fixed here. The canonical spec describes the model ID as
{provider}/{model}[/{variant}]inRouterModelSegmentandRouterProviderSegment, whileRouterModelId.patternpermits exactly one/and the invocation route declares two path parameters — so the variant form is documented but unrepresentable and unroutable. Every SDK refuses a third segment, consistent with the pattern and not with the prose. The fix belongs upstream and re-syncs from there; nothing in this diff touches the vendored copy.413and the403/504pairs keep #66's fallback answers. Only409was re-examined, because only409's two buckets demand opposite actions from the caller. The others remain classified by the letter of the original plan.Provenance
swift buildclean;swift test --no-parallel283 tests in 31 suites, 0 failed;check_router_contract.pyexit 0; branch confirmed 1 ahead / 0 behindmainata9ca628. All three re-run independently after the cherry-pick, not carried over from the pre-merge branch..introuting guard described under "The one that nearly went wrong" — a round-trip comparison rather than the specified float-type check alone, because the specified form was measured to be wrong.Retry-After's upper bound of 86400 is derived from the contract's 24-hour key retention rather than declared by it; the contract statesminimum: 1and no maximum.🤖 Generated with Claude Code
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.