Skip to content

Error Handling

When a command “fails”, two things happen:

  1. The command writes a message to standard error explaining what went wrong.
  2. The command exits with a non-zero exit code.

A script that doesn’t check those two things will silently keep running even when an earlier step couldn’t do its job — and you’ll only notice when the next step also fails, in some way that’s harder to diagnose because half the work happened.

lash gives you a small set of tools to handle this:

  • .capture runs a command and returns its stdout, stderr, and exit code as an object — instead of letting the script die.
  • .isFailure / .isSuccess ask whether the exit code was zero.
  • exit "message" stops the script with a message of your choosing.

That’s the whole error-handling vocabulary. There’s no try/catch — errors are values you inspect, not exceptions you catch.

Build a script that backs up a directory to a remote server. Network operations fail — we want to detect failures, show what went wrong, and retry automatically before giving up.

Terminal window
lash --test backup.lash # the unittests only
lash backup.lash --help # auto-generated help

The --test flag runs only the unittest { } blocks; the fn main body is skipped, so the tests don’t need a working network or a real remote server.


Step 1: The naive version and what goes wrong

Section titled “Step 1: The naive version and what goes wrong”

The simplest thing first:

rsync -az --delete ~/Documents user@nas:/backups/docs

If the network is down, this prints a cryptic rsync error and exits. The calling script gets a non-zero exit code but no context about how to recover. Two problems we want to fix: capture the error so we can report it, and retry transient failures.


Step 2: Pure logic first — the back-off schedule

Section titled “Step 2: Pure logic first — the back-off schedule”

Before the I/O, lock down the part that’s pure: how long to wait between retries. We want the wait to grow so transient network issues have time to clear, but not too aggressively.

/// backoff_seconds returns the wait between attempts: linear, 5s base
unittest {
backoff_seconds(1).must.equal(5) // after first failure
backoff_seconds(2).must.equal(10) // after second failure
backoff_seconds(3).must.equal(15) // after third failure
}

Three rows force the linear-growth behaviour. If you wrote the function as a constant 5 seconds, the second test fails. If you wrote it as exponential 2^n * 5, the second row fails. The test pins down exactly the schedule we want.

fn backoff_seconds(attempt) {
return attempt * 5
}

.capture runs a command and returns an object with stdout, stderr, and the exit code — without stopping the script on failure:

let result = `rsync -az --delete ~/Documents user@nas:/backups`.capture
if result.isFailure {
echo "Backup failed:"
(result["stderr"])
}

.isFailure is true when the exit code is non-zero. .isSuccess is the inverse. The $? variable holds the raw exit code of the last command:

grep "pattern" file.txt
echo "exit code: $?" # 0 if found, 1 if not found

Step 4: Reading lash’s own error messages

Section titled “Step 4: Reading lash’s own error messages”

When lash itself reports an error (a typo’d command, a bad chain step), the message has two parts:

  • Problem: A plain-English description of what went wrong.
  • Suggestion: An actionable step to fix it.
$ gti status
Command 'gti' not found.
Did you mean 'git'?
$ `lss -la`.sort()
^^^^^^^^
Command 'lss' not found (exit code 127).
Did you mean 'ls'?

When a chain fails, lash points at the exact step that broke, not just the whole expression:

`cat data.txt`.sort().map(x => x.toNumber()).sum()
# Error points to .map(x => x.toNumber()) with the problematic input line shown

This means you usually don’t have to wrap a chain in .capture just to debug it — running it once tells you exactly where it went wrong.


Before attempting anything, verify the required tools are available:

let check = `which rsync`.capture
if check.isFailure {
exit "rsync is required but not installed"
}

exit "string" prints the message to stderr and exits with code 1. exit <int> exits with that number as the code:

exit "something went wrong" # prints message, exits 1
exit 2 # exits with code 2

The point of exit here is to fail loudly and early. A backup script that silently continues without rsync would produce confusing errors later in the retry loop.


Now wire it together. The structure: try the backup; if it works, we’re done; if it fails and we have attempts left, wait and retry.

fn main(source: string, dest: string, retries: int = 3) {
let check = `which rsync`.capture
if check.isFailure {
exit "rsync is required but not installed"
}
mut attempt = 1
mut done = false
while !done && attempt <= retries {
echo "Attempt $attempt of $retries..."
let result = `rsync -az --delete $source $dest`.capture
if result.isSuccess {
echo "Backup complete."
done = true
} else {
echo "Failed:"
(result["stderr"])
if attempt < retries {
let wait = backoff_seconds(attempt)
echo "Retrying in $wait seconds..."
sleep $wait
}
attempt = attempt + 1
}
}
if !done {
exit "Backup failed after $retries attempts"
}
}

A few things going on:

mut attempt = 1 and mut done = false declare mutable bindings — let would refuse the later reassignments. Counters and state flags are the natural place for mut.

The (result["stderr"]) line in parentheses prints the stderr content. Without parentheses lash would treat it as a chain expression to ignore.

sleep $wait runs the system sleep command for the computed back-off — and because backoff_seconds is now a tested pure function, we trust its output.


#!/usr/bin/env lash
/// Back up a directory to a remote server with automatic retry.
fn main(source: string, dest: string, retries: int = 3) {
let check = `which rsync`.capture
if check.isFailure {
exit "rsync is required but not installed"
}
mut attempt = 1
mut done = false
while !done && attempt <= retries {
echo "Attempt $attempt of $retries..."
let result = `rsync -az --delete $source $dest`.capture
if result.isSuccess {
echo "Backup complete."
done = true
} else {
echo "Failed:"
(result["stderr"])
if attempt < retries {
let wait = backoff_seconds(attempt)
echo "Retrying in $wait seconds..."
sleep $wait
}
attempt = attempt + 1
}
}
if !done {
exit "Backup failed after $retries attempts"
}
}
fn backoff_seconds(attempt) {
return attempt * 5
}
/// backoff_seconds returns the wait between attempts: linear, 5s base
unittest {
backoff_seconds(1).must.equal(5)
backoff_seconds(2).must.equal(10)
backoff_seconds(3).must.equal(15)
}

Run it:

Terminal window
lash --test backup.lash # unittests
lash backup.lash --help # help
lash backup.lash ~/Documents user@nas:/backups/docs # default 3 retries
lash backup.lash ~/Documents user@nas:/backups/docs 5 # 5 retries

The doc-comment plus typed parameters become the --help:

Terminal window
lash backup.lash --help
backup.lash — Back up a directory to a remote server with automatic retry.
Arguments:
source (string)
dest (string)
retries (int) default: 3

Error output formats: lash supports three modes:

Terminal window
lash --error-format json script.lash # machine-readable
lash --error-format ai script.lash # compact, for LLM consumption

Or set permanently via export LASH_ERROR_FORMAT=json.

Permission errors: instead of raw “Permission Denied”, lash explains which file is restricted and how to fix it:

Cannot read '/etc/shadow': permission denied.
Try running with elevated privileges or check file permissions with 'ls -la /etc/shadow'.

Did-you-mean in interactive mode: when you mistype a command, suggestions appear as a navigable list. Up/Down to choose, Enter to submit, Escape to dismiss.