SDKs conform to SPEC §9: credential-bearing values are never rendered

Merged
JE
2mo ago
+2319-177

Follow-up to #802, which merged the credential-scoped §9 rule. This PR brings all six SDKs into conformance and closes the two adjacent §6 gaps found while verifying the caps.

Closes #788.

§9 conformance

Download transport errors — fixed message, no raw cause in the generic chain. The transport's rendering of a signed/rewritten URL was the leak (Download failed: #{e.message}, Network error: ${cause.message}, failed to download file: %w, ErrNetwork's Hint: cause.Error()). Hop-1 and hop-2 network failures now carry fixed messages, and the transport error is never chained. Where a classifier legitimately reads through the chain, a peer-free sentinel is projected in its place, exactly as §9 prescribes:

  • Go chains the bare context.Canceled/context.DeadlineExceeded — shouldTripCircuit and callers' errors.Is still classify (tested).
  • Swift chains a fresh CancellationError()/URLError(.cancelled) — isCancellation still matches, and the fresh sentinel carries no failing-URL rendering (tested).
  • Kotlin and TypeScript rethrow cancellation raw before the wrap, so their chains are simply severed.

Pre-dial signed-URL validation — origin only / unparsable. Ruby's redirect to undialable download URL: #{Security.truncate(url)} (the cap kept a short query intact) and Swift's two download Invalid URL: \(url) guards now render the parsed origin, or the fixed token unparsable.

Python raising boundary. from None suppresses the rendering but leaves the JSONDecodeError — whose .doc retains the whole credential-bearing body — in __context__. The credential sites (auth.py/async_auth.py token refresh, oauth/exchange.py, oauth/device.py ×2, download.py hop 2) now construct inside the except and raise after it. Tests assert __context__ is None on the raised exception, the trap §9 documents.

Hop-1 hook URL — origin+path only. downloadURL accepts any absolute URL and §14 step 2 preserves its query through the origin rewrite, so a signed storage URL smuggles its signature into hop 1. All six SDKs now project the hop-1 request-hook URL (start/end/retry — and Go's debug log line) to origin+path; the wire request keeps the query, and tests pin both halves. API-path hooks are untouched — those URLs carry no credential (§9), and Go gates the projection on a download-marked context so the shared loggingTransport contract is unchanged for API requests.

§6 gaps

Step 5 — the fixed code-bearing phrase. Request failed (HTTP {status}) replaces response.statusText (TypeScript), status.description (Kotlin, parameter removed), resp.Status (Go checkResponse default arm) and localizedString(forStatusCode:) (Swift). The wire reason phrase does not exist under HTTP/2 and is blank for an unregistered code — the 599 case, which used to yield a blank message, is now tested in every SDK that changed. Ruby, Python and Go's raw client path already rendered it.

Step 3 — error_description → hint. Ruby and Python never extracted it on the API path (only their OAuth paths did). Both now do, truncated per §9, threaded through error_from_response/handle_error; Ruby's class-constant hints (AuthError, ForbiddenError) fill in only when the body carries none, and a concrete Retry-After still wins on 429.

Verification

  • All six SDK suites green locally (Go, TypeScript, Ruby, Python, Kotlin JVM); full make check run on Linux.
  • Self-check: rg finds none of the old interpolations (Download failed: #{, Network error: ${, failed to download file: %w, Invalid URL: \( at the download sites, statusText ||, status.description, API error: %s, localizedString(forStatusCode:)).

Out of scope

  • The open #802 thread on rendering OAuth error_description (spec renders it; reasons stated in §9).
  • §23 connector work — #777's stack owns it; under merged §9 the dial failure is that stack's only credential site and its dialFailure already covers it.
  • Go's ErrNetwork at ordinary API sites (no credential in those URLs, per §9).

Summary by cubic

Brings all six SDKs into conformance with SPEC §9 — credential-bearing values are never rendered — and closes two adjacent §6 gaps found while verifying the caps. Also aligns §12's on_paginate prose with its interface comment (Ruby and Python). Closes #788.

§9 conformance

  • Download transport errors carry fixed messages — "Network error" at hop 1 in all six SDKs, "Download failed" at hop 2 — and never chain the raw transport error, whose rendering is the signed URL. Swift projects URL-free cancellation sentinels (Go chains bare context.Canceled/DeadlineExceeded, TypeScript a fresh DOMException with fixed text) so classifiers still read identity; Ruby severs both stored and MRI-implicit causes; hop-1 hooks see the same projection.
  • Pre-dial signed-URL validation renders the parsed origin, or the fixed token unparsable when no complete origin exists — Ruby and Swift for the validation guards, Ruby and TypeScript for an unparsable or non-HTTP(S) hop-2 Location, Python for httpx.InvalidURL, and every hook-URL projection that lacks a complete origin.
  • Python credential sites construct the error inside the except and raise after it, so the raised exception retains neither __cause__ nor __context__ (which would carry the body via JSONDecodeError.doc).
  • Hop-1 download hook URLs render as origin+path only, dropping userinfo, query, and fragment; Kotlin and Swift rebuild from a parse so IPv6 hosts and non-default ports survive. The wire request keeps the query, and API-path hooks are untouched.

§6 gaps

  • The fallback message is the fixed Request failed (HTTP {status}) in Go, TypeScript, Kotlin, and Swift — never the wire reason phrase, which is absent under HTTP/2 and blank for an unregistered code like 599.
  • Ruby and Python now extract error_description as the hint on the API path, truncated per §9; class-constant hints fill in only when absent, and Retry-After still wins on 429.