OxL Reference

Error patterns

An error as an enum#

Task. Describe several reasons for refusal with one type and handle them in one match.

oxl✓ checked in CI
@error enum PayError {
  Declined(reason: str)
  Fraud
  LimitExceeded(max: i64)
}

fn charge(amount: i64) -> i64 | PayError {
  if amount > 1000 { fail PayError.LimitExceeded(1000) }
  if amount == 13 { fail PayError.Fraud }
  if amount < 0 { fail PayError.Declined("negative amount") }
  amount
}

fn describe(r: i64 | PayError) -> str =>
  match r {
    i64 n => "charged {n}"
    PayError.Declined(reason) => "declined: {reason}"  // variants of an error enum are matched like any others
    PayError.Fraud => "fraud suspected"
    PayError.LimitExceeded(max) => "over the limit of {max}"
  }

fn main() io {
  for a in [50, 13, -5, 5000] { print(describe(charge(a))) }
}
Output
charged 50
fraud suspected
declined: negative amount
over the limit of 1000
  • @error enum PayError { Declined(reason: str); Fraud; LimitExceeded(max: i64) } is one "family" of errors; the variants carry data.
  • The function returns i64 | PayError, and match takes apart both success and every variant. A forgotten variant is non-exhaustive.
  • When to use an enum and when several @error data: an enum if the caller always handles all reasons together; separate types if different callers care about different errors and the union grows along the call chain.

Retry with a delay#

Task. Repeat an unstable operation several times without repeating forever.

oxl✓ checked in CI
@error data Flaky { attempt: i32 }

fn unreliable(attempt: i32) -> str | Flaky {
  if attempt < 3 { fail Flaky{ attempt } }
  "ok on attempt {attempt}"
}

fn withRetry(max: i32) -> str | Flaky  io, time {
  var attempt = 1
  loop {
    match unreliable(attempt) {
      str s => break s
      Flaky e => {
        if attempt >= max { fail e }  // out of attempts: return the last error
        print("attempt {attempt} failed, retrying")
        sleep(Duration.ofMillis(10 * attempt as i64))  // a linear delay between attempts
        attempt += 1
      }
    }
  }
}

fn main() io, time {
  match withRetry(5) {
    str s => print(s)
    Flaky e => print("gave up after {e.attempt}")
  }
  match withRetry(2) {
    str s => print(s)
    Flaky e => print("gave up after {e.attempt}")
  }
}
Output
attempt 1 failed, retrying
attempt 2 failed, retrying
ok on attempt 3
attempt 1 failed, retrying
gave up after 2
  • loop { … break value } is a loop expression: break s yields the successful value, fail e the last error when the attempts run out.
  • The delay grows with the attempt number (10 * attempt ms); for network rpc the same is done declaratively with the attributes @retry(n, backoff: …) - but only for @idempotent and @readonly methods, so that a retry does not perform an action twice.
  • Pitfall: retry only transient errors (Timeout, Unavailable). A retry will not fix "invalid input".

A fallback path#

Task. If a value is not in the cache, take it from the database; if the database is down, return an error.

oxl✓ checked in CI
@error data CacheMiss { key: str }
@error data DbDown { why: str }

fn fromCache(key: str) -> str | CacheMiss {
  if key == "a" { return "cached-a" }
  fail CacheMiss{ key }
}

fn fromDb(key: str) -> str | DbDown {
  if key == "c" { fail DbDown{ why: "connection refused" } }
  "db-{key}"
}

fn load(key: str) -> str | DbDown {
  await fromCache(key)
    !> CacheMiss => fromDb(key)?  // !> replaces the error with a fallback path
}

fn main() io {
  for k in ["a", "b", "c"] {
    match load(k) {
      str s => print("{k}: {s}")
      DbDown e => print("{k}: db down ({e.why})")
    }
  }
}
Output
a: cached-a
b: db-b
c: db down (connection refused)
  • await fromCache(key) !> CacheMiss => fromDb(key)? - the !> handler fires only on CacheMiss; other errors (here DbDown) go further down the chain.
  • The result type of load is str | DbDown: CacheMiss has disappeared from it because it is handled. The compiler checks this.
  • Several handlers can be placed in a row: !> A => … !> B => ….

Releasing resources: defer#

Task. Close a resource for certain, however the function ends.

oxl✓ checked in CI
fn process(name: str) -> str | Failed  io {
  print("open {name}")
  defer print("close {name}")  // defer runs on every way out of the function
  if name == "bad" { fail Failed{} }
  print("work {name}")
  "done {name}"
}

@error data Failed {}

fn main() io {
  for n in ["good", "bad"] {
    match process(n) {
      str s => print(s)
      Failed => print("failed {n}")
    }
  }
}
Output
open good
work good
close good
done good
open bad
close bad
failed bad
  • defer expression runs on any exit from the function: normal, via fail, via ? and even on a panic.
  • Several defers run in reverse order (the last first), like a call stack.
  • Types with @must_close (Server, WebSocket, …) require closing: the compiler reports must-close if you forgot defer x.close().

Back: cookbook.

See also

OxL 1.0.0-beta · Documentation version 1.0.0-beta© OxwaldLink · generated from the Standard, the std sources and the guides