Message results

A Benzene handler returns its outcome as a value, not by throwing. That value is a benzene.Result[T] — a status, an optional payload, and any error messages. A transport binding translates the status into that transport's native failure signal (an HTTP code, a gRPC code, a queue ack/nack); the handler never names one. This page is the reference for that type and the status vocabulary in Go.

The concepts here are language-neutral and defined once for every port on the website — see Wire contracts for the canonical status vocabulary and the per-protocol mapping tables. This page shows the Go shape.

The one idea

A handler is a func(context.Context, TReq) benzene.Result[TRes]. Whatever happens — success, a client mistake, a missing record, a downstream outage — you return it:

func getOrder(_ context.Context, req getOrderRequest) benzene.Result[orderResponse] {
	order, ok := store.Lookup(req.ID)
	if !ok {
		return benzene.NotFound[orderResponse]("order " + req.ID + " not found")
	}
	return benzene.Ok(orderResponse{Order: order})
}

benzene.Ok(...) carries the payload; benzene.NotFound[...](...) carries error messages. Both are ordinary values of the same type, benzene.Result[orderResponse]. The binding decides what a not-found status means on the wire — the handler stays transport-agnostic.

Result[T]

Defined in result.go:

type Result[T any] struct {
	Status  Status  // the Benzene status (see the vocabulary below)
	Payload *T      // present on success; a pointer so "absent" is representable
	Errors  []Error // structured errors, populated on failure
	// unexported: the failure flag Fail/FailWith set and an authored problem document
	// (ProblemResult)
}

The three exported fields are the result's data. Payload is a pointer so that "no payload" is distinct from T's own zero value — on a failure it is nil.

Error carries a Message and, when the producer knows them, the Field the value came from and the Code of the rule that rejected it:

type Error struct {
	Message string
	Field   string
	Code    string
}

It is an alias of wire.ProblemError, so the value a handler builds is the value that reaches the wire — there is no second shape to keep in step. The plain-string constructors (Fail, NotFound, …) fill in Message only; FailWith and ValidationErrorWith take Error values.

Reading a result

r := getOrder(ctx, req)

r.Status                 // benzene.Status, e.g. benzene.StatusOk
r.IsSuccessful()         // bool — see the classification rule below
r.Errors                 // []benzene.Error, populated on failure
r.ResultErrors()         // []string — just the messages, for code that only wants prose
if r.Payload != nil {    // nil on failure; check before dereferencing
	use(*r.Payload)
}

IsSuccessful() derives from the status class unless the result was built by Fail/FailWith/ ProblemResult, which are always unsuccessful: it returns !r.Status.IsFailure(). So a framework success status and a plain application-defined status both count as successful and carry their payload, while a framework failure status - and an application-defined one built with Fail - does not; that is the extensibility promise that custom statuses flow through untouched (see success is the status's class).

The type-erased view: ResultInfo

The registry stores handlers behind a non-generic dispatch signature, so the pipeline and transport bindings recover a result's parts through an interface rather than the concrete Result[T] (whose T they cannot name). Every Result[T] implements it:

type ResultInfo interface {
	ResultStatus() Status
	ResultErrors() []string
	ResultPayload() any // the payload as any, or nil if absent
}

ResultErrors() deliberately still returns []string: every binding that only ever wanted messages is unaffected by structured errors existing.

Three optional interfaces sit alongside it, each checked with a type assertion so that adding one never breaks an external implementation of ResultInfo:

Interface Method What it recovers
— ResultIsSuccessful() bool an application-defined failure status (Fail) surviving type erasure as a failure
ProblemInfo ResultProblems() []Error the structured errors, so a field and a code are not flattened to prose
ProblemDocumentInfo ResultProblemDocument() *Problem an application-authored problem document (ProblemResult), emitted verbatim

ProblemsOf(result) is the helper every rebuild-a-typed-result site should use: it takes the ProblemInfo view when there is one and wraps the messages when there is not, so no caller writes its own assertion and quietly drops the structure.

Constructors

Every constructor lives in result.go. The success constructors take a payload; the failure constructors take variadic error strings (errors ...string).

Success — carry a payload

Constructor Signature Status
Ok Ok[T any](payload T) Result[T] ok
Created Created[T any](payload T) Result[T] created
Accepted Accepted[T any](payload T) Result[T] accepted
Updated Updated[T any](payload T) Result[T] updated
Deleted Deleted[T any](payload T) Result[T] deleted
Ignored Ignored[T any](payload T) Result[T] ignored — handled deliberately, not an error

Ok infers T from its argument (benzene.Ok(orderResponse{...})); the rest do too.

Failure — carry error messages

Constructor Signature Status
BadRequest BadRequest[T any](errors ...string) Result[T] bad-request
ValidationError ValidationError[T any](errors ...string) Result[T] validation-error
Unauthorized Unauthorized[T any](errors ...string) Result[T] unauthorized
Forbidden Forbidden[T any](errors ...string) Result[T] forbidden
NotFound NotFound[T any](errors ...string) Result[T] not-found
Conflict Conflict[T any](errors ...string) Result[T] conflict
TooManyRequests TooManyRequests[T any](errors ...string) Result[T] too-many-requests — throttled; transient, retry after backoff
Timeout Timeout[T any](errors ...string) Result[T] timeout — downstream deadline elapsed; outcome unknown
NotImplemented NotImplemented[T any](errors ...string) Result[T] not-implemented
ServiceUnavailable ServiceUnavailable[T any](errors ...string) Result[T] service-unavailable
UnexpectedError UnexpectedError[T any](errors ...string) Result[T] unexpected-error

The failure constructors need an explicit type argument, because T can't be inferred from the error strings: benzene.NotFound[orderResponse]("..."). A failure result has a nil payload and IsSuccessful() == false.

Fail — the errors-based failure constructor

Every failure constructor above delegates to Fail, which is public for raising a failure with any status — including an application-defined one:

func Fail[T any](status Status, errors ...string) Result[T]

Because it takes error strings, a Fail result is always unsuccessful, even for a custom status that IsFailure does not recognise — which is what makes a custom failure status nack/redeliver on a queue and render its errors rather than being mistaken for a success. Fail panics if given a framework success-class status, since that would produce a self-contradictory result.

Success is the status's class — no exceptions

A result's IsSuccessful is what reaches the wire as the envelope's isSuccessful, and for a status in the vocabulary it always equals that status's class (wire-contracts §1.2): there is no way to build a successful service-unavailable or a failed ok. For an application-defined status it is the only signal a receiver has: Fail/FailWith/ProblemResult build an unsuccessful one, a plain Result[T]{Status: "partly-done", Payload: &p} a successful one. (SetResult, which decoupled the two for the old 503-health shape, is gone: a health check now returns ok with isHealthy in its report, and the HTTP health route maps isHealthy: false to 503.)

The status vocabulary

Status (in status.go) is a wire-level string, not a closed enum, so applications can extend it. The framework-defined values are held verbatim to the spec's case-sensitive lowercase-kebab-case wire contract:

type Status string

const (
	StatusOk                 Status = "ok"
	StatusCreated            Status = "created"
	StatusAccepted           Status = "accepted"
	StatusUpdated            Status = "updated"
	StatusDeleted            Status = "deleted"
	StatusIgnored            Status = "ignored"
	StatusBadRequest         Status = "bad-request"
	StatusValidationError    Status = "validation-error"
	StatusUnauthorized       Status = "unauthorized"
	StatusForbidden          Status = "forbidden"
	StatusNotFound           Status = "not-found"
	StatusConflict           Status = "conflict"
	StatusTooManyRequests    Status = "too-many-requests"
	StatusTimeout            Status = "timeout"
	StatusNotImplemented     Status = "not-implemented"
	StatusServiceUnavailable Status = "service-unavailable"
	StatusUnexpectedError    Status = "unexpected-error"
)

Three classifiers report which class a status belongs to:

Transport mapping

A status is protocol-neutral; each binding maps it to a native code. These mapping packages are zero-dependency and implement the spec's tables directly.

HTTP — httpstatus

httpstatus.ToHTTP(status benzene.Status, isSuccessful ...bool) int implements wire-contracts §4.1. An unrecognized, application-defined, or empty status (and unexpected-error) maps to 500 — unless the result was explicitly marked successful (the optional isSuccessful argument), in which case an unknown status maps to 200 rather than misreporting a success as a server error:

Benzene status HTTP code
ok, ignored 200
created 201
accepted 202
updated, deleted 204
bad-request 400
unauthorized 401
forbidden 403
not-found 404
conflict 409
validation-error 422
too-many-requests 429
not-implemented 501
service-unavailable 503
timeout 504
unexpected-error, unknown, empty 500

The package also provides httpstatus.FromHTTP(code int) benzene.Status for the reverse direction — used by an HTTP outbound client reading a response.

gRPC — grpcstatus

grpcstatus.ToGRPC(status benzene.Status, isSuccessful ...bool) int implements wire-contracts §4.2. Codes are the raw numeric gRPC status codes (a gRPC binding wraps the result as codes.Code(grpcstatus.ToGRPC(...))). All success-class statuses collapse to OK (0); an unrecognized or empty status (and unexpected-error) maps to Internal (13), with the same explicit-success exception as HTTP (an unknown status on a result marked successful maps to OK):

Benzene status gRPC code
ok, ignored, created, accepted, updated, deleted OK (0)
bad-request, validation-error InvalidArgument (3)
unauthorized Unauthenticated (16)
forbidden PermissionDenied (7)
not-found NotFound (5)
conflict AlreadyExists (6)
too-many-requests ResourceExhausted (8)
timeout DeadlineExceeded (4)
not-implemented Unimplemented (12)
service-unavailable Unavailable (14)
unexpected-error, unknown, empty Internal (13)

grpcstatus.FromGRPC(code int) benzene.Status maps back — used by a gRPC outbound client when no benzene-status trailer is present (a trailer, when present, wins verbatim).

See also