Errors in Weave


Weave is the Programming Language I’ve been developing in my spare time. …If you’ve been reading my blog at all, you’ll know that already.

As I’ve been working on making Weave more of a “Real” language I realized I needed to provide some sort of error handling mechanism. While functions are pure and beautiful, we live in a world where side effects and muck are not only expected, but often required to Get Things Done.

Exceptional Behaviors

I don’t like exceptions. They’re (usually) slow, they can make it hard to tell where something went wrong - and they are very binary. When you raise an exception, you’re done. If your caller both catches the exception and knows how to retry you might be able to get a second lease on life, maybe a retry or two.

We’ve all had code that was something like this:

loop {
  try {
    return some_operation()
  } catch RetriableException as re {
    sleep(a_while)
  } catch Exception as e {
    halt_and_catch_fire() 
  }
}

Attempt an operation, if it fails in a specific way, sleep and retry. And honestly, this example isn’t too bad. It’s catching a (presumably) well-named exception and handling it in a simple manner.

What about something more ambiguous? What if you’re a function like this:

def load_file(f) { 
  try {
    read(open(f, read))
  catch FileNotFoundException as fnf {
    # ...now what?
  }
}

It often happens that the functions closest to an error have the least context for handling it. In load_file, we don’t know why this file is being requested or what it’s for. We know that it wasn’t there when we looked for it and now, we have to choose - do we try to gracefully degrade and cover the error, or should we fail fast and ensure that alarms are raised?

It’s an impossible task.

The result of such conflicts in context is the proliferation of similar-but-slightly-different load_file methods, which will each take their own approach to error handling. Perhaps load_config_file will create a default config if the file does not currently exist, while load_user_encryption_key aborts entirely if the file doesn’t exist.

Still, the only way of handling errors is Exceptions or by returning error codes like C, or error tuples like Go (or Rust, to a slightly lesser extent). …right?

Emergency Flair

What if I told you that there’s a method of handling error conditions that’s faster than Exceptions, cleaner than error code checks and more powerful than both? …and that it was invented in LISP?

Okay, that last one’s probably no great surprise, other than the idea that LISP’s error handling approach been ignored in favor of slower, clunkier options.

In LISP, when an exception occurs, you don’t rewind the stack, aborting everything you had in progress - instead, your function asks for help. It sends up a signal flare to the higher scope and says, “I encountered this problem, what should I do?”. Then, still without aborting the stack, a handler registered earlier will send back a response, telling the function how to proceed.

A little vague? Let’s look at some Weave!

Handle this For me

The first thing to note is how we handle errors… is by registering handlers and giving them a scope in which to, uh, handle things.

handle {
  file_not_found: ^(_, c) {  # don't worry about that first arg just yet - we'll get there!
    c.resume(:create)
  }
} for {
  "profile_stats.csv" |> load_file
}

A few things of note:

  • Weave error handlers are registered before calling the code that might encounter errors. Think of handle like a catch block that comes before you try running risky code…. kind of.
  • c.resume(:create) - this is where we first see some of the power of this method of handling errors. c in this case is a standard Weave Container. We’re using dot-notation to access a function that’s been inserted at :resume. That function call accepts a strategy - a symbol that tells the deeper code how to proceed.

So, how does that :create get wired up? Let’s go take a look at load_file:

from file import exist

fn load_file(f) {
  if exist(f) {
     # Happy path - the file exists, read it and return it.
     read(f, :csv) |> load
  } else {
    report(:file_not_found, file: f) {  # Report a problem and ask for help!
      # list our strategies!
      abort: ^() { exit(1) },                 # Give up and abort the program
      default: ^(def) { def },                # Use default data instead - don't create a file
      create: ^() { write(f, [], :csv); [] }, # create an empty file and return an empty data set
      skip: ^() { [] }                        # skip creation, just return an empty data set
    }
  }
}

Whoa. What the hell is that about?

Well, as established, load_file doesn’t really know what files it’s loading. It doesn’t know why its callers want the files. When a file is present - well hey, it loads and returns the data. All good!

…But if the file is missing, the first thing our poor confused function does now is report :file_not_found has occurred. It attaches some helpful metadata: file: f that the caller may find useful - and then it waits for help.

Back in the handler:

handle {
  file_not_found: ^(_, c) { c.resume(:create) }
}

Hey, look at that, file_not_found is mapped to a function! And that function is calling something called resume with :create - one of the strategies inside load_file!

…I bet you can see where this is going.

Control returns to load_file, right where we left off - well, almost. We resume the load_file function at the lambda :create is pointing to: ^() { write(f, [], :csv); [] }

The report block finishes evaluating and our [] gets implicitly returned back up the chain to the caller.

No stack unwind! No need for load_data to guess the proper way to handle an error! Our handler tells load_file what to do and our little program can get on with its job!

So, what is that c?

handle gets two arguments - let’s talk about the second one first. c is just a Container. It contains a few special values at keys set by the report flow, but it’s otherwise the same as any other Container. It’s got the following:

  • :resume - a function like fn resume(strategy, **kw_args) - the first value is the strategy you want the function to resume with - :create in our example above, but could also have been :abort, :skip, or :default. The **kw_args are any optional key/value pairs you want to return to the strategy. For instance, :default requires a value to return - we could send it back here as def: some_default_value.
  • :stack - a function like fn stack() - this builds the stack trace to the source of the reported signal if necessary.
  • :strategies - a list of symbols showing the supported strategies a function is expecting. Weave doesn’t enforce a set of strategies, but some are very common: abort, retry, skip, etc.
  • …and then whatever was set by the reporter. For instance, load_file here is setting :file to the provided filename - could be useful context for the handler!

Getting at the Source

Which brings us back to that mysterious first argument to our handlers:

handle {
  file_not_found: ^(source, c) { # Source?
    c.resume(:create) 
  }
} for {...}

The source of a signal (that’s what the error-reporting symbols like :file_not_found are referred to collectively) is not quite the same as its declaration. Consider this code:

fn foo() {
  report(:foo) { bar: ^() {} }  # Reports a :foo signal and waits for a :bar strategy to continue. Otherwise useless though
}

bar = ^() { foo() }

fn main() {
  handle {
    foo: ^(source, c) { c.resume(:bar) }  # what is source here?
  } for {
    bar()
  }
}

main()

When we reach the handler, our call stack will report something like main -> bar -> foo -> report(:foo) - but source will report bar.

That’s because bar is what was called inside the the for block!

This makes it so that we can easily differentiate between different sources of the same signal within a handler.

fn foo() {
  report(:foo) {
    bar: ^() {}  # A silent bar
    baz: ^() { print("BAAAZ") }
  }
}

bar = ^() { foo() }

handle {
  foo: ^(source, c) { 
    if source == :foo { return c.resume(:bar) }
    if source == :bar { return c.resume(:baz) }
  }
} for {
  foo  # reports :foo, resumed with :bar
  bar  # reports :foo, resumed with :baz!
}

This way, we don’t have to divide pipelines or longer functions with multiple handle-for blocks!

This Just Looks Like Exception Handling With Extra Steps

Well, that’s where you’d be wrong, my friend! Because this is just the tip of the iceberg!

By separating

  • error reporting
  • error strategy selection
  • error handling

we can use signals to do things like…

implement logging:

report(:info, "my message") {}  # No resume needed, but the message will be recieved by the registered handler!

Or to inject a callback:

report(:halp) { 
   take_this: ^(callback) { callback() }
}

Or even something completely stupid!

handle {
  count: ^(src, c) { c.resume(:count, c[:val] + 1) }  # Incrementing our count...
} for {
  i = count_with_signals(10000)
  print("I counted to {i} with signals!")
}

fn count_with_signals(n) {
   val = 0
   while val < n {
      report(:count, val: val) {   # reporting the current count
        count: ^(new_val) { val = new_val }  # storing the new value sent by the handler
      }
   }
  val
}

On my circa 2018 Thinkpad X1 Carbon, it takes about 2ms to count to 10K this way. (This terrible, terrible way)

Try doing that with Exceptions.

weave 

See also