SCUA splits failure into two kinds, and keeping them apart is most of the job.
Some failures are expected. A charge declines, input doesn't parse, a
lookup misses. These are values: a function hands back a
Result and the caller decides what to do. Other failures
are bugs. A divide by zero, an index off the end, a broken invariant.
These are faults: they unwind the stack until something recovers them,
or they stop the program.
The short version: return a Result for a failure the
caller should handle, raise a fault for a situation that means the code
is wrong.
#Results: failure as a value
A fallible function returns Ok(x) on success or
Error(e) on failure:
fn parse_amount(s)
if s < 0 then return Error("amount cannot be negative") end
return Ok(s)
end
The caller has to deal with both arms, which is the point. The
postfix ? makes the common path short: it unwraps an
Ok and keeps going, or short-circuits by returning the
Error to its own caller.
fn parse_amount(s)
if s < 0 then return Error("amount cannot be negative") end
return Ok(s)
end
fn charge(balance, amount)
let a = parse_amount(amount)? -- unwrap Ok, or return the Error to our caller
if a > balance then return Error("insufficient funds") end
return Ok(balance - a)
end
fn report(balance, amount)
match charge(balance, amount)
Ok(left) -> print(`charged {amount}, {left} left`)
Error(e) -> print(`declined: {e}`)
end
end
report(100, 30)
report(100, 200)
report(100, -5)
Run it:
$ scua charge.scua
charged 30, 70 left
declined: insufficient funds
declined: amount cannot be negative
? is how an error from parse_amount flows
out through charge without a manual check at every level.
At the top you consume the Result with match, handling
Ok and Error explicitly.
#Faults: failure as a bug
A fault is for the situation that shouldn't happen. You raise one
with error(...), and the runtime raises one for you on
things like a divide by zero. A fault unwinds until a
try/rescue catches it. The rescue
block binds the fault and runs, and the program carries on:
fn safe_div(a, b)
try
return Ok(a // b)
rescue err
return Error(`math error: {err}`)
end
end
match safe_div(84, 2)
Ok(x) -> print(`84 / 2 = {x}`)
Error(e) -> print(e)
end
match safe_div(1, 0)
Ok(x) -> print(x)
Error(e) -> print(e)
end
print("still running")
$ scua safe-div.scua
84 / 2 = 42
math error: divide by zero
still running
safe_div catches the divide-by-zero fault and turns it
into a Result, which is a reasonable thing to do at a
boundary where you'd rather hand the caller a value than crash. The
still running line confirms the program continued.
A fault that nothing rescues stops the program with a located message:
print("before")
let x = 1 // 0
print("after")
$ scua uncaught.scua
before
scua: uncaught.scua:2: divide by zero
after never prints. That's the right outcome for a real
bug: fail loudly at the point of the mistake rather than limp along with
bad state.
When the fault happens inside a chain of function calls, the report includes a backtrace so you can see how the program got there, innermost call first:
fn health_percent(current, max)
return current * 100 // max
end
fn show_bar(entity)
return health_percent(entity.hp, entity.max_hp)
end
let dead = { hp = 0, max_hp = 0 }
print(show_bar(dead))
$ scua bar.scua
scua: bar.scua:2: divide by zero
at health_percent (bar.scua:2)
at show_bar (bar.scua:5)
at main (bar.scua:8)
The first line is the fault and where it happened; each
at line below is a caller, up to the top-level
main. Every line names its own file, so when a fault
happens inside a module you imported, the trace says so instead of
pointing at the file you ran. A fault that happens at the top level, not
inside any call, just shows the one located line.
#pcall: try/rescue as a
value
pcall runs a function and reports the outcome as a
Result instead of unwinding. It's the value-shaped form of
try/rescue: you get Ok(result) if
the function returned normally, or Error(message) if it
faulted.
fn risky(n)
if n == 0 then error("cannot be zero") end
return 100 // n
end
match pcall(fn() return risky(4) end)
Ok(v) -> print(`got {v}`)
Error(e) -> print(`failed: {e}`)
end
match pcall(fn() return risky(0) end)
Ok(v) -> print(`got {v}`)
Error(e) -> print(`failed: {e}`)
end
$ scua pcall.scua
got 25
failed: cannot be zero
Reach for pcall when you want to run something that
might fault and branch on the outcome inline, without wrapping it in a
named try block.
#Running out of stack
Every function call the program is inside costs a call
frame, and there is a ceiling: 4000 frames. Go past it
and you get a fault —
stack overflow: call depth exceeded 4000 frames — catchable with try/rescueorpcall``
— which behaves like every other fault. It unwinds,
try/rescue and pcall catch it,
and an uncaught one stops the program with a line and a backtrace.
Nine times in ten, hitting it means you wrote accidental infinite
recursion: a function that calls itself without a base case, or two
functions that call each other in a loop. Read the backtrace before you
reach for rescue — the fix is usually the missing base
case, not a handler.
The tenth time is real, and it's why the limit is catchable. If you write a library that walks data someone else supplies — a JSON document, a config tree, a parsed markup tree — you cannot know how deep it nests, and a document a few hundred levels deep is not your bug. Catch it at your boundary and hand the caller a value:
fn depth_of(node)
if type(node) != "array" then return 0 end
let best = 0
for child in node do
let d = depth_of(child)
if d > best then best = d end
end
return 1 + best
end
fn measure(doc)
try
return Ok(depth_of(doc))
rescue err
return Error(`document nests too deeply: {err}`)
end
end
fn nest(n)
let acc: any = [1]
for i in 0:n do acc = [acc] end
return acc
end
match measure([1, [2, [3]]])
Ok(d) -> print(`depth {d}`)
Error(e) -> print(e)
end
match measure(nest(5000))
Ok(d) -> print(`depth {d}`)
Error(e) -> print(e)
end
print("the program keeps running")
$ scua deep.scua
depth 3
document nests too deeply: stack overflow: call depth exceeded 4000 frames — catchable with `try`/`rescue` or `pcall`
the program keeps running
That is the pattern: measure promises a
Result, so "too deep" joins "malformed" and "empty" as one
of the failures it reports rather than a way for it to kill its caller.
Note that the frames are gone by the time your rescue block
runs — unwinding is what freed them — so the handler has a full stack to
work with, and returning a value from it is completely ordinary.
If you genuinely need to walk unbounded input, rewrite the recursion as a loop with your own stack. The catchable limit means you don't have to in order to be safe; it means you find out instead of dying.
#Values that get too big
One value — one array, one table, one string, one bytes
— has a ceiling of 256 MiB. For a plain array that is
about 16.7 million elements; an array of plain numbers
packs about four times as many, because a {i64} or
{f64} array stores them unboxed. The ceiling is structural,
not a budget: there is no flag that raises it.
Going over it is a fault, so it behaves like every other fault — it
names the limit, try/rescue and
pcall catch it, and an uncaught one stops the program with
a line and a backtrace.
fn make_buffer(n)
let buf = []
try
buf.reserve(n)
return Ok(buf)
rescue err
return Error(`cannot hold {n} items: {err}`)
end
end
match make_buffer(5000000000)
Ok(b) -> print("reserved")
Error(e) -> print(e)
end
$ scua buffer.scua
cannot hold 5000000000 items: reserve(5000000000) is over the largest a single array may be (16777214 elements)
The value you were building is untouched — the size is checked before anything is allocated, so there is no half-built array to clean up and you can carry on using it.
Most programs never come near this. If yours does, the answer is
usually to stop holding the whole thing at once: write results out as
you produce them, split the data across several values, or keep numbers
in a typed array. builder() is the right way to accumulate
text, but it does not lift the ceiling — it makes reaching it
cheaper.
#What you can't catch
Two failures deliberately ignore try/rescue
and pcall, because catching them would let a script escape
a limit it was given:
used its whole CPU budget— the--max-opsreduction budget, when you (or a host, or a sandbox) set one. A handler would resume with the budget still exhausted and immediately trip again, so a script could loop forever catching its own kill switch. Without--max-opsthere is no CPU limit to catch: the built-in ceiling refills rather than stopping the run, so an ordinary long computation just finishes.memory limit exceeded— the--max-memcap. Unwinding frees call frames, not the data that breached the cap, so a handler would run with nowhere to allocate.
The rule underneath is worth knowing, because it tells you which way a future limit will go: a limit is catchable when the handler runs in a state that no longer breaks it. Unwinding pops call frames, so the depth limit is catchable; nothing is allocated when a value is refused for being too big, so that one is too. Unwinding gives back no CPU budget and frees no memory, so those two are not — a handler would resume with the limit still breached and trip again on its next step.
print("printed before the budget ran out")
let n = 0
match pcall(fn() while true do n = n + 1 end return n end)
Ok(v) -> print(v)
Error(e) -> print(`caught: {e}`)
end
$ scua --max-ops=100k budget.scua
printed before the budget ran out
scua: budget.scua:3: this turn used its whole CPU budget of 100000 steps, set by `--max-ops`. Raise it, or remove the budget with `--max-ops=none`
The pcall does not intervene and the program stops. What
it does do is print everything the script had already printed —
a run that fails still shows you its output, which is usually where the
clue is.
#Which one to use
Return a Result when failure is part of the function's
normal contract and the caller is expected to handle it: parsing,
validation, I/O, anything where "it didn't work" is a routine outcome.
The Error carries the reason, ? propagates it,
and match forces the caller to confront it.
Raise a fault when continuing would mean running on broken state: a
violated invariant, an impossible branch, a precondition the caller was
supposed to guarantee. Don't reach for
try/rescue as routine control flow. Use it at
the boundaries where you'd rather contain a fault than let it take down
the whole program, the way safe_div does.
A useful rule of thumb: if you'd write a comment saying "this can't
happen," that's a fault. If you'd write "the caller should check for
this," that's a Result.
#See also
--max-ops/--max-mem— the two limits no handler can catch.- Pattern matching — consuming
Ok/Errorwithmatch. - Contracts — boundary refinements raise catchable, introspectable faults.
- Records and gradual types — the sealed-record write that faults at runtime.