Report a raw write whose 2xx body breaks as the success it was

Merged
JE
8d ago
+964-26

The raw write helpers in every SDK had the same bug. When HEY answered a success and the body then broke, the helper reported a failed request. That happens when the connection drops mid-body or the body runs past the size limit. By then the mutation had already taken effect. hey-cli and hey-mcp-server send mail through Go's raw Client.Post/Put, so a delivered message looked failed, and the caller would send it again.

ADR-005 already handles this case for the modelled delivery operations, via @heyLenientSuccess. That trait is per operation, though, and a raw request has no operation to carry it.

The rule, in every SDK that has raw requests

Applies to a raw request whose method isn't GET (or HEAD), answered 2xx, whose body can't be read in full (lost mid-read, or past the response body limit):

  • The call succeeds, with an empty body and the read failure attached.
  • It is sent once and never resent.
  • Decoding the body (UnmarshalData, json) returns the attached failure rather than a decode error.
  • Calls that only need the write to have landed succeed: execute, the raw helpers, send_unit/sendUnit/sendVoid, and the form calls.
  • Calls that ask for the decoded body (send<T>, send_text/sendText) return the attached failure, because that body never arrived.

Unchanged:

  • A raw GET whose body breaks is still an error. It changed nothing, so fetching again is safe, whereas an empty success would read as an empty resource.
  • A read the caller's own cancellation ends is still an error, so a cancelled send is never reported as delivered. This matches the Go delivery wrappers' existing readSentMessage.
  • Modelled routes are unchanged, and ADR-005 stays opt-in per operation. Making every typed write lenient is a separate decision. For a write whose output has required members there is no empty value to return, which is why CreateBulkReply doesn't carry the trait.

Why an attached error and not an error return

Every SDK's error model treats a returned or thrown error as "the call failed". Every caller checks for it first, and some resend on a retryable network error. A success carrying the failure is the only shape that both tells the truth (it landed, but we couldn't read the answer) and doesn't invite a resend.

SDKAPI addedAlso
Go (go/pkg/hey/client.go)Response.BodyErr and FormResponse.BodyErr, always a *BodyReadError{StatusCode, Err}. A pointer keeps FormResponse comparable. errors.Is reaches ErrResponseTooLargeLogs a Warn with method, status and reason. No error text, since a transport error can quote the URL
Rust (client.rs, error.rs)Response::body_error() -> Option<&Error>, backed by a private, unwind-safe wrapper, so Response keeps UnwindSafe/RefUnwindSafe and cargo-semver-checks reports no semver update requiredA trace::warning! with operation, status and reason
Kotlin (Response.kt, HeyClient.kt, Form.kt)Response.bodyError and FormResponse.bodyError: HeyException?, defaulted, with internal constructorsAlso fixes a resend: the body was read inside the retry loop, so a raw PUT or DELETE whose body broke was sent again
Swift (Response.swift, HeyClient.swift)Response.bodyError: HeyError?, defaultedSame resend fix as Kotlin

TypeScript has no raw request API: HttpTransport isn't exported and only generated operations go through it. Nothing changes there.

Generated vs hand-written: every helper changed here is hand-written. Go's raw helpers live in client.go. Rust, Kotlin and Swift have raw operations (Operation::raw, Operation.raw, HeyOperation.raw) that run through each client's shared hand-written finish and receive path. No generator emits them, so no generator changes. Go's generated typed operations read bodies in go/templates/client-with-responses.tmpl. They keep ADR-005's per-operation rule and are unchanged.

ADR-005 gains a "Raw requests" section that records all of this.

Tests: each fails without the fix

For each SDK I disabled the new behavior and confirmed the success-path tests fail. The GET and cancellation guards pass either way, as they should.

  • Go: unread_success_test.go. A real httptest server sends 200/201 with Content-Length: 1000, writes part of the JSON, flushes, and aborts the connection (http.ErrAbortHandler).
    • Calls covered: Post, PostMutation, Put, Patch and Delete with WithMaxRetries(2), plus PostForm and PostMultipart.
    • Each succeeds with BodyErr set and exactly one request.
    • A body past the limit is a success that wraps ErrResponseTooLarge.
    • GET is still an error, and the caller's cancellation is still context.Canceled.
  • Rust: tests/raw_unread_success.rs. A real TcpListener sends 200 with Content-Length: 1000, writes 22 bytes and closes. The tests cover POST and PUT, send_unit/send/send_text, GET, and the size cap.
  • Kotlin: RawWriteBodyTest.kt. A MockEngine body whose producer sends part of the JSON, waits until the read is under way, then fails with an IOException. On some runners a channel cancelled before the read began came through as merely closed, which is why the first CI run flaked. 5 of 5 local runs pass with the fix and 4 of 6 tests fail without it. The tests cover POST, PUT, PATCH and DELETE at 200 and 201 with exactly one request each, plus forms, GET, the size cap, and a body that reads fine.
  • Swift: UnreadSuccessTests.swift, using MockHey.interrupted with URLError(.networkConnectionLost). The tests cover POST, PUT, PATCH and DELETE at 200 and 201 with exactly one request each, plus sendVoid/send, json, the size cap, GET and cancellation. The Swift suite has no local socket server, so this one is transport-stub level, not on the wire.

Also in this PR

4d11392 bumps rustls from 0.23.45 to 0.23.46 in both Rust lockfiles. It fixes RUSTSEC-2026-0346 through -0349, which fail cargo deny on main and would otherwise keep this PR red. The fizzy-sdk counterpart is basecamp/fizzy-sdk#213.

Verified locally

These targets passed:

  • make go-check go-check-drift
  • rs-check and rs-check-drift (inside make check)
  • make kt-check kt-check-drift swift-check swift-check-drift sync-api-version-check provenance-check conformance-mvp, with 217/217 conformance cases
  • make -C rust deny

TypeScript not verified locally: ts-check failed on three release-script tests that call MAKE=/bin/true, which doesn't exist on macOS. That's environmental, and no TypeScript changed. CI covers it.

Known limits

  • send<T> on a raw write whose body broke still returns an error, even though the write landed. A caller that needs only the write to land should use execute or the raw helpers.
  • Rust's and Swift's FormResponse derive Eq/Equatable, which their error types don't implement, so a raw form there returns an empty body without the failure attached. Rust still logs its trace warning.
  • A 2xx status other than 200, 201 or 204 (for example 202) is still a failure in Go's singleRequest. The spec models none, so this PR leaves it alone.

Pre-existing CI failures this PR leaves alone

  • Trivy (Go SDK) also fails on main (e04513e). It flags kin-openapi v0.146.0 (fixed in 0.149.0) and golang.org/x/net v0.59.0 (fixed in 0.60.0). Moving kin-openapi to 0.149.0 changes oapi-codegen output: Recording.Parent goes from *Recording to Recording, a recursive value type. That needs its own PR, so I left it out of this one.
  • GitHub Actions audit (zizmor) reports ref-version-mismatch on dtolnay/rust-toolchain@7e38f4b… # v1 in test.yml at lines 138 and 172. Upstream has moved the v1 tag, so this fails no matter what a PR changes. It's a CI-workflow edit, so it's flagged for a human. The Dependabot actions PR #254 may already cover it.
  • Kotlin on the real JVM engine: while writing a wire test I found that Ktor CIO hands back a body cut short of its Content-Length as a short body, with no error, for GET and POST alike. On CIO a raw write's execute therefore already "succeeds", with a truncated body. This PR's Kotlin change takes effect on engines that report the loss. Detecting truncation against Content-Length would be a separate change, with its own risk on engines that decompress transparently.