Report a raw write whose 2xx body breaks as the success it was
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
CreateBulkReplydoesn'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.
| SDK | API added | Also |
|---|---|---|
Go (go/pkg/hey/client.go) | Response.BodyErr and FormResponse.BodyErr, always a *BodyReadError{StatusCode, Err}. A pointer keeps FormResponse comparable. errors.Is reaches ErrResponseTooLarge | Logs 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 required | A trace::warning! with operation, status and reason |
Kotlin (Response.kt, HeyClient.kt, Form.kt) | Response.bodyError and FormResponse.bodyError: HeyException?, defaulted, with internal constructors | Also 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?, defaulted | Same 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 realhttptestserver sends 200/201 withContent-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
BodyErrset 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.
- Calls covered: Post, PostMutation, Put, Patch and Delete with
- Rust:
tests/raw_unread_success.rs. A realTcpListenersends 200 withContent-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 anIOException. 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, usingMockHey.interruptedwithURLError(.networkConnectionLost). The tests cover POST, PUT, PATCH and DELETE at 200 and 201 with exactly one request each, plussendVoid/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-driftrs-checkandrs-check-drift(insidemake check)make kt-check kt-check-drift swift-check swift-check-drift sync-api-version-check provenance-check conformance-mvp, with 217/217 conformance casesmake -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 useexecuteor the raw helpers.- Rust's and Swift's
FormResponsederiveEq/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.Parentgoes from*RecordingtoRecording, a recursive value type. That needs its own PR, so I left it out of this one. - GitHub Actions audit (zizmor) reports
ref-version-mismatchondtolnay/rust-toolchain@7e38f4b… # v1intest.ymlat lines 138 and 172. Upstream has moved thev1tag, 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-Lengthas a short body, with no error, for GET and POST alike. On CIO a raw write'sexecutetherefore already "succeeds", with a truncated body. This PR's Kotlin change takes effect on engines that report the loss. Detecting truncation againstContent-Lengthwould be a separate change, with its own risk on engines that decompress transparently.