benzene.results

The bottom layer: the Result type and the status vocabulary. Distribution: benzene-results (zero dependencies).

pip install benzene-results

Overview

Every Benzene handler returns a Result — a value describing the outcome, never an exception for an expected domain case (core-concepts §5). A Result carries a status (from the wire vocabulary), an optional payload, and, on failure, structured errors.

Result

A frozen, generic dataclass:

from benzene.results import BenzeneError, ProblemDetails, Result

Result(
    status: str,
    payload=None,
    errors: tuple[BenzeneError, ...] = (),
    problem_document: ProblemDetails | None = None,
    successful: bool | None = None,
)
Member Description
.status a status-vocabulary string (or an application extension)
.payload the success payload (may also be present on failure)
.errors tuple of BenzeneError, populated on failure
.problem_document an application-authored ProblemDetails the wire edge emits verbatim; None unless set by Result.problem
.successful an explicit classification overriding the status-derived one; None (the normal case) means derive it
.messages the error messages alone — tuple(e.message for e in .errors) — for callers that only wanted prose
.is_successful .successful when it is set, otherwise is_successful(.status)

Plain strings are accepted anywhere errors is, and coerced — Result(status, None, ("boom",)) stores a BenzeneError("boom") — so a message-only failure stays a one-liner and the type annotation stays true.

BenzeneError

One structured error (wire-contracts §1.3). message is the only required member; field (the producer's own property path) and code (its machine-readable rule identifier) are emitted verbatim and never normalized or reworded.

BenzeneError(message: str, field: str | None = None, code: str | None = None)

A validator that knows all three can say all three, and they reach the caller intact — the difference between an error a UI can attach to an input and one it can only print.

Success factories

Result.ok(payload=None), Result.created(...), Result.accepted(...), Result.updated(...), Result.deleted(...), Result.ignored(...).

Failure factories

Result.failure(status, *errors) is the general form. Each failure status also has a shortcut: Result.bad_request(*errors), Result.validation_error(...), Result.unauthorized(...), Result.forbidden(...), Result.not_found(...), Result.conflict(...), Result.too_many_requests(...), Result.timeout(...), Result.not_implemented(...), Result.service_unavailable(...), and Result.unexpected_error(...). Each accepts a plain string, a BenzeneError, or the mapping the wire decoder produces.

Result.ok({"id": 1})
Result.created({"id": 2})
Result.not_found("no such order")
Result.failure("conflict", "already exists")   # any status by name
Result.validation_error(BenzeneError("Name must not be empty", field="/name", code="NotEmpty"))

A BenzeneError's field is a JSON Pointer into the request as sent (/name, /items/0/sku — wire-contracts §1.3); benzene.core.json_pointer(["items", 0, "sku"]) builds one. Omit it for a rule that has no single location.

Result.set — an application-defined status

Result.set(status: str, payload=None, successful: bool | None = None)

The one place .successful is set: how an application-defined status says whether it is a success. isSuccessful is what every receiver reads (wire-contracts §1.2), and a status outside the shared vocabulary means nothing to a receiver, so without it such a status reads as a failure to every peer.

Result.set("cache-warm", {"entries": 12}, successful=True)   # an app-defined success

For a status in the vocabulary, isSuccessful MUST equal the status's class — no exceptions — so Result.set("ok", ..., successful=False) or Result.set("service-unavailable", ..., successful=True) raises ValueError rather than putting a contradiction on the wire. (A health report on an unhealthy service is not such a case any more: the health topic answers ok and carries the verdict in its payload's isHealthy; see health checks.)

Result.problem — an application-authored problem document

Result.problem(document: ProblemDetails)

For a service that owns its own problem vocabulary and wants its own type URI to reach the caller. Every other factory derives the document from the status; this one is emitted verbatim, because deriving instead would overwrite the application's type with the registry URI, which is the entire reason for authoring one.

ProblemDetails

ProblemDetails(
    benzene_status: str,
    type: str | None = None,
    title: str | None = None,
    detail: str | None = None,
    instance: str | None = None,
    errors: tuple[BenzeneError, ...] = (),
)

.to_payload() renders it as the wire body, omitting every member it does not carry. RFC 9457's status is deliberately not a member: it is the integer HTTP code, which an application does not author — an HTTP binding sets it to the code it is actually sending, and it is absent on every other transport.

Problem details on the wire (wire-contracts §1.3)

A failure's body is an RFC 9457 problem document. benzene.core.error_payload builds it and benzene.http adds the two things §4.1 requires of an HTTP failure, so a failed response looks like:

{
  "type": "https://benzene.app/problems/validation-error",  // omitted for an app-defined status
  "title": "Validation Error",
  "status": 422,                    // RFC 9457's INTEGER HTTP code — HTTP responses only
  "detail": "Name must not be empty",
  "benzeneStatus": "validation-error",   // the transport-neutral Benzene status, always present
  "errors": [ { "message": "Name must not be empty", "field": "name", "code": "NotEmpty" } ]
}

Two rules are easy to get wrong:

Helpers for reading and building these:

from benzene.results import problem_errors, problem_status, result_with_errors
from benzene.results.problems import problem_http_status, problem_title, problem_type

problem_errors(document)          # a peer's document -> tuple[BenzeneError, ...], in §1.3's precedence
problem_status(document)          # its benzeneStatus when that names a failure, else None (§4.1)
result_with_errors(status, errs)  # a failure Result from a status + an errors sequence (wire decoder)

problem_type("not-found")         # "https://benzene.app/problems/not-found"; None if app-defined
problem_title("not-found")        # the registry title;                       None if app-defined
problem_http_status("not-found")  # 404; 500 for an application-defined status

problem_type and problem_title return None for a status the §3.1 registry does not know: an application-defined failure carries its own URI or omits the member, and the framework has no business inventing one under the benzene.app namespace on the application's behalf.

Status vocabulary

Statuses are lowercase-kebab-case strings, not an enum, so applications can extend them; an unknown status is treated as a failure (wire-contracts §3).

from benzene.results import Status, is_successful

Status.OK              # "ok"
Status.CREATED         # "created"
Status.NOT_FOUND       # "not-found"

is_successful("ok")            # True
is_successful("not-found")     # False
is_successful("app-specific")  # False  (unknown -> failure)

The Status class exposes constants for every framework-defined status. Membership sets are also exported: SUCCESS_STATUSES, FAILURE_STATUSES, and KNOWN_STATUSES.

Note what is_successful is and is not: it classifies a status string, and an unknown one is a failure by that rule. It is not how a receiver decides whether a message succeeded — the envelope's isSuccessful is (§1.2), read through benzene.core.successful_from. A result built with Result.set(..., successful=True) on an application-defined status is a success that is_successful(status) alone would report as a failure.

Success class Failure class
ok, created, accepted, updated, deleted, ignored bad-request, validation-error, unauthorized, forbidden, not-found, conflict, too-many-requests, timeout, not-implemented, service-unavailable, unexpected-error

See also