fix(swift): project the transport error before it becomes the network cause
The Swift side of #869, which strips the URL from the Rust SDK's reqwest errors; the same audit found nothing to fix in TypeScript, Ruby or Python (undici, Net::HTTP and httpx render no URL in their transport errors, and the download flow's projection from #837 already covers the one place a signed URL enters those paths).
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 put the signature into String(describing:), String(reflecting:), localizedDescription's 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 download flow was already projected at its own layer (projectedDownloadCause, #837), so this closes the layer beneath it.
The fix. One helper, HTTPClient.projectedTransportError, beside the download projection. A URLError is rebuilt from parts this SDK chooses: the same code, so isCancellation and a caller's own URLError matching classify it as before; the localized description; and the failing URL projected through the existing stripQueryAndFragment to origin and path — never its query, userinfo or fragment (SPEC §9's "projection, not redaction"). A transport that already speaks .network (#567) keeps its message and has its cause projected the same way, to the same depth bound isCancellation walks. 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, the terminal .network cause and a propagated URLError(.cancelled) all see the same URL-free shape. fetchPage and the magic-link paths are untouched: they hand the raw transport error to the caller without constructing an SDK error, on an API-origin URL whose query is pagination.
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. Three unit tests cover the transport-spoken .network wrapper, a cancelled URLError keeping its code, and a foreign error passing through by identity. On main the end-to-end test fails with:
the signed query leaked into error reflecting: Basecamp.BasecampError.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 -C swift check (473 tests), make conformance-swift.
Watching the review loop on this one through to convergence.
Summary by cubic
Stops signed query strings from leaking into Swift transport error renderings. URLError carries the failing URL in its userInfo, and the default .network(message:cause:) rendering printed it with the query, so refused connections or timeouts on signed requests exposed the signature in logs, the retry hook's error, and the NSError bridge.
Bug Fixes
- Rebuilds transport errors from the code and the URL with the query stripped — clearing components on
URLComponentsso percent-encoded delimiters keep their encoding — dropping everything else the transport wrote, before anything renders them. - Applies the projection in
performRequest's transport catch and infetchPage, so retry hooks, cause chains, pagination failures, and propagated cancellations all see the same URL-free shape. - Leaves errors that aren't
URLErroror transport-spoken.networkunchanged.