17 Designing failure
What to know first
requires is the caller’s responsibility, errors the op’sLooking back
What question did chapter 11 use to separate result, option and panic?
A. “What can the caller do?” If another path can be tried, result; if the thing is simply absent, option; if the caller already broke a promise and it cannot be fixed, stop. This chapter applies that question not to one op but to every layer of a program.
The need for this chapter, and its context
result in the layer reading a configuration file, and a contract in an inner layer that has already checked it. Get this wrong and checks repeat in layer after layer, or no layer checks at all. As the last chapter of Part IV, it lays out how to use contracts, errors and capabilities together to place failure.By the end of this chapter
result at the boundary and with contracts inside. You will see the criteria for splitting error enums and what to put in errors clauses, and for deciding whether to pass failures up with try or handle them on the spot as they move up the layers. You will also see where it is fine to throw information away with option, and where panic is acceptable.The questions this chapter answers
- How does an error written without a condition, like
errors not_digit ., differ from one with a condition, likeerrors empty eq (len s) 0 .?
17.1 The boundary and the inside#
A program has places where values come in from outside: program arguments, file contents, bytes from the network, user input. Such values carry no promises. And there is an inside where values that passed a check flow around.
The first principle of the design is to separate the two.
| Place | Nature of values | How to report |
|---|---|---|
| Boundary (parsing, input) | Anything can arrive | result and errors |
| Inside (computation) | Already passed a check | requires · range · newtype |
| No layer can handle it | A state that must not exist | panic |
Table 17.1 — Where failure is reported
examples/ch17/parse.low
module parse .
rem run: parse_u16 [52,50]
rem run: parse_u16 []
rem run: parse_u16 [52,120]
rem run: parse_u16 [55,48,48,48,48]
rem run: port_or_default [56,48]
rem run: port_or_default [120]
enum parse_error do
empty .
not_digit .
too_big .
end
fn is_digit input c u8 . output bool .
do
return and (ge c 48) (le c 57) .
end
rem bytes from outside — anything can arrive, so failure is a value
fn parse_u16 input s slice u8 . output result u16 parse_error .
errors empty eq (len s) 0 .
errors not_digit .
errors too_big .
do
guard gt (len s) 0 . else return error empty .
var acc u64 be 0 .
for c s do
guard is_digit c . else return error not_digit .
set acc (add (mul acc 10) (widen u64 (sub c 48))) .
guard le acc 65535 . else return error too_big .
end
return ok (narrow u16 acc) .
end
rem this layer needs no «why» — if absent, use the default
fn port_or_default input s slice u8 . output u16 .
do
let r result u16 parse_error be parse_u16 s .
guard not (is_error r) . else return 8080 .
return ok_value r .
end
Output
$ lowentc --run parse_u16 parse.low [52,50]
parse_u16([52,50]) = ok 42
arg0 (written) = [52,50]
$ lowentc --run parse_u16 parse.low []
parse_u16([]) = err empty
arg0 (written) = []
$ lowentc --run parse_u16 parse.low [52,120]
parse_u16([52,120]) = err not_digit
arg0 (written) = [52,120]
$ lowentc --run parse_u16 parse.low [55,48,48,48,48]
parse_u16([55,48,48,48,48]) = err too_big
arg0 (written) = [55,48,48,48,48]
$ lowentc --run port_or_default parse.low [56,48]
port_or_default([56,48]) = 80
arg0 (written) = [56,48]
$ lowentc --run port_or_default parse.low [120]
port_or_default([120]) = 8080
arg0 (written) = [120]
parse_u16 is a boundary. The bytes may be empty, may not be digits, may be too large. All three are fixable failures, so they are returned as a result. Demanding “digits only, please” with a contract would be the wrong design — the caller hands the bytes to this op as soon as it receives them, so it has no way to keep that demand.
Drawn as where a value comes from and where it goes:
outside (no promises) boundary inside (already filtered)
args · files · bytes ──▶ parse_u16 ── ok ──────▶ slot_of …
result + errors requires · range · newtype
│ error
▼
the calling layer decides what to doQ. How does an error written without a condition, like errors not_digit ., differ from one with a condition, like errors empty eq (len s) 0 .?
A. With a condition, it becomes an outgoing contract: “under this condition, exactly this error happens”. Returning normally while the condition holds is a contract violation. Callers can trust that avoiding the condition means they need not handle that error. Errors whose condition cannot be written as a contract expression (are all bytes digits?) are written without one and promise only “it can happen”. Where a condition can be written, writing it tells callers more.
17.2 How to split an error enum#
Give an error variant to each case where the caller would act differently. empty, not_digit and too_big were split because a different message can be shown for each. If callers do the same thing for all of them, merge the variants; more variants mean more work for the code that handles them with match.
Conversely, avoid a giant enum that holds the errors of unrelated layers in one op. When file errors, parse errors and configuration errors are mixed into one enum, the errors clause in the head paints a wider picture than the op can actually produce, and declarations of impossible errors are rejected by translation (chapter 14).
17.3 Moving up the layers#
A layer that receives a failure does one of two things: pass it on or handle it there.
examples/ch17/layers.low
module layers .
rem run: main
enum config_error do
bad_port .
port_zero .
end
rem inner invariant — what the caller should already have filtered is a contract
fn slot_of input port u16 . input slots u64 . output u64 .
requires gt slots 0 .
do
return mod (widen u64 port) slots .
end
rem outside input — what can be wrong is a result
fn check_port input port u16 . output result u16 config_error .
errors port_zero eq port 0 .
do
guard ne port 0 . else return error port_zero .
return ok port .
end
fn pick_slot input port u16 . output result u64 config_error .
errors port_zero eq port 0 .
do
let p u16 be try check_port port .
return ok (slot_of p 16) .
end
proc main input out cap io . output u8 . effects io .
do
let r result u64 config_error be pick_slot 8080 .
guard not (is_error r) . else do
let n u64 be write_out out 1 "bad port\n" .
return 1 .
end
let m u64 be write_out out 1 "slot ok\n" .
return narrow u8 (ok_value r) .
end
Output
$ lowentc --run main layers.low
slot ok
main() = 0
slot_ofis inside. If the number of slots were 0, the remainder operation would stop, but that is the caller’s fault, so it is written asrequires gt slots 0 ..check_portis close to a boundary. Port 0 means the configuration is wrong and can be fixed, so it is aresult.pick_slotpasses the failure up withtry, and writes the same error in its ownerrors. After success it knowspis not 0 and calls the inner op.mainis the outermost layer. There is nowhere further to pass the failure, so it handles it — writes a message and returns exit code 1.
The path a failure takes upward when port is 0. Going down, layers call; coming up, each passes the failure on or handles it.
main ───────────────────────── handles it: writes "bad port", returns 1
│ ▲ error port_zero
▼ │
pick_slot ──────────────────── passes it on: try sends it up as is
│ ▲ error port_zero │
▼ │ ▼ only after success
check_port slot_of
(boundary: result) (inside: requires gt slots 0)As a rule: if the nearest layer that could receive the failure can handle it, do not pass it on; if it cannot, pass it on. try shrank the cost of passing it on to one word, but the fact that it is passed on stays in the caller’s errors clause.
A common misconception. It is safest if every op returns a result
results, every call gets a try and every head an errors. Yet inside, the failure cannot happen — the boundary already filtered it out. Writing impossible failures creates handling code, and that code never runs. Write inner invariants with contracts and types (range, newtype). Then the check stays once at the boundary, and inside it is used as a fact and actually removed.17.4 Where information may be thrown away#
port_or_default does not need the reason for failure. Whatever went wrong, it uses the default port 8080. In such a layer, ask the result and ignore it, or turn it into an option with try … else_none and use value_or. Throwing information away is that layer’s decision. If a lower layer throws it away early as an option, an upper layer that needs the reason has no way to get it back. So throw away as high up as possible.
17.5 Where panic is acceptable#
Use panic when meeting a state that cannot be recovered from. The criterion is “which layer could handle this state?”.
- A program invariant is broken (an internal table is corrupted). No layer can produce a correct answer, so stop.
- Going on would cause greater harm (about to record data that failed verification).
Conversely, wrong user input, a missing file or a dropped connection are not reasons to panic; some layer can handle them. And panic is an effect, so the moment you use it, effects panic spreads to the head of that op and every op calling it (chapter 15). That cost is what makes you use panic sparingly.
In practice. The core of the HTTP request parser is rejection
http module reads HTTP/1.1 requests. The line its documentation introduces itself with is “its core is rejection”. Attacks such as request smuggling live where a server and a proxy accept an ambiguous request differently. So the parser accepts ambiguous input as little as possible and returns named errors. When a boundary op says clearly with a result what it rejects, the inside can trust the shape of the requests it accepted (chapter 36).17.6 Common mistakes#
Counter-example. Calling panic on bad input at the boundary
examples/ch17/mistake_panicinput.low
module mistake_panicinput .
rem run: parse_or_die [52,50]
rem trap: parse_or_die [52,120]
rem ✘ one mistyped character from the user stops the whole program
proc parse_or_die input s slice u8 . output u64 . effects panic .
do
var acc u64 be 0 .
for c s do
if or (lt c 48) (gt c 57) . do panic "not a digit" . end
set acc (add (mul acc 10) (widen u64 (sub c 48))) .
if gt acc 65535 . do panic "too big" . end
end
return acc .
end
Output
$ lowentc --run parse_or_die mistake_panicinput.low [52,50]
parse_or_die([52,50]) = 42
arg0 (written) = [52,50]
$ lowentc --run parse_or_die mistake_panicinput.low [52,120]
== ir diagnostics (1) ==
0:0 E-VM-PANIC: the program called `panic` — this is an unrecoverable stop, and it is NOT a contract violation (the code chose to stop, it did not break a promise)
The user only typed 4x, and the whole program stops. Bad input is something that happens all the time, and some layer can deal with it — ask again, use a default, show a message. panic removes every one of those choices, and effects panic spreads to every caller on top of that. Return failure as a result, like parse_u16 at the start of this chapter, and let the calling layer decide what to do.
Counter-example. Throwing the reason away in a lower layer
examples/ch17/mistake_dropwhy.low
module mistake_dropwhy .
rem run: explain []
rem run: explain [120]
enum parse_error do
empty .
not_digit .
end
fn parse_digit input s slice u8 . output result u8 parse_error .
errors empty eq (len s) 0 .
errors not_digit .
do
guard gt (len s) 0 . else return error empty .
let c u8 be index s 0 .
guard and (ge c 48) (le c 57) . else return error not_digit .
return ok (sub c 48) .
end
rem ✘ the lower layer threw the reason away early
fn digit_or_none input s slice u8 . output option u8 .
do
return try (parse_digit s) else_none .
end
rem the upper layer wants different messages for empty and non-digit input, but sees 0 for both
fn explain input s slice u8 . output u8 .
do
let d option u8 be digit_or_none s .
guard is_some d . else return 0 .
return 9 .
end
Output
$ lowentc --run explain mistake_dropwhy.low []
explain([]) = 0
arg0 (written) = []
$ lowentc --run explain mistake_dropwhy.low [120]
explain([120]) = 0
arg0 (written) = [120]
The moment digit_or_none turns the error into an option with else_none, the “why” is gone. The upper layer explain wants different messages for empty input and non-digit input, but sees 0 for both, and there is no way to get the reason back. Carry the reason all the way up and discard it only in the layer that knows it may be discarded.
examples/ch17/dropwhy_fixed.low
module dropwhy_fixed .
rem run: explain []
rem run: explain [120]
rem run: explain [55]
enum parse_error do
empty .
not_digit .
end
fn parse_digit input s slice u8 . output result u8 parse_error .
errors empty eq (len s) 0 .
errors not_digit .
do
guard gt (len s) 0 . else return error empty .
let c u8 be index s 0 .
guard and (ge c 48) (le c 57) . else return error not_digit .
return ok (sub c 48) .
end
rem carry the reason all the way up and split on it in the layer that needs it
fn explain input s slice u8 . output u8 .
do
match parse_digit s do
case ok v . return 9 .
case error e . do
rem bind the error value to a name, then split that enum once more
match e do
case empty . return 1 .
case not_digit . return 2 .
end
end
end
end
Output
$ lowentc --run explain dropwhy_fixed.low []
explain([]) = 1
arg0 (written) = []
$ lowentc --run explain dropwhy_fixed.low [120]
explain([120]) = 2
arg0 (written) = [120]
$ lowentc --run explain dropwhy_fixed.low [55]
explain([55]) = 9
arg0 (written) = [55]
Split on an error variant directly with case error <variant name>.
examples/ch17/errvariant.low
module errvariant .
rem run: explain []
rem run: explain [120]
rem run: explain [53]
enum parse_error do
empty .
not_digit .
end
fn parse_digit input s slice u8 . output result u8 parse_error .
errors empty eq (len s) 0 .
errors not_digit .
do
guard gt (len s) 0 . else return error empty .
let c u8 be index s 0 .
guard and (ge c 48) (le c 57) . else return error not_digit .
return ok (sub c 48) .
end
fn explain input s slice u8 . output u8 .
do
rem a declared variant name after `case error` matches that variant alone
match parse_digit s do
case ok v . return 9 .
case error empty . return 1 .
case error not_digit . return 2 .
end
end
Output
$ lowentc --run explain errvariant.low []
explain([]) = 1
arg0 (written) = []
$ lowentc --run explain errvariant.low [120]
explain([120]) = 2
arg0 (written) = [120]
$ lowentc --run explain errvariant.low [53]
explain([53]) = 9
arg0 (written) = [53]
When the name after case error is a declared variant, that arm takes that variant alone. When it is not, it binds the whole error value to that name (case error e). One spelling carries two meanings, but the rule is the one that holds everywhere else in a match: a bare name that is a variant is that variant (chapter 7). Write one arm per variant and the exhaustiveness check finds the one you forgot; bind a name instead and that single arm takes every error, so there is nothing left to find.
Counter-example. Calling an op that returns a result as a statement
examples/ch17/mistake_dropresult.low
module mistake_dropresult .
rem expect: W-RESULT-DISCARD
enum config_error do
port_zero .
end
fn check_port input port u16 . output result u16 config_error .
errors port_zero eq port 0 .
do
guard ne port 0 . else return error port_zero .
return ok port .
end
proc main input out cap io . output u8 . effects io .
do
rem ✘ the result of the check is dropped as a statement --- the port is 0, yet "started" is printed
check_port 0 .
let n u64 be write_out out 1 "started\n" .
return 0 .
end
Output
$ lowentc --check mistake_dropresult.low
mistake_dropresult.low:18:0 W-RESULT-DISCARD: this op returns a `result` — it says failure is a VALUE — and the value is dropped here, so a failure leaves no trace at all. Bind it and look at it (`let r … be …` then `is_error`), forward it (`try`), or say in the code why the failure does not matter
check_port 0 . returned an error, but nobody received it: the port is 0, yet “started” would be printed and the exit code would be 0. The design of returning failure as a value only holds when the caller looks at that value, so the tool reports it as W-RESULT-DISCARD. Receive the result of such an op with let and ask, pass it on with try, or split it with match. Binding it and never reading it gets the same warning — the failure vanishes just the same. Where there genuinely is nothing to do with it (closing a handle on an error path), write drop <name> . to say you are letting this one go. The fault is not ignoring a failure; it is ignoring it silently.
17.7 This chapter’s syntax at a glance#
| Shape | Meaning | Why |
|---|---|---|
boundary op output result t e . + errors | return the failure of outside values as a value | the calling layer chooses what to do |
inner op requires · range · newtype | invariants of already-filtered values | one check stays at the boundary; inside it is removed |
enum parse_error do empty . not_digit . end | one variant per different caller action | more variants, more handling work |
let v u16 be try check_port port . | pass it upward when you cannot handle it | the passing stays visible in errors |
case error e . do match e do … end end | bind the error value, then split its variants | the name in case error <name> is a new binding unless it names a declared variant |
try … else_none · value_or | discard the reason | discard as high up as possible |
proc … effects panic . + panic "…" | stop in a state no layer can handle | not for bad input or missing files |
entry point output u8 . | the outermost layer — handle failure and report by exit code | there is nowhere further to pass it |
Table 17.2 — Shapes for designing failure — shape · meaning · why it looks this way
Recap
result and errors; inside, where checks have passed, write invariants with contracts and types. Give error variants to each case where the caller acts differently. A layer receiving a failure handles it if it can and passes it on with try if not. Throw information away as high up as possible, and use panic only for states no layer can handle.