Project the Swift transport error before it becomes the network cause
The Swift side of #174 (Rust) and #175 (Go); the same audit found nothing to fix in TypeScript or Ruby, where undici and Net::HTTP render no URL in their transport errors.
The leak. URLSession reports every transport failure as a URLError whose userInfo carries the failing URL — NSURLErrorFailingURLErrorKey, its string twin, and the session task's own description, which prints the request line — and Swift's default rendering of .network(message:cause:) prints the whole of it, query included. So a refused connection, timeout or TLS failure on a request whose query is signed — an Active Storage direct-upload or blob URL, where the query is the credential — put the signature into String(describing:), String(reflecting:), the NSError bridge and its userInfo, the cause chain, and the onRetry hook's error argument: everywhere the error gets logged. performRequest is the generic path every operation goes through.
The fix. One helper, HTTPClient.projectedTransportError. A URLError is rebuilt from parts this SDK chooses: the same code, so a caller's own URLError matching classifies it as before; the localized description; and the failing URL projected to origin and path — never its query, userinfo or fragment, following basecamp-sdk SPEC §9's "projection, not redaction" since this repo has no spec of its own to say otherwise. A transport that already speaks .network keeps its message and has its cause projected the same way, to a bounded depth. Any other error is the transport's own diagnostic and passes through unchanged. The one site projects once, at the top of the transport catch in performRequest, so the retry hook and the terminal .network cause see the same URL-free shape; a FizzyError thrown by a custom transport goes through the same projection on its way out. fetchPage and the magic-link flow are untouched: they hand the raw transport error to the caller without constructing an SDK error, on a fixed API-origin URL that carries no query (the magic-link token rides in the body).
The test. TransportErrorProjectionTests dials 127.0.0.1:1 through the shipped transport with ?signature=SECRETVALUE under a two-attempt policy and asserts SECRETVALUE is absent from String(describing:), String(reflecting:), localizedDescription, the NSError description and userInfo, every link of the .network cause chain and the NSUnderlyingError chain, and the onRetry error; that the cause is still a URLError(.cannotConnectToHost) whose failingURL is http://127.0.0.1:1/blob; and that the rendering keeps the origin and path. Two unit tests cover the transport-spoken .network wrapper and a foreign error passing through by identity. On main the end-to-end test fails with:
the signed query leaked into error reflecting: Fizzy.FizzyError.network(message: "Network error", cause: Optional(Error Domain=NSURLErrorDomain Code=-1004 "Could not connect to the server." UserInfo={… _NSURLErrorFailingURLSessionTaskErrorKey=LocalDataTask <…> {{ GET http://127.0.0.1:1/blob?signature=SECRETVALUE }} …
Gates run locally: make swift-check (40 tests). There is no Swift conformance runner in this repo (make conformance-swift reports SKIP).
Watching the review loop on this one through to convergence.
Summary by cubic
Fixes a credential leak in Swift's transport error reporting: URLSession failures embed the failing URL, including signed query parameters, in URLError's userInfo, and .network(message:cause:) prints them, so signatures appeared in logs, NSError descriptions, cause chains, and retry hooks. The error is now projected to origin and path before it reaches any caller.
Changes
HTTPClient.projectedTransportErrorrebuildsURLErrors keeping only the same code and a URL stripped of query, userinfo, and fragment; the transport's own description no longer survives, and URLComponents clearing keeps percent-encoded host delimiters encoded instead of re-parsing them.- Applied once at the top of the transport catch in
performRequestand tofetchPagefollow-up-page failures, so the retry hook, the terminal.networkcause, and page-fetch callers see the same URL-free shape. - Transport-spoken
.networkcauses are projected recursively, bounded to avoid cycles. - Non-
URLErrorerrors pass through unchanged. - Tests assert no secret appears in any rendering, including cause and underlying chains, and that the failing URL keeps origin and path.