error handling
This commit is contained in:
444
docs/error-handling.md
Normal file
444
docs/error-handling.md
Normal file
@@ -0,0 +1,444 @@
|
||||
# Error Handling in sx
|
||||
|
||||
A guide to writing fallible code in sx — raising errors, propagating
|
||||
them, handling them, and cleaning up.
|
||||
|
||||
---
|
||||
|
||||
## The mental model
|
||||
|
||||
In sx, errors travel on a **separate channel** from return values, not
|
||||
wrapped around them. A function that can fail adds a trailing `!` to its
|
||||
return type:
|
||||
|
||||
```sx
|
||||
parse_digit :: (s: string) -> (s32, !) {
|
||||
if s.len == 0 raise error.Empty;
|
||||
if !is_digit(s[0]) raise error.BadDigit;
|
||||
return s[0] - '0';
|
||||
}
|
||||
```
|
||||
|
||||
The `(s32, !)` says "returns an `s32` on success, or an error." The `!`
|
||||
is one more slot in sx's normal multi-return — the error rides
|
||||
alongside the values, it doesn't replace them.
|
||||
|
||||
Three things to know up front:
|
||||
|
||||
1. **Errors are tags, not data.** `error.BadDigit` is a lightweight
|
||||
name (interned to an integer), not a struct with fields. To attach
|
||||
context, log it; the tag itself is just an identity.
|
||||
2. **You can't ignore an error by accident.** Every failable result
|
||||
must be explicitly propagated, handled, or absorbed — the compiler
|
||||
rejects code that silently drops an error.
|
||||
3. **`try` marks every place an error can escape.** Reading the code,
|
||||
every point where an error leaves a function is visibly a `try`.
|
||||
|
||||
---
|
||||
|
||||
## Declaring what can go wrong
|
||||
|
||||
### Inferred sets — just write `!`
|
||||
|
||||
The simplest failable function uses a bare `!`. The compiler figures
|
||||
out which error tags it can produce by looking at the body:
|
||||
|
||||
```sx
|
||||
read_byte :: (r: *Reader) -> (u8, !) {
|
||||
if r.at_end raise error.Eof; // mints error.Eof on use
|
||||
return r.next();
|
||||
}
|
||||
```
|
||||
|
||||
Callers see `read_byte`'s error type as exactly the set of tags it can
|
||||
raise — here, `{ Eof }`.
|
||||
|
||||
### Named sets — when you want an explicit contract
|
||||
|
||||
For a stable, documented error contract, declare a named set and use it
|
||||
in the signature:
|
||||
|
||||
```sx
|
||||
ParseErr :: error { Empty, BadDigit, Overflow };
|
||||
|
||||
parse_int :: (s: string) -> (s32, !ParseErr) {
|
||||
if s.len == 0 raise error.Empty;
|
||||
if overflowed raise error.Overflow;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
With a named set, `raise error.X` is checked against the declaration —
|
||||
a typo like `error.BadDgit` is a compile error, because `BadDgit` isn't
|
||||
in `ParseErr`.
|
||||
|
||||
> **Tip:** Use a named set when the error contract is part of your API.
|
||||
> Use bare `!` for internal helpers where the errors are an
|
||||
> implementation detail.
|
||||
|
||||
---
|
||||
|
||||
## Raising an error
|
||||
|
||||
`raise` ends the function with an error, like `return` ends it with a
|
||||
value:
|
||||
|
||||
```sx
|
||||
if denominator == 0 raise error.DivByZero;
|
||||
```
|
||||
|
||||
`raise` is a statement — it can't appear inside an expression. You can
|
||||
raise a literal tag (`raise error.X`) or a tag held in a variable
|
||||
(`raise e`), which is handy for forwarding:
|
||||
|
||||
```sx
|
||||
v := parse(s) catch e {
|
||||
if e == error.Recoverable return default;
|
||||
raise e; // forward everything else
|
||||
};
|
||||
```
|
||||
|
||||
Inside a closure, `raise` ends **that closure**, not the function the
|
||||
closure was written in — a closure is its own failable function.
|
||||
|
||||
---
|
||||
|
||||
## Propagating with `try`
|
||||
|
||||
When you call a failable function and want its error to bubble up to
|
||||
*your* caller, prefix the call with `try`:
|
||||
|
||||
```sx
|
||||
two_digits :: (s: string) -> (s32, !) {
|
||||
a := try parse_digit(s); // if this fails, two_digits fails
|
||||
b := try parse_digit(s[1..]);
|
||||
return a * 10 + b;
|
||||
}
|
||||
```
|
||||
|
||||
`try parse_digit(s)` means: run it; on success, `a` gets the value; on
|
||||
failure, `two_digits` returns immediately with that error.
|
||||
|
||||
`try` works anywhere a value is expected — arguments, struct fields,
|
||||
conditions:
|
||||
|
||||
```sx
|
||||
v := combine(try parse(a), try parse(b)); // short-circuits on first failure
|
||||
cfg := Config.{ port = try parse_port(s), host = try parse_host(s) };
|
||||
if try is_ready(conn) { ... }
|
||||
```
|
||||
|
||||
**The rule:** a failable call must be marked. If you write a bare
|
||||
failable call with nowhere for its error to go, it's a compile error:
|
||||
|
||||
```sx
|
||||
v := parse_digit(s); // ERROR: parse_digit can fail — handle it
|
||||
v := try parse_digit(s); // OK: propagate
|
||||
```
|
||||
|
||||
This is the heart of sx error handling: **every escape point is a
|
||||
visible `try`.** You can grep for `try` to find every place your
|
||||
function can fail out.
|
||||
|
||||
---
|
||||
|
||||
## Fallbacks and chains with `or`
|
||||
|
||||
`or` provides a value when a failable call fails, or chains to another
|
||||
attempt.
|
||||
|
||||
### Fall back to a default value
|
||||
|
||||
```sx
|
||||
port := parse_port(s) or 8080; // if parsing fails, port = 8080
|
||||
```
|
||||
|
||||
The error is absorbed; `port` is a plain `s32`.
|
||||
|
||||
### Chain attempts — first success wins
|
||||
|
||||
```sx
|
||||
v := try fetch_local(key) or try fetch_remote(key);
|
||||
// try local; if it fails, try remote; if both fail, propagate
|
||||
```
|
||||
|
||||
Each attempt is a `try`; if all fail, the last error propagates (and the
|
||||
trace records every attempt). Mix in a terminator to never fail:
|
||||
|
||||
```sx
|
||||
v := try fetch_local(key) or try fetch_remote(key) or default_value;
|
||||
// try both; fall back to default if both fail — never propagates
|
||||
```
|
||||
|
||||
> `or` is the same operator sx uses for optional fallback. It binds
|
||||
> looser than `try`, and chains left-to-right.
|
||||
|
||||
---
|
||||
|
||||
## Handling with `catch`
|
||||
|
||||
`catch` handles an error inline and produces a value (or diverts
|
||||
control). The bound name (`catch e`) is the error tag:
|
||||
|
||||
```sx
|
||||
v := parse_int(s) catch e {
|
||||
log.warn("bad input '{}': {}", s, e);
|
||||
return -1; // bail out of the enclosing function
|
||||
};
|
||||
```
|
||||
|
||||
The catch body either produces a value of the success type, or diverges
|
||||
(`return`, `raise`, `break`, `continue`, `unreachable`).
|
||||
|
||||
### Ignore the error
|
||||
|
||||
Omit the binding entirely (the body must be braced):
|
||||
|
||||
```sx
|
||||
flush(buf) catch { }; // attempt it; ignore any failure
|
||||
```
|
||||
|
||||
### Dispatch on the tag — the `catch e == { }` form
|
||||
|
||||
When you want to handle specific tags differently, use the match form —
|
||||
it's sugar for `catch e { if e == { ... } }`:
|
||||
|
||||
```sx
|
||||
v := parse_int(s) catch e == {
|
||||
case .Empty: 0;
|
||||
case .BadDigit: -1;
|
||||
else: raise e; // forward the rest
|
||||
};
|
||||
```
|
||||
|
||||
### Multi-value catch
|
||||
|
||||
If the function returns multiple values, the catch body produces a
|
||||
tuple:
|
||||
|
||||
```sx
|
||||
v, n := parse_pair(s) catch e {
|
||||
log.warn("parse failed: {}", e);
|
||||
(0, 0)
|
||||
};
|
||||
```
|
||||
|
||||
### Comparing tags
|
||||
|
||||
Error tags compare with other tags and `error.X` literals — never with
|
||||
raw integers (tag ids are an internal detail):
|
||||
|
||||
```sx
|
||||
if e == error.Empty { ... } // OK
|
||||
if e == 42 { ... } // ERROR — compare against a tag
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cleanup: `defer` and `onfail`
|
||||
|
||||
Both register cleanup that runs when a block exits. The difference is
|
||||
*when*:
|
||||
|
||||
- **`defer`** runs on **every** exit — success or failure.
|
||||
- **`onfail`** runs **only** when an error leaves the block.
|
||||
|
||||
### Use `defer` for unconditional cleanup
|
||||
|
||||
```sx
|
||||
process_file :: (path: string) -> ! {
|
||||
f := try open(path);
|
||||
defer close(f); // always close, success or fail
|
||||
try process(try read_all(f));
|
||||
}
|
||||
```
|
||||
|
||||
### Use `onfail` for "undo on failure" — when ownership transfers on success
|
||||
|
||||
The classic case is a constructor that hands the resource to its caller
|
||||
on success, but must clean up if a later step fails:
|
||||
|
||||
```sx
|
||||
make_handle :: () -> (Handle, !) {
|
||||
h := try sys_open();
|
||||
onfail sys_close(h); // close only if a LATER step fails
|
||||
|
||||
try configure(h);
|
||||
try register(h);
|
||||
return h; // success: onfail is skipped — caller owns h
|
||||
}
|
||||
```
|
||||
|
||||
If `configure` or `register` fails, `sys_close(h)` runs and the error
|
||||
propagates. On success, `onfail` is skipped — `h` belongs to the caller
|
||||
now. Using `defer` here would be a bug: it'd close the handle you just
|
||||
handed out.
|
||||
|
||||
`onfail` can bind the in-flight tag and is block-scoped — it fires when
|
||||
an error leaves *its* block, even if a caller later catches that error:
|
||||
|
||||
```sx
|
||||
v := (try {
|
||||
h := try open();
|
||||
onfail close(h); // scoped to this block
|
||||
try use(h);
|
||||
42
|
||||
}) catch { default };
|
||||
// use() fails → close(h) runs (cleanup happens) → catch absorbs → default
|
||||
```
|
||||
|
||||
### Cleanup that can itself fail
|
||||
|
||||
Cleanup routines are often failable too. Inside a `defer`/`onfail` body
|
||||
you can't `try` or `raise` (cleanup can't propagate — you're already
|
||||
unwinding), so absorb the error locally:
|
||||
|
||||
```sx
|
||||
onfail {
|
||||
close(h) catch { }; // ignore a failed close
|
||||
flush(buf) catch fe { log.warn("flush failed: {}", fe); };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When something fails: error traces
|
||||
|
||||
In debug builds, sx records a **return trace** — the path an error took
|
||||
from its `raise` site up through every `try` that propagated it. Print
|
||||
it from a handler:
|
||||
|
||||
```sx
|
||||
v := parse(s) catch e {
|
||||
log.error("parse failed: {}", e);
|
||||
trace.print_current();
|
||||
return default;
|
||||
};
|
||||
```
|
||||
|
||||
```
|
||||
error trace:
|
||||
raised error.BadDigit
|
||||
at parse_digit (parse.sx:12:5)
|
||||
at parse_int (parse.sx:34:13)
|
||||
at handle_line (main.sx:21:8)
|
||||
```
|
||||
|
||||
Traces are on by default in debug builds and compiled out in release
|
||||
(re-enable with `--release-traces`). They cost nothing on the success
|
||||
path. Frame locations resolve through the binary's debug info, so
|
||||
`lldb` / `gdb` work on sx binaries too.
|
||||
|
||||
Interpolating a tag with `{}` prints its **name**, not a number — in
|
||||
every build, including release:
|
||||
|
||||
```sx
|
||||
log.warn("parse failed: {}", e); // → "parse failed: BadDigit"
|
||||
```
|
||||
|
||||
For human-readable context, use `log` on the error path — the tag tells
|
||||
you *what* failed, the log tells you the *details*:
|
||||
|
||||
```sx
|
||||
parse :: (s: string) -> (s32, !) {
|
||||
onfail e { log.warn("parsing {}: {}", s, e); }
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `main` and exit codes
|
||||
|
||||
`main` may be void or return an integer, and may be failable:
|
||||
|
||||
```sx
|
||||
main :: () { ... } // exit 0 on success
|
||||
main :: () -> u8 { return 42; } // exit code 42
|
||||
main :: () -> ! { ... } // exit 0, or 1 + trace on an unhandled error
|
||||
main :: () -> (u8, !) { ... } // exit code on success; 1 on error
|
||||
```
|
||||
|
||||
If a failable `main` exits via an error, sx prints the formatted trace
|
||||
and the tag to stderr and exits with code `1`.
|
||||
|
||||
For explicit, shell-friendly exit codes anywhere in the program, call
|
||||
`process.exit`:
|
||||
|
||||
```sx
|
||||
process :: #import "modules/process.sx";
|
||||
|
||||
main :: () -> ! {
|
||||
if bad_args process.exit(64); // EX_USAGE — immediate, bypasses the error system
|
||||
try run();
|
||||
}
|
||||
```
|
||||
|
||||
`process.exit` is a final stop: it does not run `defer`/`onfail` and does
|
||||
not propagate. Use it for deliberate termination, not for recoverable
|
||||
errors.
|
||||
|
||||
---
|
||||
|
||||
## Patterns
|
||||
|
||||
### Resource acquisition
|
||||
|
||||
```sx
|
||||
open_db :: (url: string) -> (Conn, !DbErr) {
|
||||
c := try connect(url);
|
||||
onfail disconnect(c);
|
||||
try authenticate(c);
|
||||
try select_schema(c);
|
||||
return c; // caller owns the live connection
|
||||
}
|
||||
```
|
||||
|
||||
### Selective handling, forward the rest
|
||||
|
||||
```sx
|
||||
load :: (path: string) -> (Data, !) {
|
||||
return read(path) catch e == {
|
||||
case .NotFound: try read(fallback_path); // recover one case
|
||||
else: raise e; // forward the rest
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Fallible pipeline
|
||||
|
||||
```sx
|
||||
// `|>` threads a value through stages; mark each fallible stage
|
||||
n := try parse(s) |> try validate() |> try normalize();
|
||||
```
|
||||
|
||||
### Validate-and-collect
|
||||
|
||||
```sx
|
||||
parse_config :: (src: string) -> (Config, !ParseErr) {
|
||||
return Config.{
|
||||
name = try field(src, "name"),
|
||||
port = try field_int(src, "port"),
|
||||
host = try field(src, "host"),
|
||||
};
|
||||
// first failing field short-circuits — no partial Config escapes
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rules of thumb
|
||||
|
||||
- **Add `!` when a function can fail.** Use a named set for public
|
||||
contracts, bare `!` for internal helpers.
|
||||
- **`raise` to fail, `try` to propagate, `catch` to handle, `or` to
|
||||
fall back.**
|
||||
- **Every failable call needs a marker** (`try` / `catch` / `or` /
|
||||
destructure). If you forget, the compiler tells you exactly where.
|
||||
- **`defer` always runs; `onfail` runs only on error.** Reach for
|
||||
`onfail` when success transfers ownership.
|
||||
- **Cleanup can't propagate** — absorb failable cleanup with `catch` /
|
||||
`or`.
|
||||
- **Tags are identities, not data** — log for context; compare tags to
|
||||
tags, never to raw integers.
|
||||
- **Traces are free in release** (compiled out) and automatic in debug.
|
||||
Reference in New Issue
Block a user