Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ respective folders

- `bitops2` - an updated version of `bitops.nim`, filling in gaps in original code
- `byteutils` - utilities that make working with the Nim `byte` type convenient
- `eh` - error-handling utils for working with tracked exceptions, `Result` or `Option` types. Please see [the dedicated docs](docs/error_handling.md).
- `endians2` - utilities for converting to and from little / big endian integers
- `objects` - get an object's base type at runtime, as a string
- `ptrops` - pointer arithmetic utilities
- `result` - friendly, exception-free value-or-error returns, similar to `Option[T]`, from [nim-result](https://github.com/arnetheduck/nim-result/)
- `results` - friendly, exception-free value-or-error returns, similar to `Option[T]`, from [nim-result](https://github.com/arnetheduck/nim-result/)
- `shims` - backports of nim `devel` code to the stable version that Status is using

## Layout
Expand Down
200 changes: 200 additions & 0 deletions docs/error_handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
## The `errors` pragma

The `errors` pragma is similar to the `raises` pragma, but it uses
the Nim type system to ensure that the raised recoverable errors
will be handled at the call-sites of the annotated function.

To achieve this, it performs the following simple transformation:

```nim
proc foo(x: int, y: string): float {.errors: (ValueError, KeyError).} =
body
```

is re-written to the equivalent of:

```nim
type
Raising[ErrorsList, ResultType] = distinct ResultType

proc foo_original(x: int, y: string): float {.
raises: [Defect, ValueError, KeyError]
.} =
body

template foo(x: int, y: string): untyped =
Raising[(ValueError, KeyError), float](foo_original(x, y))
```

Please note that the original proc now features a `raises` annotation
that will guarantee that no other exceptions might be raised from it.
The `Defect` type was implicitly added to the list as a convenience.

The returned distinct type will be useless at the call-site unless
it is stripped-away through `raising`, `either` or `check` which are
the error-handling mechanisms provided by this library and discussed
further in this document.

If you accidentally forget to use one of the error-handling mechanisms,
you'll get a compilation error along these lines:

```
required type for x: float
but expression 'Raising[(ValueError, KeyError), float](foo_original(x, y))' is of type: Raising[tuple of (ValueError, KeyError), system.float]
```

Please note that if you have assigned the `Raising` result to a
variable, the compilation error might happen on a line where you
attempt to use that variable. To fix the error, please introduce
error handling as early as possible at the right call-site such
that no `Raising` variable is created at all.

`noerrors` is another pragma provided for convenience which is
equivalent to an empty `errors` pragma. The forced error handling
through the `Raising` type won't be applied.

Both pragmas can be combined with the `nodefects` pragma that
indicates that the specific proc should be proven to be Defect-free.

The transformation uses a template by default to promote efficiency,
but if you need to take the address the Raising proc, please add the
`addressable` pragma that will force the wrapper to be a regular proc.

Finally, `failing` is another pragma provided for convenience which
is equivalent to `{.errors: (CatchableError).}`.

## The `raising` annotation

The `raising` annotation is the simplest form of error tracking
similar to the `try` annotation made popular by the [Midori error model](http://joeduffyblog.com/2016/02/07/the-error-model/),
which is also [proposed for addition in C++](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0709r0.pdf) and [already available in Zig](https://ziglang.org/documentation/master/#try).

It merely marks the locations in the code where errors might be raised
and strips away the `Raising` type to disarm the compiler checks:

```nim
proc attachValidator(node: BeaconNode, keyFile: string) {.failing.} =
node.addValidator(raising ValidatorPrivKey.init(raising readFile(keyFile))
```

When applied to a `Result` or an `Option`, `raising` will use the
`tryGet` API to attempt to obtain the computed value.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for unification with Result, this is practically the same as ? - should ? be smarter and convert back-and-forth between the error handling models? from a performance POV, that's a bit dumb (the visual cost is much smaller than the incurred penalty) but it's... pragmatic.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered this as well, but it may be indeed too magical. I guess we'll know soon with more practice.


## The capital `Try` API

The capital `Try` API is similar to a regular `try` expression
or a `try` statement. The only difference is that you must provide
exception handlers for all possible recoverable errors. If you fail
to do so, the compiler will point out the line in the `try` block
where an unhandled exception might be raised:

```nim
proc replaceValues(values: var openarray[string],
replacements: Table[MyEnum, string]) =
Try:
for v in mitems(values):
let
enumValue = parseEnum v
replacement = replacements[enumValue] # Error here
Copy link
Member

@arnetheduck arnetheduck Apr 16, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the hallmark of exception-unsafe code, and a school-book example of a design issue in Try: mid-way through the iteration on values, an exception is raised and you're left with a partially mutated values input which the calling code cannot reason about and that is invisible when reading the code, unless you carefully analyze the control flow - there's no visual indication for the reader that this in particular is where there KeyError happens.

The most "natural" thing to do if the compiler is pestering me about KeyError here is to add a except KeyError below except ValueError which hides the actual exception safety issue with this code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

notably, the raising annotation does not suffer from this issue, as I understand it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, this is just an illustrative example. After all, the code doesn't compile, so it being a "school-book example of a design issue" is reasonable. If you can offer a better example with exceptions that every Nim user will be familiar with, feel free to suggest it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you need to put raising inside Try or not?

Copy link
Contributor Author

@zah zah Apr 16, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You have to put it for APIs that use the errors pragma.

v = replacement
except ValueError:
echo "Invalid enum value"
```

The above example will fail to compile with an error indicating
that `replacements[enumValue]` may fail with an unhandled `KeyError`.

## The `either` expression
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems like a small, out-of-scope utility - I wouldn't add until there's significant evidence that it's generally useful in real-world code - using a default after getting an error is a bit weird: the operation failed to calculate a value so now I use a some other value? why didn't the calculation give me that replacement value directly if that makes sense for the type? clearly, this is used when taking "shortcuts" with the type, and seems less legitimate

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would you censor a small utility like this? I find it useful, so let's see how much adoption it will get in practice. it's not just about providing a replacement value - it's also a control-flow construct.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because it's meant to become a core concept in the language and imported in every module basically - you don't want cruft in there from the start - better start every feature at -100 points and work up a case for it, and in this case it's cheap and easy to do "manually"


The `either` expression can be used with APIs based on `Option[T]`,
`Result[T, E]` or the `errors` pragma when it's appropriate to
discriminate only between successful execution and any type of
failure. Regardless of the error handling scheme being used,
`either` is used like this:

```nim
let x = either(foo(), fallbackValue)
```

On success, `either` returns the successfully computed value
and the failure side of the expression won't be evaluated at all.

Besides providing a substitute value, the failure side of the
expression may also feature a `noReturn` statement such as
`return`, `raise`, `quit` as long at it's used with the following
syntax:

```nim
let x = either foo():
return
```

Within the failure path, you can also use the `error` keyword to
refer to the raised exception or the `error` value of the failed
`Result`.

## The `check` expression

The `check` macro provides a general mechanism for handling
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so we don't call it match because... it's not general enough?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check is about unpacking the successful result while handling the errors. I think that's different enough from general pattern matching to deserve its own name.

the failures of APIs based the `errors` pragma or `Result[T, E]`
where `E` is an `enum` or a case object type.

It takes an expression that might fail in multiple ways together
with a block of error handlers that will be executed in case of
failure. If the user failed to cover any of the possible failure
types, this will result in a compilation error.

On success, the `check` returns the successfully computed value
of the checked expression. In case of failure, the appropriate
error handler is executed. It may produce a substitute value or
it may return from the current function with a `return`, `raise`,
`quit` or any other `noReturn` API.

The syntax of the `check` expression is the following:

```nim
let x = check foo():
SomeError as err: defaultValue
AnotherError: return
_: raise
```

If the `foo()` function was using the `errors` pragma, the
above example will be re-written to:

```nim
let x = try:
raising foo()
except SomeError as err:
defaultValue
except AnotherError:
return
except CatchableError:
raise
```

Alternatively, if `foo()` was returning a `Result[T, E: enum]`, the
example will be re-written to:

```nim
let x = foo()
if x.isOk:
x.get
else:
case x.orror:
of SomeError:
let err = x.error
defaultValue
of AnotherError:
return
else:
raiseResultError x
```

The `Result` error type can also be a case object with a single `enum`
discriminator that will be considered the error type. The generated code
will be quite similar to the example above.

Please note that the special default case `_` is considered equivalent
to `CatchableError` or `else` when working with enums.

Loading