Error Handling
What’s an error?
Section titled “What’s an error?”When a command “fails”, two things happen:
- The command writes a message to standard error explaining what went wrong.
- 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:
.captureruns a command and returns itsstdout,stderr, and exit code as an object — instead of letting the script die..isFailure/.isSuccessask 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.
lash --test backup.lash # the unittests onlylash backup.lash --help # auto-generated helpThe --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/docsIf 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 baseunittest { 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}Step 3: Capture command results
Section titled “Step 3: Capture command results”.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.txtecho "exit code: $?" # 0 if found, 1 if not foundStep 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 statusCommand '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 shownThis 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.
Step 5: Verify prerequisites with exit
Section titled “Step 5: Verify prerequisites with exit”Before attempting anything, verify the required tools are available:
let check = `which rsync`.captureif 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 1exit 2 # exits with code 2The 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.
Step 6: The retry loop in main
Section titled “Step 6: The retry loop in main”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.
Complete script
Section titled “Complete script”#!/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 baseunittest { backoff_seconds(1).must.equal(5) backoff_seconds(2).must.equal(10) backoff_seconds(3).must.equal(15)}Run it:
lash --test backup.lash # unittestslash backup.lash --help # helplash backup.lash ~/Documents user@nas:/backups/docs # default 3 retrieslash backup.lash ~/Documents user@nas:/backups/docs 5 # 5 retriesThe doc-comment plus typed parameters become the --help:
lash backup.lash --helpbackup.lash — Back up a directory to a remote server with automatic retry.
Arguments: source (string) dest (string) retries (int) default: 3Further reading
Section titled “Further reading”Error output formats: lash supports three modes:
lash --error-format json script.lash # machine-readablelash --error-format ai script.lash # compact, for LLM consumptionOr 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.