Explicit error composition
Status: Implemented through HTTP client adoption. Library mapping, general
anonymous sums, human-readable reporting, typed streams and HTTP framing have
shipped. The HTTP client’s head, write and body paths preserve concrete causes
without internal stored-error retrieval. Installed CLI/data and JSON-service
checks cover application errors, reports, recovery and cleanup.
? supports lossless error-set widening without user-defined conversions.
Builtin Failure remains a deliberate last resort; issue #342 is closed.
The legacy response Reader bridge remains available beside typed response
reads and partial body-collection progress.
The agreed B71 acceptance batch is complete. Floating-point report formatting,
dynamic error objects and other additive extensions remain in B71;
the benchmark-template migration stays deferred during performance comparisons.
The companion error sets page records the sum contract and
the last-resort role of builtin Failure; formatting
records the shipped enum-capable reporting design.
Typestate addresses lifecycle invariants.
Reading this family
| Question | Design and current contract |
|---|---|
| How do I deliberately translate an error? | Mapping below; std.error |
| How do I retain several possible error types? | Anonymous-sum design; current sums |
| How do I present an error to a person? | Completed reporting design; std.report |
| How do stream adapters preserve a transport cause? | Experiment and production evidence; std.io.typed, std.http.typed |
Problem and recommendation
At the original investigation baseline, ? propagated an identical error type,
except for deliberate erasure to Failure. Library adapters repeated success forwarding just to
translate failures. A second, independent problem is that std.io.Reader
fixes its error type to StreamError: HTTP adapters store richer causes in a
separate field and return a generic error through the stream interface.
Explicit result mapping and an isolated parameterized-stream experiment
established the baseline. General anonymous sums and human-readable formatting
for enums and classes subsequently shipped. Issue #342 removed the export and
retained-job cases that forced Failure; the builtin stays as a last resort.
Production typed-stream adoption then removed the HTTP client’s internal error
side channels. Keep named domain errors where they encode useful policy:
neither mapping nor sums alone can recover causes discarded by a stream API.
Evidence in the stdlib
Locations below describe the inspected baseline, commit bef41b1; function
names are the durable anchors when line numbers move.
| Location | Existing burden | Candidate improvement |
|---|---|---|
stdlib/std/http/body.plenty:79–140, allocated, http_allocated, lifted, http, json, stored | Repeated Ok forwarding around error translation | Result mapping; retain classifiers for deliberate allocation normalization |
stdlib/std/http/client.plenty:333–387, alloc, bytes_of, framed, netted, parsed | Several near-identical wrappers | Map only the failure payload |
stdlib/std/json.plenty:121–147, faulted, reserved, unplaced | Nested adaptation before parsing can proceed | Shared mapping operation with named classifiers |
stdlib/std/http/client.plenty:599–640, Wire.noted, fault, written | Error stored in _failure, generic stream error returned, cause retrieved later | Preserve the transport error as part of the returned type |
stdlib/std/http/client.plenty:690–707, Response.read, take_failure | Caller must retrieve richer error separately | Typed stream error path with optional compatibility adapter |
stdlib/std/http.plenty:633–647, BodyReader.read | Framing faults become Io(InvalidInput) plus side storage | Parameterized framing/stream error |
stdlib/std/error.plenty:62–85, context, path_context | Already preserves a typed cause and handles failed context allocation | Reuse this foundation; do not replace it with unconditional boxing |
These are opportunities, not claims that all conversions can disappear.
BytesError.InvalidUtf8 becoming BodyError.InvalidUtf8, for example, is
intentional public API policy.
Explicit mapping
Possible future method spelling (not current executable Plenty):
body = std.json.encode(value, limits).map_err(body_json_error)?
def body_json_error(cause: JsonError) -> BodyError:
BodyError.Json(cause)
The operation consumes Result[T, E] and a mapper constrained by
F: OnceCallable[[E], G], returning Result[T, G].
The implemented std.error.map_error(result, mapper) provides these semantics
as an ordinary library function. Method syntax remains an ergonomic choice,
not required semantics. See the reference
and the tutorial.
- Evaluate the result and mapper expressions once, left to right.
- Invoke the mapper exactly once on
Err, never onOk. - Move the original error into the mapper; forward success without copying.
- Drop an unused mapper and its captures on success. Ownership and early-exit cleanup apply normally, including failure while evaluating the mapper.
- Mapping itself allocates nothing. Callback construction and execution retain their ordinary effects; do not silently box captured callbacks.
- A mapper returns
G, not a secretly flattenedResult[G, X]. Fallible context enrichment needs an explicit API and policy for preserving the cause. ?still performs no user-defined conversion. The call site names the policy.
This removes repetitive success branches while leaving semantic conversions reviewable. A helper can still be worthwhile when it communicates a domain decision. Do not mechanically inline all classifiers or introduce complex closures solely to reduce line count.
Rust’s thiserror generates error implementations and selected conversions;
it reduces declaration boilerplate while retaining a named error type.
Plenty could later consider declared conversions, but implicit conversion at
every ? would need ambiguity and coherence rules. Explicit mapping is the
smaller initial contract. thiserror documentation
Does an error protocol solve it?
The idea is coherent, but has three distinct meanings:
| Meaning | What it provides | Fit today |
|---|---|---|
E: Error generic constraint | Operations available on one statically known E | Classes and enums can satisfy protocols; a reporting bound is optional |
Result[T, Error] holding arbitrary implementations | An owned existential value with hidden concrete type | Not supported; protocols are not runtime value types |
| `Result[T, A | B]` | One of a finite, statically declared set |
A generic bound does not allow one specialization to return unrelated concrete types on different branches. It still needs an enum, union, or erased container. Nor does a common reporting interface establish how to classify interruption, retryability, partial progress, or a framing failure.
An existential error container must specify storage, destruction, dispatch, thread transfer, reporting, and whether downcasting is supported. Arbitrarily large owned payloads cannot all fit a fixed-size inline object; boxing or an explicit capacity/fallback policy is needed. Allocation while reporting an allocation failure is a particular problem for Plenty. Borrowed errors cannot generally escape the failing function’s locals.
Rust trait objects make runtime dispatch explicit. anyhow offers an erased
error with contextual reporting and downcasting, trading away an exhaustive
signature of possible causes. That can suit application boundaries, but it is
not equivalent to a finite typed sum. Rust trait objects,
anyhow documentation
Do not make a reporting protocol mandatory for error payloads: Plenty currently
allows ordinary typed data in Err. Reporting and recoverability are separate.
The implemented formatting protocol provides
presentation without an existential error container. Enums can satisfy protocols,
and anonymous sums forward this formatting protocol when all members qualify.
Builtin Failure stays as a last resort, for when no precise error can be
written (issue #342); an erased error container and heterogeneous cause-chain interface remain deferred.
Removing the stream error side channel
Current parameterized protocols
already substitute type arguments in result positions. A minimal Source[E]
and generic reader using an enum error type also passed the local debug compiler
smoke check. Prototype the following
shape in an isolated library module, before changing std.io:
protocol Reader[E]:
def read(self: &mut Reader[E], buffer: &mut Bytes,
start: i64, stop: i64) -> Result[i64, E]:
pass
enum ReadFailure[E]:
Source(E)
UnexpectedEof
InvalidProgress
OutOfRange
Allocation(AllocError)
This is a proposed interface shape, not a claim that changing Reader alone
is sufficient. Helpers generate their own errors, and retry logic needs a way
to recognize interruption. Keep that policy explicit: use a classifier callback
or a protocol parameter for a classifier class, or require a normalized stream
envelope that distinguishes Interrupted from Source(E). Compare these
options against concrete adapters before selecting one.
read_exact[E, R: Reader[E]] would return progress plus ReadFailure[E];
copy_all must preserve independent reader/writer error types and the failing
side. HTTP framing would similarly need HttpError[E] (or a distinct generic
framing error), separating transport causes from protocol failures. Existing
StreamError is still useful for OS adapters and compatibility facades.
Avoid recursive type equations such as making ClientError contain
HttpError[ClientError]. Define a transport-only error underneath framing and
an exchange-level error above it. Separate EOF, malformed progress, cancellation,
and protocol faults; arbitrary E cannot be interrogated as if it were
StreamError. Parameter threading increases signatures and specialization;
measure whether eliminating side storage pays for that complexity.
Retain old APIs during a migration, or make a deliberate coordinated breaking
change. Remove _failure/take_failure only when every relevant adapter and
caller receives the full cause directly. Mapping alone cannot achieve this.
Acceptance criteria
The evidence companion records the mapper comparisons, prototype tests and production adoption. The HTTP qualification record records the installed CLI/data and JSON-service acceptance. The criteria below preserve the checks that motivated those changes.
Compare before/after HTTP body adapters, a JSON classifier, and one complete HTTP-over-stream path. Count adapter functions and side-channel fields, not just lines. Verify exact cause preservation, once-only mapper invocation, success without mapping allocation, destruction of affine payloads/captures, partial progress, interruption, cancellation, and allocation failure. Check diagnostic quality for incompatible error types and protocol inference. Tutorial/reference changes belong with an implementation, not this proposal.
Prototype findings
The experiment record records mapper adoption, specialization limits, and the typed-stream prototype. It distinguishes the isolated examples from the public library APIs.
Additive production slice
The production slice provides typed I/O helpers and fixed-length/chunked/close-delimited HTTP body readers, validated-head integration and full client adoption. Internal transport side channels are removed; the legacy response Reader bridge remains for compatibility. The evidence page records the delivered scope and limits.