Skip to content

Errors ​

Docker. try / catch / finally, fail and error, recover, $* for an expected non-zero exit, and how a code becomes the exit code of the process. Then retry_if with a backoff strategy, until for waiting on a state, and timeout.

bash
lask sync
lask eval build-with-retry
lask eval classify --path /nope ; echo "exit $?"
lask run always-fails ; echo "exit $?"
lask eval flaky-with-retry
lask eval wait-for --path /nope ; echo "exit $?"
lask eval too-slow

The thing to take away: static errors are not catchable. lask check finds them before anything is evaluated at all.

To try the commands above, paste the source below into main.lask in an empty directory and run lask sync first, or clone the repository:

bash
git clone https://github.com/lask-task-runner/lask.git
cd lask/example/02-language/07-errors

Source ​

This is example/02-language/07-errors in the lask repository.

lask
// Errors
//
// A failure carries an `Error` — `{code, message}` — and an uncaught one
// becomes the exit code of the process.
//
//   lask sync
//   lask eval build_with_retry
//   lask eval classify --path /nope   ; echo $?

alpine = #alpine:3.22.2

command { "cat", "sh", "test", "sleep" } on alpine

// Build, retrying once when the disk was full.
//
// A command that exits non-zero raises, and the failure carries that
// exit code. `try` catches it, `finally` runs on both paths.
//
// @return Which attempt succeeded.
build_with_retry(): String = try {
  attempt(fail_first = true)
} catch (e) {
  // e: Error = {code: Number, message: String}
  // Not ours: re-raise it unchanged. An `if` without `else` around
  // `fail` stops here, and only the disk-full case goes on.
  if (e.code != 2) { fail(e) }
  log("retrying after #{trim(e.message)}")
  attempt(fail_first = false)
} finally {
  // Runs exactly once, whichever path was taken. Its value is discarded.
  log("cleaning up")
}

internal attempt(--fail_first: Bool = false): String = do {
  if (fail_first) {
    $[alpine] sh -c 'echo "no space left" 1>&2; exit 2'
  }
  "built"
}

// Turn an exit code into a decision.
//
// `fail` raises, and `error(code, message)` builds the value it raises.
// The exit code of `lask run` is that `code`.
//
// @param path  A file that has to exist.
// @example lask eval classify --path /etc/os-release
classify(--path: String = "/etc/os-release"): String = do {
  r = $* cat #{path}
  case {
    r.code == 0 -> "readable"
    r.code == 1 -> fail(error(70, "cannot read #{path}"))
    else        -> fail(error(r.code, "cat failed unexpectedly"))
  }
}

// The size of a file, or 0 when it cannot be read.
//
// The body and the `catch` must have the same type, so a recovery
// supplies a value of that type rather than a different shape.
size_or_zero(--path: String = "/etc/os-release"): Number = try {
  $[alpine] wc -c < #{path}
  |> trim
  |> to_number
} catch (e) {
  log("defaulting to 0: #{e.message}")
  0
}

// `try` / `catch` written as a function call.
//
// `recover(body, handler)` is `try` / `catch` spelled as a function:
// a zero-argument body and a handler taking the `Error`. The return
// type of `fail` comes from its context, and a bare lambda gives it
// none — hence the `: String` on this one.
guarded(): String = recover(
  \(): String -> fail(error(9, "boom")),
  \(e: Error) -> "recovered from #{e.code}"
)

// Ask a yes/no question with a command.
//
// Not every failure deserves an exception. `$*` hands back the
// `CommandResult` and never raises on an exit code, which is the form
// to reach for when a non-zero code is an expected answer rather than
// an error.
has_user(--name: String = "root"): Bool = do {
  r = $* sh -c 'grep -q "^#{name}:" /etc/passwd'
  r.code == 0
}

// Fail from inside a `catch`.
//
// A failure raised inside `catch` or `finally` propagates outward — it
// does not re-enter the same `catch`.
//
// @example lask run always-fails ; echo $?
always_fails(): String = try {
  fail(error(3, "first"))
} catch (e) {
  fail(error(4, "second, raised while handling #{e.code}"))
}

// Retry a step that fails now and then.
//
// A retry strategy is the array of delays, in seconds, before each
// further attempt, so its length is the bound: `backoff_fixed(0.5, 6)`
// is six waits of half a second, seven attempts in all. Once the
// delays run out, the last failure is raised unchanged. `retry_if`
// retries only what the predicate accepts: here the temporary failure
// (75, EX_TEMPFAIL), and nothing else.
//
// @example lask eval flaky-with-retry
flaky_with_retry(): String = retry_if(
  backoff_fixed(0.5, 6),
  \(e: Error) -> e.code == 75,
  \() -> flaky_step()
)

// Succeeds one time in two. A command runs to the end of its line, so
// a command given to retry is wrapped in a function of its own.
internal flaky_step(): String = $ sh -c '[ $((RANDOM % 2)) -eq 0 ] || exit 75; echo done'

// Wait for a state rather than for a failure to go away.
//
// `until(delays, done, body)` evaluates `body()` until `done` accepts
// its value, and returns that value. `$*` never raises, so "not there
// yet" is a value here, not an error. When the delays run out it fails
// with code 124, naming the last value it saw.
//
// @param path  The file to wait for.
// @example lask eval wait-for --path /etc/os-release
// @example lask eval wait-for --path /nope ; echo $?
wait_for(--path: String = "/etc/os-release"): CommandResult = until(
  backoff_exponential(0.25, 2, 3),
  \(r: CommandResult) -> r.code == 0,
  \() -> exists(path)
)

internal exists(path: String): CommandResult = $* test -e #{shell_quote(path)}

// Bound a step in time.
//
// Once the limit passes, the body is abandoned — its command is
// stopped, container and all — and the call fails with code 124, the
// exit status of GNU `timeout`. The failure is raised at the call, so a
// `try` around the call catches it and a `try` inside the body does not.
//
// @example lask eval too-slow
too_slow(): String = try {
  timeout(1, \() -> sleep_for(5))
} catch (e) {
  "gave up: #{e.message}"
}

internal sleep_for(seconds: Number): String = $ sleep #{seconds}

// Static errors are a different thing entirely: `lask check` reports
// them before anything is evaluated, and no `try` can catch them. A
// misspelled name, a wrong argument type or a command with no
// environment never reaches run time at all.

Released under the MIT License.