Results, Tool problems, and faults
This specification owns how failure appears in a Flow program: Result<T, E>
as an ordinary value, ? and x ? f, the four cases of ToolProblem<E>,
faults and the four places they come from, and how a run ends as the language
sees it.
It does not own the syntax of ?, match or guard (grammar.md),
enum declarations and constructor resolution (types.md), pattern
meaning and return (semantics.md), the discard rules and
exhaustiveness (checking.md), @tool declarations and reply
decoding (tools.md), task faults and stopping
(concurrency.md), the facts a failure leaves in history and
replay verdicts (history.md), the result and option helpers
(stdlib.md), or run states, exit codes and limits
(execution.md).
Examples are illustrative. A flow block is a fragment of source in the
decided syntax; the Tool modules and types it mentions are assumed.
Two kinds of failure
Flow separates failures a program handles from failures that end it.
A failure the program handles is a value. A function names it in its result
type, usually as the Err side of a Result or as a constructor of its own
outcome enum. A Tool call names it through ToolProblem<E>. The program takes
it apart with match, guard let and ?, like any other value.
A fault is not a value. It ends the run. No expression observes, catches or
recovers from one, and there is no exception, unwinding or catch.
Nothing converts one kind into the other. A program can't raise a fault, and a
fault never becomes an Err.
Serves foundations: honest uncertainty.
Result
Result<T, E> is an ordinary std enum with two constructors, Ok(T) and
Err(E). It is exported by the prelude (modules.md),
and std marks it @lang(result) so that ? and Tool calls can find it
(stdlib.md). E is any type; it is usually an enum the program
declares.
An Err is a value and nothing more. It doesn't leave the function that holds
it, doesn't stop a list.map over many calls, and doesn't propagate unless the
program writes ?. There is no attempt, no automatic propagation, no
universal error type and no implicit conversion between error types.
type ReadView { Read(Text), Missing, Failed(Text) }
flow describe(reply: Result<Text, ToolProblem<files.ReadError>>) -> ReadView = match reply {
Ok(body) => Read(body),
Err(Failed(NotFound)) => Missing,
Err(problem) => ReadView.Failed("${problem}"),
}Open in playground →Reaching the success value takes a match that covers every case, or one of
the forms below, so using a result means facing its error. Dropping a result
unread takes let _ =. checking.md owns both rules; neither
knows anything about Result.
Option<T> is the type for an absent value, not for a failure. It has no ?;
a program unwraps it with match or guard let:
guard let Some(owner) = dict.get(owners, plan.owner) else { return UnknownOwner(plan.owner) }Open in playground →std's result and option modules offer a small set of helpers, such as map,
map_err, and_then, unwrap_or and ok. None of them faults, and there is
no unwrap (stdlib.md).
? and x ? f
Postfix ? returns early from a Result, visibly at each place it is written.
x?: whenxisOk(v), the expression isv. WhenxisErr(e), the enclosing function returnsErr(e)unchanged. The error type ofxmust be exactly the error type of the function's result; there is no subtyping and no conversion.x ? f: whenxisOk(v), the expression isvandfis not called. WhenxisErr(e), the enclosing function returnsErr(f(e)).fis a name or an anonymous function; a constructor is a function, sox ? Planningwraps the error in a constructor.
The enclosing function is the nearest one, anonymous functions included, the
same target return has (semantics.md). That function's result
type must be Result<_, E>. ? anywhere else is an error, including in a
function that returns its own outcome enum: such a function calls a helper that
returns Result and matches on it once.
? works only on the type std marks @lang(result). Every conversion between
error types is a function written at the call; nothing is converted by a trait
or by type.
use "github.com/acme/tools@v1/planner"
use "github.com/acme/tools@v1/files"
type StepError {
Planning(ToolProblem<planner.PlanError>),
Reading(ToolProblem<files.ReadError>),
}
type Outcome { Done(Text), GaveUp(StepError) }
flow step(goal: Text) -> Result<Text, StepError> !tool = {
let plan = planner.next(goal) ? Planning
let body = files.read(plan.path) ? Reading
Ok(body)
}
pub flow main(goal: Text) -> Outcome !tool = match step(goal) {
Ok(body) => Done(body),
Err(problem) => GaveUp(problem),
}Open in playground →grammar.md owns the precedence of ? and the forms f may take.
Tool problems
A call to a Tool declared as returning Result<T, E> has the type
Result<T, ToolProblem<E>> (tools.md). ToolProblem<E> is an
ordinary std enum, marked @lang(tool_problem) and exported by the prelude:
pub type ToolProblem<E> {
Failed(E), // the Tool reported its declared error
BadReply(Text), // a reply arrived but didn't match the declared type
NotRun(Text), // the call definitely didn't happen
Unknown(Text), // the call may or may not have happened
}Open in playground →A Tool call's outcomes mean exactly this:
| Case | Claim | Who makes it |
|---|---|---|
Ok(t) |
the Tool replied with a value of the declared success type | the Tool, checked by the runtime |
Failed(e) |
the Tool replied with a value of its declared error type | the Tool, checked by the runtime |
BadReply(reason) |
a reply arrived and did not decode into the declared type | the runtime |
NotRun(reason) |
the call did not reach the outside world | the host |
Unknown(reason) |
the call may have reached the outside world, and no admitted reply says what happened | the host |
The split tells a program whether repeating the call is safe. NotRun is a
claim that nothing happened, so a retry cannot repeat an effect. Unknown
makes no such claim: the call may have taken effect, and repeating it may
repeat the effect. BadReply says a reply arrived, so the call ran.
The Text in BadReply, NotRun and Unknown is the reason as the runtime or
host gave it. The language does not classify it, gives it no structure, and
infers nothing from it. A Tool whose declared error type is Never can't
produce Failed; its call fails only in the other three ways.
ToolProblem<E> is data whenever E is: it has equality, ordering and text,
and it can be stored, returned and sent to another Tool like any other value
(types.md). A program's own enum may have constructors named
Failed or Unknown; where a bare name is ambiguous it is qualified by its
type, as in ToolProblem.Unknown.
Every case is the record of an outcome, not a finding about the world. Ok and
Failed are the Tool's claims, admitted because they have the declared type;
admission makes them interpretable, not true. tools.md owns how a
reply is decoded and which values a reply may build, and
history.md owns the Reply fact each case is recorded as.
Serves foundations: honest uncertainty, typed, composable crossings.
No automatic retry
Flow never repeats a Tool call on its own. Replay and resume never re-dispatch
a recorded call, and they never turn Unknown into success or failure.
Attempts a host makes inside one call are not calls
(history.md).
A retry the program writes is a new call, with its own identity and its own
outcome. Recovery from Unknown is usually not a retry but a question the
program asks with another call:
use "github.com/acme/tools@v1/payments"
type Settlement { Confirmed(Text), Declined(payments.ChargeError), NotCharged, Unsettled }
flow settle(invoice: Invoice, key: Text) -> Settlement !tool = match payments.charge(invoice, key) {
Ok(receipt) => Confirmed(receipt.id),
Err(Failed(error)) => Declined(error),
Err(NotRun(_)) => NotCharged,
Err(_) => reconcile(key),
}
flow reconcile(key: Text) -> Settlement !tool = match payments.lookup(key) {
Ok(Some(receipt)) => Confirmed(receipt.id),
Ok(None) | Err(_) => Unsettled,
}Open in playground →The language does not assume a Tool supports lookup, idempotency keys or compensation. Retry schedules, backoff and reconciliation are library code and application decisions over these values.
Serves foundations: effects cannot always be repeated, honest uncertainty.
Faults
A fault is a bug or an exhausted limit. A program cannot raise one: there is no
fail, panic, assert or unwrap, and an impossible state is an error
variant like any other failure. Faults come from exactly four places:
- Division by zero:
int.div,%orDecimal/with a zero divisor, as data.md defines them.int.checked_divreturns anOptioninstead. - Waiting on a stopped task, with
task.awaitortask.first(concurrency.md). task.first([]), a race over no tasks (concurrency.md).- Exceeding a host limit with the program's own values, such as the size
of an
IntorTextor the depth of non-tail calls. The specification sets minimums every implementation supports; exceeding the limit an implementation actually has is a fault (execution.md).
A fault ends the run as faulted, with a message and the source location of the operation that faulted. Nothing catches it. A fault inside a task reaches whoever awaits or stops that task, and if nobody does, it faults the call that owns the task at once; concurrency.md owns that rule.
Other failures are not faults:
- A reply that doesn't match its declared type is
BadReply, a value, and so is a reply larger than the host's size limit: the limit was exceeded by something from outside, and the program can handle it. - A Tool the host can't reach, or a host token the host no longer recognizes,
is
NotRun, a value. - A program that doesn't check, a missing binding, or input that doesn't decode refuses the run before it starts; nothing has faulted, because nothing ran.
- A replay that disagrees with its history reports a verdict (history.md); it is not a fault of the program.
A fault rolls nothing back. Calls already made stay made, and their outcomes stay in history. Flow does not claim that a Tool received or honored a cancellation, and the host owns cleanup of processes, connections and anything else outside the run.
How a run ends
As the language sees it, a run that started ends in one of two ways, or does not end yet:
- Completed(value): the entry function returned. The value is the entry's
result, whatever it is. An entry that returns
Err(e)has completed withErr(e); the run did not fault. - Faulted(message, location): a fault ended it.
- Paused: a Tool call is waiting for its reply. A pause is an open call and nothing else; resuming supplies the reply (history.md).
Halting a run is the host's act, not a language outcome
(execution.md). Stopped is only what
task.stop returns, never a name for a halted run. A nested run's fault is a
value to the outer run (execution.md).
execution.md owns how a host reports these
states, including refusal before a run starts and the CLI's exit codes.
history.md owns the End fact and the replay verdicts.
Serves foundations: semantic execution history.