Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Error-composition experiments and typed streams

Status: Implemented through HTTP client adoption. This is the evidence companion to the composition overview, not a separate proposal or work queue. It accompanies the completed B71 acceptance batch. Mapping, typed streams and framing, and the HTTP client’s head, write and body paths have shipped. Current contracts belong to std.error, std.io.typed, and std.http.typed.

Prototype findings

The first implementation needs no compiler or runtime changes. map_error uses OnceCallable, a mutable local callback binding, and an ordinary match. The mutable binding accepts stateful reusable callbacks as well as named functions and consuming closures. A callback is owned even when unused, so success drops its captures. Error values need no reporting protocol.

The initial representative stdlib changes covered three result adapters: std/http/body.plenty removes json and maps directly in read_json and send_json; std/http/client.plenty uses mapping in framed; and std/json.plenty uses it in faulted. Three explicit success-forwarding branches disappear. Two convenience adapters remain and one non-generic bytes_fault classifier exposes the JSON policy. The number of named functions is therefore unchanged, while one adapter disappears completely. Other conversion helpers initially remained available for comparison.

A wider initial conversion of eleven adapters exceeded the compiler’s global 256 generic-function specialization cap in the offline HTTP JSON service test. Keeping a generic convenience adapter around map_error adds another instance for each success/callback type. The follow-up for issue #141 separates a 256-level dependency-depth guard from a 16,384-instance total resource budget; independent specializations no longer consume the depth guard. It restores the wider eleven-adapter adoption: body allocation, byte classification, HTTP and storage conversions; client allocation; and JSON allocation and location-free errors now also use mapping. Their conversion policies are unchanged. This addresses the observed scaling constraint without removing finite compiler limits or introducing allocation. See specialization limits.

Worker integration exposed a real ergonomic restriction: thin function values lose their target identity inside the generic helper, so thread effect checking rejects their indirect calls. The stdlib adopters use concrete consuming closures (def once (...)) instead; their bodies remain inspectable and allocate nothing. Named classifiers are called directly from these closures. This keeps worker checks intact but adds a wrapper at those sites. Mapping does not solve that existing callable-design limitation.

The isolated modules in examples/error_composition/ explore a normalized StepError[E] envelope: Interrupted requests retry; Source(E) preserves everything else, including cancellation and allocation failure. Reader[E] and Writer[E] feed read_exact, write_all, and copy_all; helper-generated failures have their own variants. Copy errors distinguish reader and writer types and count bytes delivered to the writer, including partial writes. Caller-provided scratch storage avoids allocation in the helpers.

FixedBody[E, R] demonstrates Content-Length body framing over a typed transport. Its failures distinguish Transport(E), truncation, invalid progress, invalid range, and reuse after failure. Interruption leaves it usable; terminal framing/transport failures mark it broken. The full body-copy path returns the original transport cause through FramingError[E] and CopyFailure[FramingError[E], W]. It contains zero error-storage fields and zero retrieval methods. At that prototype stage, production _failure fields and take_failure methods were retained: the experiment alone did not justify their removal.

This is one complete body-transfer path, not a complete HTTP client. It assumes a previously validated Content-Length and response semantics. It does not parse heads, implement chunked/close-delimited framing, perform TLS or network I/O, or manage connection reuse. Scripted sources exercise rich TLS-like errors and cancellation as typed data, not real handshakes or cancellation tokens. Those boundaries must be covered before a production migration.

Protocol-bound inference recovers the error types from concrete methods, even through the framing adapter: fixed_body(source, length) and copy_all(&mut body, &mut writer, &mut scratch) need no explicit type arguments. The generic definitions and nested error signatures are still long. A normalized envelope keeps retry policy simple but requires adapters to wrap their failures. A classifier callback would leave transport signatures alone, at the cost of threading retry policy through helpers; it remains an alternative, not implemented here. Arbitrary errors must not be retried by guessing their meaning.

Focused tests live in tests/test_error_composition.rs and tests/test_typed_stream_prototype.rs. They cover mapper ownership, allocation failure, nested results, evaluation order, progress, retry, EOF, cancellation, failure-side attribution, and typed transport preservation. These findings supported the additive production framing below; mapping remains independently usable.

Additive production slice

std.io.typed now makes the prototype’s typed protocols and progress helpers available without copying example modules. Its owned legacy reader/writer bridges preserve the returned StreamError and classify only explicit interruption as retryable. A consuming free function recovers each bridge’s stream for explicit flushing, closing or seeking. The private optional slot supports extraction under existing field-move rules; it allocates no storage.

std.http.typed initially provided fixed-length and close-delimited body readers. Close-delimited EOF is sticky, empty reads do not consume input, and its connection is never reusable. Both return FramingError[E] through the typed protocol without error-storage fields or retrieval methods. The production tests run the prototype’s cause/progress cases against both implementations and exercise real std.io.Cursor bridges through both framing modes. The standalone standard.plenty example is the public-module counterpart to main.plenty.

The subsequent chunked reader preserves typed transport errors and payload progress through size lines, data CRLF and bounded trailers. It retains parsing state across interruption and does not read into the next message. Framing and allocation failures use FramingError.Protocol(HttpError); the concrete transport cause stays in Transport(E). Its scripted regression tests inject interruptions and transport errors throughout metadata and payload, and check limits, malformed input and terminal-state behavior. The original isolated prototype remains a fixed-body experiment rather than a duplicate chunk parser.

Production now also includes parsed-head framing validation, bounded typed head readers and writers, typed buffering, and guarded recovery of completed persistent connections. Shared grammar/serialization helpers keep legacy and typed HTTP validation aligned. Head failures count consumed wire bytes; writes count delivered wire bytes; body-copy and collection counts refer to payload.

The HTTP client uses these paths for CONNECT, request heads, in-memory and streamed uploads, response heads and all body modes. Its existing TLS/network transports, deadlines, cancellation, redirect policy and pool remain in charge of connection lifetime. Internal Wire/Dialled error storage and retrieval are removed. Response.read/take_failure remains a compatibility bridge; typed_response carries the full ClientError through typed stream helpers, and read_all_progress retains bytes appended before failure. Existing send/fetch signatures still return causes without per-head/write counts; those are available from the lower-level typed operations. No builtin Failure, general-sum, or reporting change was needed for this migration.

On 2026-10-18, a local unoptimized compiler comparison of main.plenty against standard.plenty used five --compile runs each with PLENTY_CACHE=off. Median wall time was 0.196 s versus 0.216 s (ranges 0.163–0.209 s and 0.181–0.225 s); linked executable sizes were 3,286,648 and 3,290,816 bytes. Other validation builds were running concurrently, so these timings are a bounded smoke measurement, not a performance guarantee. The public fixed path adds roughly 4 KiB in this probe; it imports the legacy stream module to define bridges. Both copy signatures still carry two error types and two stream types, while fixed framing carries one error and one reader type. Call sites infer these parameters. No specialization-count instrumentation was added, and this small case does not establish scaling for a complete HTTP client.