Error Handling

Error Handling

Error handling in Weave uses a condition/restart system inspired by Common Lisp. The syntax looks sort of like Exception handling:


# In Weave, you set up your error handler first - but it's sort of like a 'catch' block:
handle {
  file_not_found: ^(src, c) { c.resume(:skip) }
} for {
  # ...and this is the code whose errors are being handled. This is sort of the "try" section.
  read("data.csv", :csv) *> process_data_row
}

So… what’s the difference?

Handle This For Me

Instead of trying some code and catching an error, in Weave, you register a handler for error conditions.

When you encounter an error, exceptions stop your program. Your program halts, gathers up stack information and then rewinds up the stack until reaching the catch block (if any). The catch block might have some retry logic - or it might not.

With conditions, the stack doesn’t get rewound - instead, the reported error reaches out to the registered handler (if any) and asks how to restart (Well, resume in Weave) the function. This allows the outer-context handler to tell the inner-context code how to proceed after encountering an error. No stack rewinds necessary - the inner context asks for help, the outer context tell it how to proceed, and your program is able to continue on.

Report Your Condition

report signals that something unusual has happened. Your code reports the error condition (a symbol) and provides a block of named recovery strategies:

report(:file_not_found, path: "config.toml") {
    abort: ^() { exit(1) },
    skip: ^() { [] },
    create: ^(default) { write("config.toml", default, :toml); default }
}

Each strategy is a lambda (or function) that resolves the problem in a different way. The code that reports the condition defines what recoveries are possible and waits for a handler to tell it how to continue.

You can assign report just like any other expression. Its value is whatever the chosen strategy returns:

content = report(:file_not_found, path: filename) {
    abort: ^() { exit(1) }, # The program exits, so content never gets assigned.
    skip: ^() { "" },  # content will be an empty string
    create: ^(default) { write(filename, default, :csv); default }  # Content will hold whatever was sent back in `default`
}

Handling Complexity

Each handler function must accept two arguments:

  • source — a symbol identifying the function within the for block that led to the condition (e.g. :load_config). This tells you which call path triggered it within the scope of the for block. More on that later.
  • c — a context container with metadata about the condition.

Sometimes a strategy may require arguments, such as in the case of create above - in that scenario, the handler passes that data as extra arguments to the resume function. e.g. c.resume(:create, [ port: 8080, host: "localhost" ] )

handle is also an expression. It returns the result of its for block.

A Complete Example

Suppose you’re loading configuration from a file, with a fallback:

from os import file_exists

fn load_config(path) {
    # Happy path first - if the file exists, read it and return
    if file_exists(path) { return read(path, :toml) }

    # Report an error and wait for further instructions!
    report(:file_not_found, path: path) {
        abort:        ^() { exit(1) },
        use_defaults: ^() { [host: "localhost", port: 8080] },
        create:       ^(defaults) {
                          write(path, defaults, :toml)
                         defaults
                      }
    }
}

# You can predefine a handler function too:
fn use_default_cfg(src, c) {
    puts("Config not found at {c[:path]}, using defaults...")
    c.resume(:use_defaults)
}

# Now, we can request the config inline with our error handler and we're ready to safely load config
config = handle { 
       file_not_found: use_default_cfg
    } for {
       load_config("app.toml")
    }

puts("Running on {config[:host]}:{config[:port]}")

load_config can’t decide what to do when the file is missing. It doesn’t have the context - but it can say “this happened, here are your options.” The calling code picks :use_defaults. If a different caller wanted to create a new function with specific defaults, it could pick :create instead.

The Context Container

The c argument passed to handlers is a container with these fields:

Field Description
c.type The condition type symbol (e.g. :file_not_found)
c.stack The call stack as a container of function name symbols
c.resume Lambda to invoke a strategy — call as c.resume(:strategy, args...)
user keys Any key-value pairs passed to report (e.g. c[:path])

The metadata keys you pass to report are available on c, so handlers can make informed decisions:

handle {
    parse_error: ^(source, c) {
        puts("Parse error in {c[:file]} at line {c[:line]}")
        c.resume(:skip)
    }
} for {
    report(:parse_error, file: "data.csv", line: 42) {
        skip: ^() { [] },
        abort: ^() { exit(1) }
    }
}

Conditions as Notification

Your reporting code doesn’t have to provide any error handling strategies at all. Since we don’t unwind the stack, you can use Conditions to send signals to other parts of the codebase without the intervening code needing to wire it up!

For instance - Conditions can be used to implement logging:

report(:info, msg: "Everything's fine here, how are you?")

Meanwhile, up at the top of your stack, you can establish any sort of logging handler you want:

fn echo_logger(func) {
  handle {
     info: ^(_, c) { puts(c.msg) }
  } for {
    func()
  }
}

fn main() {
...
}

# launch main() using the echo logging system.
echo_logger(main)  

So long as the handler and the callers agree on the names for log notifications, you can swap out your handlers without any other code needing to change.

Notifications differ from recovery conditions in one important way: if no handler catches them, execution continues silently. That way, your program wont stop just because your instrumentation system hasn’t been set up yet.

fn process(data) {
    report(:processing_started, count: data.len)
    data *> ^(item) { transform(item) }
}

# Without a handler, the notification is simply ignored
process(my_data)

# Or attach a handler to observe it
handle {
    processing_started: ^(source, c) {
        puts("Processing {c[:count]} items...")
    }
} for {
    process(my_data)
}

Catch-All Handlers

Use _: to handle any condition type that doesn’t have a specific handler:

handle {
    file_not_found: ^(source, c) { c.resume(:skip) },
    _: ^(source, c) {
        puts("Unexpected condition: {c.type}")
        c.resume(:abort)
    }
} for {
    risky_operation()
}

Specific handlers are checked first. The catch-all only runs if no specific handler matches.

Nesting Handlers

Handlers can nest. If an inner handler doesn’t call c.resume(), it declines the condition and the search continues with outer handlers:

handle {
    # Outer handler - if we get here, give up.
    file_not_found: ^(source, c) {
        puts("Outer handler caught it")
        c.resume(:abort)
    }
} for {
    handle {
        # inner handler - we can only handle the error if it came from 'load_cache'.
        file_not_found: ^(source, c) {
            if source == :load_cache {
                c.resume(:skip)    # Handle it
            }
            # For anything else, return without resume — decline
        }
    } for {
        load_cache |> load_config    # Either method could report :file_not_found - but we can only handle it from load_cache
    }
}

The inner handler only handles :file_not_found from :load_cache. For any other source, it declines by returning without calling c.resume(), and the outer handler gets a chance.

If no handler at any level handles a recovery condition, Weave aborts with a message listing the available strategies:

Unhandled condition: :file_not_found
  Available strategies: :abort, :skip, :create

Thus, if you want your programs to not stop, you need to include some handlers!

Wait… What about source?

Source - that first argument to the handler methods is kind of strange to explain, but easier to reason about in practice.

Let’s say we have a handler like so:

from sqlish import select, join, where
from datetime import today, start_of_month

handle {
   file_not_found: ^(src, c) {...}
} for {
  customers = load_customers
  orders = today |> start_of_month |> load_orders
  customers |> join(orders, ^(c, o) { c.id == o.customer_id }) 
            |> select("name", "order_num", "amount", "date")
            |> where(^(r) { r.amount > 300 && r.date > "2025-01-01" } )
}

Okay, so if customers aren’t found - we have a bigger issue and need to stop. If orders aren’t found, maybe we just haven’t gotten any yet. So, in the “no customers” case, we want to abort and alert the users. In the “no orders” case, we can just return an empty list and proceed - the program will continue just fine.

But when we get :file_not_found - how do we know which function raised the error?

One way would be to wrap each call in its own handle block, but that gets pretty clunky fast, especially if we need to handle multiple error conditions.

Instead, we can check source to see which function in the for block triggered the error:

from iter import dispatch

handle {
  file_not_found: ^(src, c) {
     src |> dispatch([      # this is sort of like a switch statement - condition -> handler function pairs.
         ^(s) { s == :load_customers }, ^(s) { c.resume(:abort) }, # abort if load_customers fails!
         ^(s) { s == :load_orders },    ^(s) { c.resume(:skip) }   # This one's okay!
    ])
  }
} for {
  customers = load_customers
  orders = today |> start_of_month |> load_orders
  customers |> join(orders, ^(c, o) { c.id == o.customer_id })
            |> select("name", "order_num", "amount", "date")
            |> where(^(r) { r.amount > 300 && r.date > "2025-01-01" } )
}

One final note - I’ve been fairly explicit here to make things hopefully easier to understand, but one of the fun things about Weave is that it’s really easy to build up your own micro-DSLs to make things cleaner. For instance:

from iter import dispatch

when_from = ^(target) { ^(src) { src == target } } # returns a lambda: ^(src) { src == ? } to be used with dispatch
otherwise = ^(_) { true }   # a catch-all lambda for dispatch

handle {
   file_not_found: ^(src, c) {
     src |> dispatch([
        when_from(:load_customers), ^(_) { c.resume(:abort) },
        otherwise,                  ^(_) { c.resume(:skip)  }
     ])
   }
} for {
  ...
}