mirror of
https://github.com/Comfy-Org/comfy-swift-sdk.git
synced 2026-11-04 23:47:26 +00:00
[PR #78] [MERGED] feat(errors)!: give ComfyError a sanitizing, bounded description #79
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#79
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/78
Author: @mattmillerai
Created: 9/16/2026
Status: ✅ Merged
Merged: 9/18/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-14667-comfyerror-sanitized-description📝 Commits (4)
edc15a9feat(errors)!: give ComfyError a sanitizing description so no payload reaches a log unbounded1d17e36fix(errors): bound JobExecutionError's server-derived fields when reflectedc9e8f79fix(errors): redact on the failing-URL keys, not the error domainaf091e3fix(errors): redact on URL-key presence, and spend the whole log budget📊 Changes
8 files changed (+945 additions, -5 deletions)
View changed files
📝
README.md(+1 -1)📝
Sources/ComfySwiftSDK/Internal/ComfySDKInfo.swift(+1 -1)➕
Sources/ComfySwiftSDK/Internal/LogSafeText.swift(+114 -0)📝
Sources/ComfySwiftSDK/Internal/SDKLog.swift(+4 -1)📝
Sources/ComfySwiftSDK/Internal/Transport.swift(+30 -1)📝
Sources/ComfySwiftSDK/Internal/WebSocketSession.swift(+19 -1)📝
Sources/ComfySwiftSDK/Public/ComfyError.swift(+235 -0)➕
Tests/ComfySwiftSDKTests/ComfyErrorDescriptionTests.swift(+541 -0)📄 Description
ELI-5
If something goes wrong, the SDK hands your app a
ComfyError. If you then wrote that error into a log —logger.error("\(error)")— Swift printed it by reflection, which dumps every value inside verbatim: the whole server message, the whole failing URL, newlines and all, with no length limit. A hostile or just chatty server could therefore write its own fake lines into your log, or flood it.RouterErrorin this same SDK already fixed that for itself; this brings the rest of the error taxonomy up to the same bar, and closes one credential leak that the length limit alone would not have caught.What changed
ComfyErrordeclared onlyError, Sendable, soString(describing:)and every"\(error)"fell to Swift's default enum reflection. Three payload shapes reach that renderer carrying unbounded, control-character-unhandled, server- or network-derived text: theURLSessionerror boxed in.network(underlying:)/.unknown(underlying:)(whose bridgedNSErroruserInfocarries the failing URL, query string included),SubmitErrorBodyboxed in.unknown(underlying:)(raw server string, nodescriptionof its own), and.serverRejected(reason: .other(_))(the rawerror/message/reasonfield from the server's JSON).extension ComfyError: CustomStringConvertible, CustomDebugStringConvertible—descriptionswitches over every case, deliberately exhaustively so a new case is a compile error rather than a silent reflection fallback. Payload-free cases render a short stable label reusing the vocabularySDKLog.comfyErrorCaseNamealready emits (a test asserts the two agree, so a reader correlating a log line with a rendered error does not need to know two spellings).debugDescriptionmirrorsdescription..router(_)delegates toRouterError.descriptionrather than re-rendering its stored fields, so theIdempotency-Keyand the server-echoedinputvalues insidevalidationErrorsthat it deliberately withholds stay withheld..rateLimited(retryAfter:)renders theDoubledirectly. NotInt(retryAfter)— that is a trapping conversion on a response-controlled value (Retry-After: 9223372036854775807stores exactly 2^63, one pastInt.max, andInt(_:)on it terminates the process), the same hazard already called out onRouterError.description.LogSafeText.bounded(_:to:)replaces every scalar inCharacterSet.controlCharacters.union(.newlines)with.— the union deliberately includes U+2028/U+2029, which are Zl/Zp rather than Cc/Cf but which a log viewer renders as line breaks — and clamps to 512 UTF-8 bytes with the ellipsis charged to the same budget. The cut walks byCharacterso it never lands mid-cluster; when a single cluster is wider than the room left (combining marks are neither control characters nor newlines), whatever room theCharacterwalk leaves is filled at the scalar boundary from the cluster it stopped on — wherever in the string that cluster sits — so one wide cluster cannot collapse the rendering to its prefix.SubmitErrorBodyandJobExecutionErrorgain their own boundeddescriptions (JobExecutionErroromitting absent fields rather than rendering anilplaceholder a server could also send), so they are safe wherever they are reflected rather than only inside the one renderer that wraps them.0.5.0→0.6.0(README install pin with it), matching how the last error-taxonomy change was released.The judgment call worth reviewing
The plan this PR implements prescribed rendering a boxed error as
type(of:)plus a clampedString(describing:). That bound does not actually close the leak it names, and the diff therefore goes further forNSURLErrorDomainerrors specifically.WebSocketSession.buildWebSocketURLputs the API key or OAuth access token in the WebSocket URL'stokenquery item. Areceive()failure on that task reaches.network(underlying:)viaTransport.translate, carrying that URL in its bridgeduserInfo. Measured against a synthetic-but-shaped failure, the entire leaking reflection is 271 bytes against a 512-byte budget — the credential survives clamping completely intact. This repo's own rule is that credentials stay out of logs and error messages, so a renderer built to make errors log-safe should not still print a live token.So an error carrying a failing URL is rendered from the fields that are diagnostic and cannot carry a secret — the domain, the code (for
NSURLErrorDomain, theURLError.Coderaw value, which is the classification), and the failing URL's scheme, host and path — with the query, fragment and any userinfo dropped and theuserInfodictionary never reflected. When the failing URL is absent or unparseable, the domain and code stand alone. Review follow-up: the trigger is the URL-bearinguserInfokeys rather than the domain, because those keys are not exclusive toNSURLErrorDomain(Transport.translateboxesNSPOSIXErrorDomainand CFNetwork failures into.network/.unknown, andURLSessionputs the same failing URL on those), withNSURLErrorDomainstill triggering on its own and theNSUnderlyingErrorKeychain followed to a bounded depth. Round 2: the trigger is now the presence of one of those keys, not the success of extracting a URL from it. A key holding a stringURL(string:)rejects — or a key sitting past the chain walk's bounds — left the extraction nil and fell through toString(describing:), which prints the wholeUserInfo={…}whether or not this code could parse the value. Presence now decides the redaction; the parsed URL only decides whether a scheme/host/pathurl=is appended or the domain and code stand alone. The walk's own bounds (4 levels ofNSUnderlyingErrorKey, 4 wide acrossNSMultipleUnderlyingErrorsKey, which is now followed too) count whatever they leave unwalked as carrying a URL. One measured correction to the finding:NSError.descriptionon this toolchain stops printing nested underlying errors at about three levels, so the depth-cap fall-through was not in fact reachable that way — but that is an undocumented Foundation detail, and the code no longer depends on it. Every error carrying neither key still reflects normally, bounded and control-stripped — widening it to every bridgedNSErrorwould flatten the SDK's own boxed error types, since every Swift error bridges.This removes information from a log line, so it is worth stating what is not lost: the tests assert the endpoint (
wss://host/ws), the domain and the numeric code all still survive, which is what identifies the failure. What is dropped is the query string, whose only SDK-produced contents areclientIdandtoken.JobExecutionErroris the second deviation: it is the same shape asSubmitErrorBody(three strings straight off the server'sexecution_errorframe, boxed as.unknown(underlying:), nodescription) but the plan's audit named onlySubmitErrorBody. It gets the identical treatment.Behaviour change
This changes what
String(describing:)returns for every consumer ofComfyError. It is a log-string format only — nothing should parse it, the full values remain readable on the associated values themselves, andlocalizedDescriptionis unaffected (ComfyErroris notLocalizedError, so NSError bridging still supplies that). The package is pre-1.0. No log line the SDK itself emits changes:SDKLogrenders errors throughcomfyErrorCaseName, never by interpolating the error.Test plan
New
ComfyErrorDescriptionTests(30 tests), mirroring the existingRouterErrordescription tests:URLErrorcarrying a failing URL renders neither the query string nor a raw newline, from both.networkand.unknown— including an explicit non-vacuity assertion that the unrendered reflection does contain the credential and does fit the byte budget, so the test cannot pass for the wrong reason;URLSessionerror with no failing URL still renders its domain and code; a non-NSURLErrorDomainerror still reflects normally;.serverRejected(.other("a\nb" + long))is control-stripped (a.b, replaced not dropped) and length-capped; the four closed reasons render by name;.router(e)output equals"ComfyError.router(\(e.description))"exactly, and contains no idempotency key under eitherdescriptionorString(reflecting:);…; the budget is bytes not characters (1,000 four-byte scalars); a value inside the budget is returned whole and unmarked; sanitizing that shrinks a value does not falsely mark it truncated; the ellipsis is charged to the budget rather than added to it (the bug that made a "512-byte" bound render 515, caught by these tests during development);SubmitErrorBodyandJobExecutionErrorare bounded and stripped when reflected standalone as well as when boxed;Retry-Afterdoes not trap the description;SDKLog.comfyErrorCaseNameemits.Residual
Not fixed by this PR, and each one is a separate, self-contained piece of work:
ServerRejectionReasonstill has noCustomStringConvertibleof its own.ComfyError.descriptionrenders it safely via a private helper, but a consumer who destructures.serverRejected(reason:)and writes"\(reason)"still gets default reflection, which prints.other(_)'s raw server string verbatim and unbounded. Deliberately left alone: the plan scoped the new renderers toComfyErrorandSubmitErrorBody, and adding a public conformance to a second public type is a behaviour change worth deciding on its own. Fix is ~12 lines inComfyError.swift, mirroring the privaterendered(_:)helper this PR already has.RouterError.loggablestill bounds by 512 scalars, not 512 bytes (RouterError.swift:434-441). Because.router(_)delegates,ComfyError.descriptionfor that one case can therefore exceed the 512-byte budget every other case obeys — up to ~4× per field, across several fields. Left alone deliberately: the plan's instruction was to delegate, andRouterError's rendering is shipped and covered by its own tests, so changing it is a separate behaviour change. The two units should probably be reconciled onto the sharedLogSafeTexthelper.The URL redaction is scoped toClosed by review follow-up: the redaction is now keyed on the URL-bearingNSURLErrorDomain.userInfokeys, so a CFNetwork- or POSIX-domain failure carrying the same URL is redacted too. What remains is narrower: a boxed error that carries a credential somewhere other than those keys is bounded and control-stripped but not redacted. I swept everyErrortype the SDK can box into.network/.unknown— 40 construction sites, resolving toSubmitErrorBody,JobExecutionError,EmptyOutputError,OAuthTokenEndpointError,RouterInputSerializationError,WorkflowInputNotSupportedError,URLError, POSIXNSErrorand caughtDecodingErrors — and none of the remainder carries a credential today:OAuthTokenEndpointErroris scrubbed and clamped at construction,RouterInputSerializationErroris a fixed string,WorkflowInputNotSupportedErrorcarries a Swift case name,EmptyOutputErrorhas no payload. A future boxed type could reintroduce the gap, and nothing enforces that it does not.JobExecutionErrorRETAINS its three server strings uncapped.exceptionType/exceptionMessage/nodeTypecome straight off theexecution_errorframe and are bounded at render time only, so a hostile host's multi-megabyteexception_messagestays alive for as long as the consumer holds theComfyError.unknown(underlying:)that boxes it.SubmitErrorBodygot a construction-time cap in this PR and this type did not: the log hazard (forged lines, crowded-out log) is closed either way, what is left is memory retention, and the fix is the same ~10 lines (SubmitErrorBody.messageMaxLength-style clamp in an initializer) applied to a second type. Self-contained follow-up.ServerRejectionReason.other's payload is likewise bounded only at render time. The stored string is uncapped, and it is extracted from the same fully buffered, size-unlimited submit body beforeTransport.checkBodythrows.serverRejected(reason: .other(message))(Sources/ComfySwiftSDK/Internal/Transport.swift:649). Capping inside the enum would change a public associated value, so the placement would be thatTransportthrow site instead — a behaviour change to what a public error payload carries, which deserves its own PR rather than a rider on this one. (This is the retention half; the bullet above aboutServerRejectionReasonhaving noCustomStringConvertibleis the separate rendering half.).unknownsites" was re-derived rather than re-verified site by site: I measured 40.network/.unknownconstruction sites acrossSources/ComfySwiftSDK/. Nothing was run against a live Comfy server — the WebSocket credential leak is demonstrated against a syntheticURLErrorshaped like the oneURLSessionproduces (correct keys, correct domain), not against a realreceive()failure, so the claim that a real failure populatesNSURLErrorFailingURLErrorKeyis inferred fromURLSession's documented behaviour rather than observed. The consuming iOS app is out of scope and untouched.Provenance
swift build: clean, no warnings from changed files;swift test --no-parallel: 402 tests in 33 suites passed, 0 failed (397 after the round-1 follow-up, 394 at open) (two pre-existing Swift 6SendableClosureCaptureswarnings inPollingFallbackTests/JobStatusEndpointMigrationTests, both untouched by this PR);python3 Scripts/contract/check_router_contract.py: OK, 15 error types in sync with the vendored spec.NSURLErrorDomainerrors are rendered from chosen fields rather than a clampedString(describing:), because the prescribed clamp measurably does not remove the credential it was meant to bound; andJobExecutionErrorreceived the same belt-and-bracesdescriptionasSubmitErrorBody, which the plan's audit did not name.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.