Give a malformed response body one structural marker in all six SDKs
Closes #750.
"Did the server send a body I could not decode?" had six different answers, one of them a substring test. This gives it one structural answer everywhere — and on the way, finishes #730, which is still live one module over.
It contains a live bug fix, and that is the first commit
#730 added BasecampException.Api.decodeFailure and migrated the three Kotlin composites to it. The Kotlin conformance runner was never migrated — it still read (e as? BasecampException.Api)?.cause as? SerializationException.
The two agree only because Api.malformedBody sets both slots to the same exception. They diverge for exactly the case #730 exists to fix: an AuthStrategy throwing Api(message, cause = SerializationException(...)) through the public constructor gets decodeFailure = null, so the composites pass it through correctly while the runner calls it a malformed mock body. #730's defect outlived #730.
Red proof. Reverting malformedBodyFailure to the verbatim pre-fix expression and running make conformance-runner-tests-kotlin → exit 2, 39 tests completed, 1 failed:
FAIL an auth strategy failure carrying a serialization cause is not a malformed body — expected <null> but was <kotlinx.serialization.SerializationException: Unexpected JSON token at offset 0>
…while a decoder refusal carries the decode failure passed, so the test is not vacuous. After restore: exit 0, 3/3 new tests executed per the JUnit XML.
The Kotlin visibility problem: public read-only val
:conformance is a separate Gradle module and cannot see internal, which is why the runner was reading cause. @PublishedApi internal does not solve it — it lifts an internal declaration into the ABI for public inline functions of the same module and still cannot be named from another module's source. Reflection would work and would be a mechanism the compiler cannot check.
The public getter costs nothing, because the guarantee is a property of the producer: the constructor that fills the slot is private, the factory that reaches it is internal and name-mangled, and both are pinned by ApiConstructorSurfaceTest (unchanged, still passing). It also matches Swift's decodeFailure, which is public in this same PR for the same reason.
One commit beyond the plan, and it is load-bearing
The three Kotlin composites restate the base layer's malformed-body refusal through the public constructor, which leaves decodeFailure null — so the restatement of a malformed body claimed not to be one. That was invisible while the composites were the slot's only readers. Once the runner reads it, a mock body refused by a Documents/Todolists/Schedules composite would reach the assertions instead of triggering "repair the fixture" (#555).
Worse for Swift: malformedBodyMessage did recognise those restatements, because the message contained the phrase. Retiring the phrase without preserving the marker would have been a runner regression. So the internal Kotlin factory now takes the composites' hint, and Swift's three composites pass decodeFailure forward.
No committed fixture reaches those three paths, which is exactly why it would have shipped unnoticed.
The four dynamic SDKs: nearly free, none of it breaking
Go, TypeScript and Ruby all had a cause slot and simply weren't setting it at the refusal sites. Go's documentDecodeError interpolated the decoder error into the message and then dropped it, so errors.As/errors.Is could not reach it.
-
Go — both composite decode refusals set
Cause, soerrors.Asreaches*json.UnmarshalTypeErrorand*json.SyntaxErrorthroughUnwrap. -
Ruby — the paginated-page parse failure carries its
JSON::ParserErrorincause. -
Python —
BasecampErrorgrew acauseproperty over__cause__, which the refusal sites already set. Purely additive; it was the only SDK of the six with nowhere to put it. -
TypeScript — one behaviour change, documented: a followed pagination page whose body is not JSON used to escape as a bare
SyntaxError— no code, no hint, indistinguishable from a bug in the caller's own code. It is now the same statusless, non-retryableapi_errorRuby and Python already raised there, with theSyntaxErrorincause.Review caught this catching too much, and it was a real regression:
response.json()also rejects when the body stream fails — a reset socket or corrupt gzip surfaces asTypeError, an aborted read asAbortError— so transient failures were being stamped non-retryable. Only a syntax failure is classified now; the rest propagate with their own type.The discriminator is
name === "SyntaxError", notinstanceof, and the engines forced that rather than merely permitting it:case Node 22 / undici WebKit (Safari 26.5) body arrived, invalid JSON SyntaxError,instanceoftrueDOMException named "SyntaxError",instanceoffalsesocket reset mid-body TypeError: terminatedTypeErroraborted read DOMException AbortErrorDOMException AbortErrorcross-realm syntax error instanceof SyntaxErrorfalse,instanceof Errorfalse,nametrueidentical So the obvious narrowing would have classified nothing in Safari, and excluding
DOMExceptiondefensively would have broken exactly the engine that produces one. Matching onnameis also the house convention —oauth/device.tsmatchesAbortErrorthat way, its comment citing the same reason. A dedicated test covers Safari's shape, which no Node run can reach, and mutating the check back toinstanceofkills it.
Each site red-proved individually: Go documents / Go schedules (exit 1 each, the other passing), Ruby cause: (exit 1), Python sync / async from e (exit 1 each), TS bare-array / wrapped paginator (exit 1 each, with the failing describe verified to be the mutated one).
Swift: a compile break, taken deliberately
BasecampError.api gains a fifth associated value carrying the decode failure, and a decodeFailure computed property alongside it — nil for every other shape, modelled on fieldErrors from #549 so callers can stop matching the case entirely.
malformedBodyMessage and the repo's only @_spi are gone. The helper's own docstring named the reason it existed: .api had no slot to answer the question structurally. This PR removes that reason. It also deletes the Swift runner's dependence on message text — the contract was the wording of a sentence, so rewording the message moved the answer, and a caller composing their own .api around the phrase forged one.
Precedent, since this is a source break. MIGRATING.md's Class A/Class B plus a breaking label is the whole process here — no CHANGELOG, no semver gate, no API-surface snapshot — and the same move has shipped twice in the last two releases: BasecampError.validation gained a fifth associated value in v0.13.0 (#549), and BasecampError gained case limitExceeded in v0.14.0. #725 declined this on scope, not policy.
Blast radius, all migrated: 17 binding matches (6 Sources/, 11 Tests/) and 15 constructions (12 Sources/, 3 Tests/), plus the swift/README.md example, which now also demonstrates the property. The 5 bare case .api matches survive untouched, verified by name rather than assumed: BasecampError.swift:118, conformance/.../Assertions.swift:35 (the runner's exhaustive switch), RetryTests.swift:637, DownloadTests.swift:221 and :296.
Red proof. testTheMarkerSeparatesADecodeFailureFromThePaginationRefusal asserts both errors are statusless .api, that the decode failure's marker is DecodingError, and that the same-origin refusal's marker is nil — the distinction the substring test could only make by reading English. Mutating the producer (decodeFailure: nil in BaseService.malformedBody) → exit 1, 16 failures including that test. Each of the three composite restatements, mutated on its own, failed exactly its own test and nothing else.
MIGRATING.md
The entry is modelled line-for-line on #549's, down to steering callers at the property so a sixth associated value will not move them again.
It also corrects the #604 entry in the same unreleased section, which states outright that .api gained no slot because adding an associated value would break every switch. That was true when written and is the decision this release reverses; leaving it would have shipped a release note contradicting itself.
Verification
make check at final HEAD → exit 0 — it includes go-check ts-check rb-check kt-check swift-check py-check, every drift gate, the doc gates, conformance and conformance-runner-tests. Individually: kt-check 0 · go-check 0 · rb-check 0 · py-check 0 · ts-check 0 · swift-check 0 (431 tests) · conformance 0 (197 passed, 1 skipped, 198 declared). Post-rebase onto the current main: doc-constants-check 0 and the new check-operation-assignment-parity 0.
One pre-existing environment caveat, not introduced here. LC_ALL=C make conformance fails in conformance/runner/ruby/runner.rb with Encoding::InvalidByteSequenceError reading conformance/tests/*.json — the known unpinned-encoding trap. This branch touches neither conformance/tests/ nor the Ruby runner (0 files). The green runs above used LC_ALL=C.UTF-8. Worth its own gate-hardening issue; not fixed here.
Reported, not fixed here
Go has the same over-wide refusal shape, at vaults.go:508 and schedules.go:814. Go streams the body and io.ReadAll runs inside the generated parse, so unexpected EOF, a reset or a deadline lands in decodeErr and is stamped CodeAPI, statusless, Retryable: false. It predates this PR — only Cause: err is new — and the fix is an errors.As gate at two call sites plus tests, not a one-line narrowing, so it wants its own issue rather than widening this one. The other four SDKs are safe: Ruby, Kotlin, Swift and Python all buffer the body before decoding.
Deliberately not done
The marker is not added to SPEC.md §6. Doing so would make it a normative obligation on all six SDKs, which is a larger call than this PR's remit — flagged rather than decided.