Keep the reasons HEY gives for a 422 on the error, in every SDK
HEY answers a refused write with a 422 and {"errors": [...]}, the model's own full messages (render json: { errors: habit.errors.full_messages }, status: :unprocessable_entity throughout haystack). Every SDK lost that list somewhere:
- Go, which hey-cli and hey-mcp-server use, lost the most. About twenty service methods map their status through
CheckResponse, which never read the body, and by then the generated parser had already drained it anyway. So a refused habit, sticky, todo or time track read just "validation error". The rawPost/Put/Patchpath had no 422 case at all, so a refusal came back as a genericapi_error(exit 7, not 9) reading "Request failed (HTTP 422)". Only the draft, topic-comment and contact wrappers, which readJSON422themselves, showed the reasons. - Rust read "validation error" with an empty hint.
server_messagelooked atmessageanderrorbut nevererrors. - TypeScript, Kotlin and Swift joined the reasons into the message or hint, but kept no list a caller could walk.
The rule, in every SDK
- A 422's error carries field errors: one per entry of the body's
errorslist, in order, each with amessageand thefieldwhen HEY named one.- A string entry, which is what HEY sends today, is a message with no field.
- An object entry
{"field"|"attribute", "message"}carries both. - An entry that is neither is skipped, so one malformed entry doesn't cost the caller the rest.
- With no list, the body's single
errorormessageis the one reason. A missing or non-JSON body, or an empty list, gives none. - The joined text stays where each SDK already put it. Go:
Message, asErrValidationand the draft wrappers already do. Rust, Kotlin, Swift: the hint. TypeScript: the message. An entry with a field readsfield: message. - Every other status is unchanged. A 422 still isn't retried.
| SDK | API added | Also |
|---|---|---|
Go (errors.go, helpers.go, client.go, client-with-responses.tmpl) | FieldError{Field, Message}, (*Error).FieldErrors(). The list sits behind an unexported pointer so Error stays comparable (apidiff) | CheckResponse reads a 422's body (up to MaxErrorBodyBytes) and puts it back for the caller. The generated parsers now hand an error status's body back on HTTPResponse, so CheckResponse sees it after a parse (template change, client regenerated). The raw path maps 422 to CodeValidation. The body cap's refusal uses the new status-only statusError, so it never reads the body it's capping. ErrValidation(msgs...) keeps each message as a field error |
Rust (error.rs, lib.rs, services/contacts.rs) | FieldError { field: Option<String>, message } (#[non_exhaustive]), Error::field_errors(), read from the kept body, so no new field on Error and no growth against result_large_err | server_message falls back to the joined errors list, as Kotlin and Swift already did. The contact-write rewording keeps the body and request id it used to drop |
TypeScript (errors.ts, index.ts) | FieldError type, HeyError.fieldErrors (a new last constructor parameter, defaulted) | Object entries now reach the message too. An errors list with no usable entry falls through to error/message |
Kotlin (HeyException.kt) | data class FieldError(field, message), HeyException.Validation.fieldErrors, read from the kept body. No constructor changes | serverMessage reads object entries |
Swift (HeyError.swift) | struct FieldError (Sendable, Equatable), HeyError.fieldErrors, read from the kept body. No change to ErrorDetail or the enum cases | Same |
Why read from the body rather than store it: Rust, Kotlin and Swift already keep the failure body, up to 1 MiB. Reading the list from that body adds nothing to the error's layout and can't disagree with it. Go keeps no body on *Error, so there the list is stored.
Behavior changes a caller could notice (Go): a raw-path 422 is now CodeValidation (exit 9) where it was CodeAPI (exit 7), and its message is HEY's reasons. A service method's 422 message is HEY's reasons where it was "validation error". Both match what the draft wrappers already did.
Generated vs hand-written: the Go change to the generated code is in the template (go/templates/client-with-responses.tmpl), and client.gen.go is regenerated from it. Everything else is hand-written. No other generator changes.
What HEY sends: haystack's 422s are errors: <model>.errors.full_messages or errors: [message], plus a few {error: "..."} on billing pages. None name a field, so in practice field is empty/absent. The object shape is read because it costs one branch and the brief asked for field and message. I haven't invented field names by splitting full messages.
Tests: each fails without the fix
For each SDK I disabled the new parsing and confirmed the new tests fail. The "only a 422" and "SDK-raised" guards pass either way, as they should.
- Go:
validation_errors_test.go.Habits().Createagainst a real 422 body is theCheckResponse-after-parse path. With the template change reverted it fails onFieldErrors().- Raw
Postfails without the new case. CheckResponsecovers every body shape and leaves the body readable.- The message bound,
ErrValidation, and a compile-time check thatErrorstays comparable.
- Rust:
tests/validation_errors.rs.create_habit, rawpost, andcontacts().set_note(wiremock) keep the reasons and request id, plus every body shape. 5 of 6 fail withfield_errorsstubbed empty. - TypeScript:
client.test.ts, "keeps each reason a 422 gives as a field error". It fails witherrors.tsreverted. - Kotlin:
ValidationErrorTest.kt. 4 of 5 fail withfieldErrorsstubbed empty. - Swift:
ValidationErrorTests.swift. 4 of 5 fail withfieldErrorsstubbed empty. - Conformance: a new
errorFieldpath,fieldErrors, in all five runners and the TypeScript contract, which now allows a list-valuederrorField. A new case inerror-mapping.jsonsends CreateHabit's real 422 body and checks the code, status, request id and both reasons. 218/218 in Go, Rust, Kotlin and Swift; TypeScript 214 passed, 4 not applicable.
Local: make go-check go-check-drift rs-check rs-check-drift kt-check swift-check, the TS typecheck and suite, and all five conformance runners. Two TS tests timed out locally under load (release-workflow, version-scripts). This change doesn't touch them; CI decides.
Summary by cubic
HEY answers a refused write with a 422 and {"errors": [...]}, the model's own full messages, but every SDK dropped or flattened that list: Go answered "validation error" (the raw path a generic api_error), Rust read "validation error", and TypeScript, Kotlin, and Swift joined the reasons into a message or hint with no list a caller could walk.
Each SDK now carries the reasons as field errors, in order, each with a message and the field when HEY names one: Go (*Error).FieldErrors(), Rust Error::field_errors(), TypeScript HeyError.fieldErrors, Kotlin HeyException.Validation.fieldErrors, Swift HeyError.fieldErrors. A string entry is a message without a field; an object entry {"field"|"attribute", "message"} carries both; other entries are skipped. With no list, the body's single error or message is the one reason. A 422's hint is built from the same field errors, so the two always agree.
Go callers could notice a raw-path 422 is now CodeValidation (exit 9) where it was CodeAPI (exit 7). Go's contact, topic-comment, and draft wrappers read the typed JSON422 payload, which turned single messages or object entries into an empty list; they now map through CheckResponse like everything else. Rust, Kotlin, and Swift read the list from the kept failure body, so their error layouts don't change; Go stores it behind an unexported pointer so Error stays comparable. The only generated-code change is the Go template returning an error status's body on HTTPResponse; the client is regenerated from it. Two edge cases now read the same in every SDK: Go keeps the message of an object entry whose field isn't a string, and TypeScript no longer reads an errors member that's a plain string as the reason. Each SDK has new tests that fail without the fix, plus a conformance case tying all five to the same 422 body.
Bug fixes
- Go's
truncateStringcut at a byte count, so an error message past 500 bytes of multibyte text could end in half a character; the cut now backs up to a rune boundary.