Skip to content

Dual-Consumer Error Output

This document explains the error-output architecture that rust_template ships as a foundation for adopters: a single binary that serves two audiences from one error value. It covers why the architecture exists, the envelope fields, the applicability markers, the type-URI versioning policy, and how the dual renderer selects a format. For the source-level error conventions (thiserror, the Result alias, ? propagation), see the Error Handling reference.

Why error output is a dual-consumer problem

Section titled “Why error output is a dual-consumer problem”

A command-line tool now answers to two distinct consumers:

  1. The human who ran the command and reads the terminal.
  2. The LLM agent that orchestrated the command, parses the bytes, and decides whether to retry, escalate, or abandon.

Most CLIs serve the first consumer well and the second poorly. That imbalance is not a polish problem — it is a cost, reliability, and convergence problem. A verbose traceback shipped into an agent’s tool_result burns tokens (a 5 KB traceback is roughly 1,600 tokens; a structured envelope cuts that by about 75 %). A transient error without a retry directive causes agents to abandon recoverable work. An unmarked “suggested fix” invites agents to apply plausible-looking but wrong edits.

The architecture’s response is the model from RFC 9457 Problem Details: the human keeps the lush Display line unchanged, and the agent gets a structured application/problem+json envelope carrying the standard members plus three agent-facing extensions. The two renderings come from one Error value, so they can never drift.

ProblemDetails is a serializable struct. It is hand-rolled on serde (no heavyweight diagnostic framework), keeping the dependency surface to serde and serde_json, both of which pass cargo deny check.

MemberTypeMeaning
typeURI stringStable, versioned URI identifying the problem type.
titlestringShort human-readable summary. Stable per type.
statusnumberNumeric status class (mirrored to the process exit_code).
detailstringExplanation specific to this occurrence — the Display line.
instanceURI stringurn: reference identifying this specific occurrence.

detail is the error’s Display string, so the human rendering and the machine detail are the same text by construction.

ExtensionTypeMeaning
retry_afternumber | nullDelta-seconds before a safe retry. Explicitly null on non-transient errors so an agent never has to guess.
suggested_fixobject | nullA recovery action with a description and an applicability marker.
code_actionsarrayStructured edits modeled on the LSP CodeAction interface.

retry_after is serialized even when absent (as JSON null). Both error variants the template ships today are non-transient, so they carry retry_after: null. An adopter modeling a rate-limit class sets it to a positive number with ProblemDetails::with_retry_after.

ExtensionTypeMeaning
exit_codenumberThe process exit code emitted alongside the error.

Every suggested_fix and every code_actions[] entry carries an Applicability marker, modeled on the rustc diagnostic precedent. Without it, an agent cannot tell a safe auto-applicable edit from a guess.

MarkerAgent contract
machine_applicableApply the edit and retry without human confirmation.
maybe_incorrectEscalate to a human before applying.
has_placeholdersThe fix has slots the agent must fill; lower confidence.
unspecifiedApplicability unknown; consumers treat it as maybe_incorrect.

unspecified is the default and the safe floor: a missing or unknown marker is never treated as auto-applicable.

Each Error variant maps to a distinct, version-embedded type URI:

VariantType URI
InvalidInputhttps://zircote.com/rust-template/errors/invalid-input/v1
OperationFailedhttps://zircote.com/rust-template/errors/operation-failed/v1

The policy is a commitment: the meaning of a given URI never changes. The /v1 segment is the version, carried per problem type so one type can advance to /v2 without disturbing the others. A breaking change to a type’s semantics ships a new version rather than redefining the existing one. Agents that key behavior off a type URI can therefore rely on it across releases.

Each URI is dereferenceable: it resolves to a live problem-type reference page (the documentation registry lives at https://zircote.com/rust-template/errors/). Those per-type pages are the source of truth for a type’s status, recovery action, and stability — see the Errors reference.

Configurable for adopters. Because this is a template, the URI host is not hardcoded across the code. A single constant, ERROR_TYPE_BASE_URI, holds the base (https://zircote.com/rust-template/errors by default); every type URI is derived as {base}/{slug}/{version}. An adopter points that one constant at their own documentation host and all type URIs follow. The occurrence instance URN namespace is derived from the crate name (CARGO_PKG_NAME), so renaming the crate in Cargo.toml updates it automatically.

The binary ships both renderings and selects between them. Selection is OutputFormat::select, driven by an explicit --format flag first, then by stderr TTY detection:

--formatstderr is a TTYSelected format
json(ignored)JSON
pretty(ignored)Pretty
(absent)yesPretty
(absent)noJSON

The rationale for honoring both signals: a human at a terminal gets the lush line by default; the same binary, run by an agent or in a pipe (no TTY), emits the structured envelope without any flag. An explicit --format always wins, so a human can force JSON and an agent can force pretty when needed.

The pretty rendering is byte-identical to the binary’s historical Error: {e} line — adopting this architecture changes nothing for human users. The JSON rendering is the compact application/problem+json envelope.

The InvalidInput variant produced by divide(10, 0) renders two ways from the same value. Pretty:

Error: invalid input: divisor cannot be zero

JSON (application/problem+json):

{
"type": "https://zircote.com/rust-template/errors/invalid-input/v1",
"title": "Invalid input",
"status": 400,
"detail": "invalid input: divisor cannot be zero",
"instance": "urn:rust_template:invalid-input",
"retry_after": null,
"suggested_fix": {
"description": "Correct the input so it satisfies the documented constraints, then retry.",
"applicability": "maybe_incorrect"
},
"code_actions": [
{
"title": "Replace the offending input with a valid value",
"kind": "quickfix",
"applicability": "maybe_incorrect"
}
],
"exit_code": 2
}

The envelope, the mapping, and the format selector are all part of the library surface, not buried in the binary. An adopter building a different binary — or a service, or a second CLI — reuses ProblemDetails, Error::to_problem, and OutputFormat directly. The binary in crates/main.rs is just the first consumer of a reusable contract. The cost is a slightly larger public API; the benefit is that every project built from the template inherits a machine-readable error contract for free, rather than re-implementing one per binary.

  • The Error Handling reference — the field tables and source conventions in lookup form.
  • Architecture & Design — the broader “why” behind the template’s layout, CI, and lint philosophy.
  • RFC 9457 — Problem Details for HTTP APIs
  • LSP CodeAction — the shape code_actions[] follows.