Lowent Manual←↑→

17 Designing failure

What to know first

chapter 11, Types that hold answers · three ways to report failure
chapter 14, Contracts · requires is the caller’s responsibility, errors the op’s
chapter 16, Capabilities · the entry point shows where the program touches the outside

Looking 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

Knowing all the grammar does not by itself decide where and how failure should be reported. The same “invalid port number” must be a 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

You will learn the design that separates values coming from outside from inner invariants, reporting with 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

  1. 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 .?

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.

PlaceNature of valuesHow to report
Boundary (parsing, input)Anything can arriveresult and errors
Inside (computation)Already passed a checkrequires · range · newtype
No layer can handle itA state that must not existpanic

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 do

Q. 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

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

If even the inside returns 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?”.

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

The standard library’s 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#

ShapeMeaningWhy
boundary op output result t e . + errorsreturn the failure of outside values as a valuethe calling layer chooses what to do
inner op requires · range · newtypeinvariants of already-filtered valuesone check stays at the boundary; inside it is removed
enum parse_error do empty . not_digit . endone variant per different caller actionmore variants, more handling work
let v u16 be try check_port port .pass it upward when you cannot handle itthe passing stays visible in errors
case error e . do match e do … end endbind the error value, then split its variantsthe name in case error <name> is a new binding unless it names a declared variant
try … else_none · value_ordiscard the reasondiscard as high up as possible
proc … effects panic . + panic "…"stop in a state no layer can handlenot for bad input or missing files
entry point output u8 .the outermost layer — handle failure and report by exit codethere is nowhere further to pass it

Table 17.2 — Shapes for designing failure — shape · meaning · why it looks this way

Recap

At boundaries where values come from outside, report failure with 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.