Effection Logo

Error Handling

We have previously discussed how correctness and proper handling of failure cases is why we wrote Effection in the first place. In this chapter we will take a more in-depth look at how Effection handles failures and how you can react to failure conditions.

Tasks as Promises

Whenever you call run or use the spawn operation, Effection creates a Task for you. This value is a handle that lets you both respond to the operation's outcome as well as stop its execution.

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:

import { run, sleep } from 'effection';

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

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

By the same token, if a task's operation results in an error, then the task's promise also becomes rejected:

import { run } from 'effection';

async function runExample() {
  try {
    await run(function*() {
      throw new Error('boom');
    });
  } catch(err) {
    console.log("got error", err.message) // => "got error boom"
  }
}

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. In this example, we show a very long running task that is stopped in the middle of its work even though it would eventually return a value if we waited long enough.

import { run, sleep } from 'effection';

async function runExample() {
  // this task takes a long time to return
  let task = run(function*() {
    yield* sleep(10_000_000);
    return "hello world";
  });

  await task.halt();

  try {
    let value = await task // 💥 throws "halted" error;

    // will never reach here because halted tasks do not produce values
    console.log(value);
  } catch(err) {
    console.log(err.message) // => "halted"
  }
}

An important point is that task.halt() is an operation in its own right and will only fail if there is a failure in the teardown. Thus it is not an error to await the task.halt() operation; it is only an error to await the outcome of the operation which has been halted.

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 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 code. The same is true in Effection where we can use a regular try/catch to deal with errors.

import { main, sleep } from 'effection';

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

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

However, note that something interesting happens when we instead spawn the tickingBomb operation:

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

await main(function*() {
  yield* spawn(tickingBomb);
  try {
    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, 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 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.

Error boundary

If we do want to catch an error from a spawned task (or from a Resource) then we need to introduce an intermediate task which allows us to bring the error into the foreground. We call this pattern an "error boundary":

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

main(function*() {
  try {
    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);
  }
});
  • PreviousEvents