Condition/Restart in Ruby


Ruby is my favorite programming language. Now, I’ll readily admit it’s not the right choice for many projects, but its syntax is so great. I feel like I can go from thought to code faster in Ruby than any other programming language (including Weave!)

But there’s one thing that Ruby (and many other languages!) could improve - error handling.

Ruby, like many other languages, relies primarily on Exception handling as its error-handling approach of choice.

Exceptions are.. well, they’re fine. By now, most everyone knows how to use them, so if you add Exceptions to your language, everyone pretty much knows how to write their error handlers immediately. Some languages of course, forego Exceptions - and error handling constructs - entirely. (C and Go being popular examples.) In such languages, functions must return results and/or an error on their single output channel - the returned data itself.

But what if I told you there are more than two ways to handle errors.

LISP still doing it best

LISP is one of the oldest programming languages in existence, first released in 1954. And it’s also ridiculously flexible. Via macros, programmers can extend the syntax of the language, allowing them to freely experiment with different ways to express their programs’ behavior. Small wonder then, that the best method of handling errors comes to us via LISP as well.

Condition/Restart.

And since Ruby is an Acceptable LISP I thought maybe I’d try my hand at recreating LISP’s superior error handling in Ruby itself!

Restarting instead of Throwing Up

Imagine you’ve been assigned to staple some pages together. You get through three sets - ka-chunk - ka-chunk - ka-chunk - and then - ka-click - the stapler runs out of staples. Quel horreur! Now, you could choose to give up, tear up all your work thus far and lie on the floor kicking your legs in the air, refusing to work until someone else comes over, sets everything back up for you - including a fresh load of staples - …or you could go ask somebody where the staples are so you can finish your task.

Condition/Restart is exactly this second approach - when something goes wrong, get help from someone who knows what to do.

Exception handling is like throwing a tantrum. At the first sign of trouble, the work stops. The program throws up, drops any partially completed work on the floor and immediately aborts, unwinding the stack until a handler catches the exception and does… well, usually something like this:

# File Not Found has a technically accurate, but ugly class name in Ruby. Alias it:
NoSuchFile = Errno::ENOENT

def setup
  config = load_config("./config.toml")
  do_stuff(config)
rescue NoSuchFile => e
  logger.error("Well hells bells, somethin' durn went and blew up on me. #{e}")
  # now what?
  # re-raise? return nil? return a default? Depends. (as usual) But often - a log line before a crashout or work-stop is the 
  # de facto pattern for exception handling.
end

Conversely, Condition/Restart registers “Restart” strategies. When an error (a Condition) occurs, the inner scope (where the error occured) walks the stack looking for a matching Restart handler. (This does not unwind the stack! The inner scope is still there, just looking for help!) When a matching restart is found, the inner scope calls this handler and asks for help. The Restart, being in the outer scope where it has more context about the work-in-progress - sends instructions back to the inner scope so it can continue its work.

Once you see how effective this pattern is at making software more reliable, you’ll wonder why we ever adopted anything else - oh and it’s easy too. In practice, it’s no harder to write that the try/catch block you already know and.. uh, tolerate!

Missing Config

Let’s take a look at an example.

Let’s say that our app has a configuration file. It’s written in TOML (because why would you ever use anything else?) and that we expect it to always contain certain, mandatory, settings. Finally, let’s say that if the file doesn’t exist, we can respond in only one of two ways:

  1. create a new config file with default values set
  2. abort the program.

During application launch, we want to use the first case - we assume it’s a fresh install or we’ve been reset, etc.

If the file is missing during a later check though - abort. Something strange has occurred and we don’t want to make things worse.

Here’s our basic load_config function, sans any error-handling:


def load_config(cfg_file)
   File.open(cfg_file) do |f|
     return TOML.parse(f)
   end
end

Simple enough - but of course, this will still currently raise and crash if cfg_file is missing.

Here’s our default config creator. The contents don’t really matter, just know that the method exists and just dumps some TOML data into the named file.

def create_default_config(cfg_file) 
  File.open(cfg_file, 'w') do |f|
   f.write(TOML.dumps({ foo: 12, bar: 22, baz: ['qux'] })
  end
end

Cool.

Now, note how the load_config method doesn’t have any context to decide if it should create a default or abort if cfg_file is missing?

If we want to handle a File Not Found error inside the load_cfg method, we either guess what to do or just let the exception bubble up.

Frequently, we end up addressing this sort of decision by adding boolean “flag” arguments:


def load_config(cfg_file, create_if_missing)
  if !File.exist?
    if create_if_missing
      create_default_config(cfg_file)
    else
     raise :file_not_found
    end
  else
    File.open(cfg_file) do |f|
      return TOML.parse(f)
    end
  end
end

…which leads to code sprinkled with:

load_config(cfg_file, true)
...
load_config(cfg_file, false)

Without reading the code of the load_config method - what does that flag do? …Who knows! All we can see from the outside is true or false!

We can, of course, clean this up a bit with, say, named-param syntax, …or by injecting an error handler, …or by creating two methods, …or by passing a :symbol flag instead of a boolean flag - all to get around the lack of context available to the code.

Help Signals

…Or we could apply Condition/Restart.

The inner code doesn’t need to change much - instead of taking in a flag parameter, we signal an error Condition and then receive a Restart. A Restart contains the instructions to the erroring code on how to proceed.

Note: I don’t like the term Restart here I feel that it implies the erroring method is going to abort, pop off the stack and then begin again with new inputs. But that’s not the case! Typically, the code just… resumes. That’s why in Weave, I used resume instead of restart as the magic word.

In my Ruby implementation, signal is a method that accepts a Condition as input and returns the Restart:

def load_config(cfg_file)
  if !File.exist?
    # FileNotFoundCond is just a PORO - we don't need anything special:
    case signal(FileNotFoundCond::new(cfg_file))
    when FileNotFoundCond::USE_DEFAULT
      create_default_file(cfg_file)
    when FileNotFoundCond::ABORT
      signal Abort::new("Could not find #{cfg_file} and I don't know what to do!")
    end
  end

  File.open(cfg_file) { |f| return f.read }
end

Check out that FileNotFoundCond class - it’s just a basic, teensy class to let us pass some info around and namespace the restart strategies:

class FileNotFoundCond
  attr_reader :path

  USE_DEFAULT = :use_default
  ABORT = :abort

  def initialize(path)
    @path = path
  end
end

And Abort is likewise - but let’s ignore it for right now:

class Abort
  attr_reader :msg
  def initialize(msg)
    @msg = msg
  end
end

When we call signal - the Condition system looks for a Restart to handle it. It uses a stack, so that we’re able to resolve handlers from the inside -> out. i.e. the closest handler for a Condition wins.

So… how do we register those handlers? Let’s go look at our two outer scopes now, where we’ll declare our handlers:

First, the Startup:

def startup
  cfg_file = "./config.toml"
  
  # Note the order is opposite of try/catch - we declare our error handler blocks, then we have the code to be handled. 
  # Structurally akin to catch { exception_handlers } try { code }!
  # Partially structured like this due to the constraints of working within Ruby syntax
  # ...and partially because this helps differentiate it visually from try/catch syntax.
  restart_with(-> { |cond|
     # this handler Lambda returns either a Restart value or nil, if it doesn't match the Condition
     case cond
     when FileNotFoundCond
       FileNotFoundCond::USE_DEFAULT
     end
  }) do
    load_config(cfg_file)
  end
end

Second, when we’re checking our config at runtime:


def get_config_value(key)
  cfg_file = "./config.toml"

  restart_with( -> { |cond|
    case cond
    when FileNotfoundCond
      FileNotFoundCond::ABORT  # okay, this time, we're not messing around - Halt and Catch Fire
    end
  }) do
    load_config(cfg_file)[key]   # We could also set up Conditions to signal KeyMissing, etc.
  end
end

First off, read restart_with like a catch or rescue handler that precedes the ’try’ block in question instead of following it.

The bigger difference is under the hood - as we’re handling the Condition, signal is scanning the the stack of restart handlers to see if anyone handles our particular condition - but again, we don’t unwind the call stack. Instead, once we find a valid handler for the given key (that’s the lambda we’re handing to restart_with) we pass the result back as the return of signal - sending the strategy right back to the error location, where we can actually do something about it.

Abort

Here, Abort is just a deliberately unhandled Condition. signal never finds a handler for it, reaches the end of the stack and aborts the program. In my Ruby implementation, I actually raise an exception for this (ssshhh) so I can take advantage of the built-in stacktrace support. We even get Ruby-standard error formatting, pointing to the error location

# Just trust me, con_stack.rb:48 is where the AbortCond was signaled in my local implementation.
continue_rb/lib/con_stack.rb:48:in `handle': Could not find config.toml! And I don't know what to do! (ConStack::NoHandler)

In Weave, of course, Conditions have a stack trace built in by the interpreter.

What Makes This Better?

In Exception-handler languages, neither the caller nor the callee ever has all the information needed to handle an error. The callee knows what it is trying to do - and the state built up on the call stack. The caller knows the context of the task in flight. An exception drops the call stack state and the caller almost never gets a chance to correct or retry issues.

Condition/Restart encourages error handling at the appropriate layer - that is, at the layer where the context is available to actually decide what to do about a given error.

Condition handling is fast - often faster than exceptions! (Python’s a special case here because its exceptions are designed for performance)) Exceptions are slow (typically) because they have to build a lot of state-of-the-world before they start flowing back up the stack. The current call stack, for one, plus all the other metadata that goes into those balls of data. A Condition, OTOH, can be a simple linear-time lookup for a handler, plus a function call. It’s no more overhead than a typical event handler.

Finally - we can extend this pattern to simplify our code by passing in Procs for our Restart strategies. I’ve kept things simple here by passing up simple structs as my Condition objects and returning symbols as the Restarts. But we can just as easily send back a Proc to an inner function!

Now we can implement our load_cfg Restart to expect a Proc - like this:

def load_cfg(cfg_file)
  # The Restart is a callable Proc passed down from the outer context! All we have to do is execute it.
  signal(FileNotFoundCond::new(cfg_file)).call() unless File.exist? cfg_file

  File.open(cfg_file){ |f| f.read() }
end

def setup
  cfg_file = "./config.toml"

  restart_with( -> { |cond|
    case cond
    when FileNotFoundCond
      # Send back a Proc that initializes the default config file contents
      -> { |cfg_file| create_default_file(cfg_file) }
    end
  }) do
    load_cfg(cfg_file)
  end
end

def get_config_value(key)
  cfg_file = "./config.toml"

  restart_with( -> { |cond|
    case cond
    when FileNotFoundCond
      # send back a Proc that aborts - by signaling with the Abort condition!
      -> { |cfg_file| signal Abort("Could not find config file: #{cfg_file}") }
    end
  }) do 
    load_cfg(cfg_file)
  end
end

Now our config reader is fully decoupled from its error handling! Any future cases that come up - maybe creating an empty file, for instance, can be handled at the scope which has the context to make the proper decision - without changing any other code. The scope just sends a Restart with the new Proc.

What’s the Catch? Why not use this Everywhere?

Well… you may have noticed that this doesn’t map to Ruby with the cleanest syntax. My implementation has restart_with take a Proc and yielding to an implicit Block:

def restart_with(handler)
    ConStack.push(handler)
    yield
  ensure
    ConStack.pop
end


restart_with( -> { |cond|
   # handler lambda, with the error handlers enumerated
   case cond
   when FileNotFoundCond
     -> { |f| create_default_file(f) }
   when PermissionDeniedCond
     -> { |f| Abort::new("Access dened to #{f}") }
   end
  }) do # This could also be }){... but that way madness lies.
    # code under test, wrapped in a do block, right after just }) ... it looks clunky!
    load_cfg(cfg_file)
  end
end

In Weave, by comparison, the equivalent syntax reads a little cleaner:

fn setup { 
  cfg_file = "./config.toml"
  handle {
    file_not_found: ^(src, cond) { c.resume(:use_default) }
    permission_denied: ^(src, cond) { c.abort() }
  } for {
    # `handle` {} `for` {} keywords make the blocks responsibilities a littler clearer. 
    # and I like the read of `handle { :errors } for { :code }`
    read(cfg_file, :toml)
  }
}

In Weave, the multiple Conditions become a list of Conditions and their Restart handlers. In Ruby - well, at least in my implementation - the list of Condition/Restarts is addressed in the handlers’ case/when blocks - but there’s nothing enforcing that. You could as easily write a bunch of nested if/else statements or whatever else you want. The language doesn’t have syntax support for Conditions, so I ended up making something that looks sorta right and works, but the syntax of function(lambda{}) do code end is busy and doesn’t parse easily.

The other issue is frankly the ecosystem.

Everything in the Ruby ecosystem uses Exceptions. Even if you adopt Condition/Restart code for all your future Ruby projects - any Gem you use is going to be raising Exceptions, not signaling Conditions. Best case, you end up with wrappers everywhere in your code, trying to catch and convert every Exception caught into a Condition.

Conversely, if you’re vending a Gem - can you imagine the hubris of saying “I don’t use Exceptions - you need to install ConditionsRB to handle my errors”? Good way to end up with zero users!

Wrap up

Still - this was a fun thing to build for Ruby. Implementing Condition/Restart at all with a reasonable syntax without needing macros or touching the AST, was a fun challenge.

As ever, Ruby impresses me with its sheer flexibility and expressiveness!