Skip to content

Commit

Permalink
update the guide on v3 error handling.
Browse files Browse the repository at this point in the history
Once the scope API is finalized we'll need to incorporate it into this
  • Loading branch information
cowboyd committed Aug 7, 2023
1 parent b75a910 commit 619f9ee
Showing 1 changed file with 46 additions and 93 deletions.
139 changes: 46 additions & 93 deletions www/docs/errors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,30 @@ react to failure conditions.

## Tasks as Promises

Every Effection operation runs within a Task, when you call `run` or use the
`spawn` operation, you create a task. For each `yield` point, Effecion creates
a task for you. Every Task evaluates to one of three possible states, the task
can either become `completed`, meaning it finished normally, or it can become
`errored` meaning the task itself or one of its descendants had an error
thrown, or it can become `halted`, meaning that the evalutation of the task was
stopped before it finished.
Whenever you call `run` or use the `spawn` operation, Effection
creates a [`Task`][task] for you. This value is a handle that lets you
both respond to the operation's outcome as well as stop its execution.

We have seen that tasks can act like a `Promise` and that this interface can
be used to integrate Effection code into promise based or async/await code:
As we have seen, a Task is not only an Operation that yields the
result of the operation it is running, it is also a promise that can
be used to integrate Effection code into promise based or async/await
code. That promise will resolve when the operation completes successfully:

``` typescript
import { run } from 'effection';
import { run, sleep } from 'effection';

async function runExample() {
let value = await run(function*() {
yield sleep(1000);
yield* sleep(1000);
return "world!";
});

console.log("hello", value);
}
```

Tasks can also act like a [Future][], which can be useful sometimes.

Of course if we throw an error inside of the task, then the task's promise
also becomes rejected:
By the same token, if a task's operation results in an error, then the
task's promise also becomes rejected:

``` typescript
import { run } from 'effection';
Expand All @@ -53,51 +49,59 @@ async function runExample() {
}
```

When treating a task as a promise, if the task becomes halted, the promise is
rejected with a special halt error:
However, when an operation is halted, it will never produce a value,
nor will it ever raise an error. And, because it will produce neither
a positive nor negative outcome, it is an error to `await` the result
of a halted operation.

When this happens, the promise is rejected with a special halt error:

``` typescript
import { run } from 'effection';
import { run, suspend } from 'effection';

async function runExample() {
try {
let task = run();
task.halt();
let task = run(supsend);
await task.halt();
await task;
} catch(err) {
console.log("got error", err.message) // => "got error halted"
}
}
```

Notice how it is not an error to `await` the halt operation itself, only to
`await` the outcome of a halted operation.

## Error propagation

One of the key principles of structured concurrency is that when a child fails,
the parent should fail as well. In Effection, when we spawn a task, that task
becomes linked to its parent. When the child task becomes `errored`, it will
also cause its parent to become `errored`.
becomes linked to its parent. When the child operation fails, it will
also cause its parent to fail.

This is similar to the intuition you've built up about how synchronous code
works: if an error is thrown in a function, that error propagates up the stack
and causes the entire stack to fail, until someone catches the error.

One of the innovations of async/await code over plain promises and callbacks,
is that you can use regular error handling with `try/catch`, instead of using special
error handling constructs. This makes asynchronous code look and feel more like
regular synchronous codes. The same is true in Effection, we can use `try/catch`
One of the innovations of async/await code over plain promises and
callbacks, is that you can use regular error handling with
`try/catch`, instead of using special error handling constructs. This
makes asynchronous code look and feel more like regular synchronous
code. The same is true in Effection where we can use a regular `try/catch`
to deal with errors.

``` typescript
import { main, sleep } from 'effection';

function* tickingBomb() {
yield sleep(1000);
yield* sleep(1000);
throw new Error('boom');
}

main(function*() {
await main(function*() {
try {
yield tickingBomb()
yield* tickingBomb()
} catch(err) {
console.log("it blew up:", err.message);
}
Expand All @@ -108,22 +112,22 @@ However, note that something interesting happens when we instead `spawn` the
`tickingBomb` operation:

``` typescript
import { main } from 'effection';
import { main, suspend } from 'effection';
import { tickingBomb } from './ticking-bomb';

main(function*() {
yield spawn(tickingBomb());
await main(function*() {
yield* spawn(tickingBomb);
try {
yield; // sleep forever
yield* suspend(); // sleep forever
} catch(err) {
console.log("it blew up:", err.message);
}
});
```

You might be surprised that we do *not* enter the catch handler here! Instead,
You might be surprised that we do *not* enter the catch handler here. Instead,
our entire main task just fails. This is by design! We are only allowed to
catch errors thrown by whatever we yield to directly, not by any spawned
catch errors thrown by whatever we yield to directly, _not_ by any spawned
children or resources running in the background. This makes error handling more
predictable, since our catch block will not receive errors from any background
task, we're better able to specify which errors we actually want to deal with.
Expand All @@ -135,71 +139,20 @@ we need to introduce an intermediate task which allows us to bring the error int
the foreground. We call this pattern an "error boundary":

``` typescript
import { main } from 'effection';
import { main, call, spawn, suspend } from 'effection';
import { tickingBomb } from './ticking-bomb';

main(function*() {
try {
yield function*() { // error boundary
yield spawn(tickingBomb()); // will blow up in the background
yield; // sleep forever
}
yield* call(function*() { // error boundary
yield* spawn(tickingBomb); // will blow up in the background
yield* suspend(); // sleep forever
});
} catch(err) {
console.log("it blew up:", err.message);
}
});
```

## Stack traces

We have already seen using the `main` entry point to run our code when we build
our entire application using Effection. One advantage of `main` over `run` is that
when our operation fails, we exit the program with a proper failure exit code. Additionally
`main` prints a nicely formatted stack trace for us on failure:

![Error when using main](/images/no-main-error.png)

We can see that in addition to the regular stack trace of our program, we also
receive an "Effection trace". This gives some context on where the error
occurred within the structure of our Effection code. To make this as useful as
possible, you can apply [Labels][] to your operations, which will be shown in
this trace.

## MainError

There are cases where we want the program to exit, but it might be due to user
error, rather than an internal failure. In this case we might not want to print
a stack trace. For example, if we're building a CLI which reads a file, and the
file that the user has specified does not exist, then we might want to show a
message to the user, but there is no need to show a stack trace. Additonally,
we might want to set a specific exit code.

Effection ships with a special error type, `MainError`, which works together
with `main` for these types of situations:

``` typescript
import { main, MainError } from 'effection';
import { promises as fs } from 'fs';

const { readFile } = fs;

main(function*() {
let fileName = process.argv[2];
try {
let content = yield readFile(fileName);
console.log(content.reverse().toString());
} catch(err) {
throw new MainError({ message: `no such file ${fileName}`, exitCode: 200 });
}
});
```

When using `MainError` the stack trace will not be printed and the exit code
specified in the error will be used. This is what it looks like when we try to
read a file which does not exist:

![Using main error](/images/main-error.png)

[Future]: /docs/guides/futures
[Resource]: /docs/guides/resources
[Labels]: /docs/guides/labels
[Resource]: ./resources
[task]: https://deno.land/x/effection/mod.ts?s=Task

0 comments on commit 619f9ee

Please sign in to comment.