Error Handling

Error Handling

Overview

rill has no exceptions and no try/catch. Errors are values.

When a computation fails, the result becomes an invalid value. Invalid values carry a status sidecar with an error code, message, and trace. Any access on an invalid value halts execution. Recovery requires explicit guard or retry<limit: N> blocks.

ConceptSyntaxPurpose
Status probe$x.!Test whether a value is invalid
Status field$x.!codeRead the error atom from the sidecar
Guard recoveryguard { body }Catch a halt; return the invalid value
Filtered guardguard<on: list[#AUTH]> { body }Catch only specific error atoms
Retryretry<limit: 3> { body }Re-enter the body up to N times
Vacancy default$x ?? fallbackReplace a vacant or missing value
Presence check$x.?fieldTest field existence without halting

Status Sidecar

Every rill value logically carries a status sidecar. Valid values share one frozen singleton (zero allocations). Invalid values carry a populated clone.

The sidecar has a fixed shape:

FieldTypeMeaning
codeatomError atom, e.g. #TIMEOUT
messagestringHuman-readable description
providerstringName of the component that produced the error
rawdictProvider-specific payload
tracelistOrdered sequence of trace frames

Read sidecar fields using .!field on any value:

"hello".!               # false  (valid value — not invalid)
"hello".!code           # #ok
"hello".!message        # ""
"hello".!provider       # ""

.! never halts. It bypasses the access gate and reads the sidecar directly.


Access vs Non-Access

Not all operations behave the same when applied to an invalid value. The access gate enforces a strict two-category split.

Access forms halt on an invalid value:

FormExample
Field read$x.name
Index read$x[0]
Method call$x.upper
Pipe$x -> fn
Arithmetic$x + 1
Type assertion$x :string

Non-access forms pass the invalid value through unchanged:

FormExample
Capture$x => $y
Conditional$x -> ? "yes" ! "no"
Status probe$x.!
Presence check$x.?field
Default operator$x ?? fallback
guard / retry<limit: N> wrappingguard { $x.name }

Access halts are catchable by guard and retry<limit: N>. Halts from error and assert are non-catchable and propagate through any recovery block.

"valid" => $v
$v.upper
# Result: "VALID"
# Error: access halt — $x is invalid
#AB0x => $x
$x.upper

Error Atoms (:atom)

Error codes are atoms. Atoms are capitalized identifiers prefixed with #. They are interned at startup; identity comparison is O(1).

Read the code from any value’s sidecar:

"ok".!code
# Result: #ok

A valid value’s code is always #ok. An invalid value’s code is the atom set at invalidation time.

:atom is the 16th primitive type in rill. Atoms are interned at registry init time; two atoms with the same name are the same identity.

Convert between atoms and strings with the built-in forms:

OperationSyntaxResult
Atom to string#TIMEOUT -> string"TIMEOUT" (no # sigil)
String to atom"TIMEOUT" -> atom#TIMEOUT atom identity
Unknown string to atom"BOGUS" -> atom#R001

The .!code probe returns the atom value. Pass atoms in option lists as #CODE literals.

Unregistered atom names in "NAME" -> atom resolve to #R001. The conversion never throws.


Pre-Registered Atoms

These atoms are available in every rill runtime without registration:

AtomKindMeaning
#oksentinelCode on every valid value; never appears on invalid values
#R001registryUnknown atom at parse or link time; default fallback
#R999registryUnhandled extension throw reshaped at the extension boundary
#TIMEOUTgenericOperation exceeded its time limit
#RILL_R082runtimetimeout<total:> wall-time bound exceeded; recover via guard/??
#RILL_R083runtimetimeout<idle:> inactivity bound exceeded; recover via guard/??
#AUTHgenericAuthentication failure (HTTP 401)
#FORBIDDENgenericAuthorization failure after authentication (HTTP 403, scope mismatch, content-filter block)
#RATE_LIMITgenericTemporal throttling (HTTP 429); recover via retry-after
#QUOTA_EXCEEDEDgenericAccount-level resource exhaustion (billing credits, plan limit)
#UNAVAILABLEgenericService or resource not available
#NOT_FOUNDgenericRequested resource does not exist
#CONFLICTgenericState conflict (e.g. duplicate write)
#INVALID_INPUTgenericInput failed validation; also: sort key extractor returns a vacant value; negative n for take/skip; n <= 0 for batch/window; step <= 0 for window
#PROTOCOLgenericResponse shape violates documented contract (parse failure, schema mismatch)
#DISPOSEDgenericExtension was called after disposal
#TYPE_MISMATCHgenericFailed :type assertion or conversion; also: sort key extractor produces mixed types across elements, sort key_fn argument is non-callable, tuple comparison receives different-length or differently-typed tuples, or start_when/stop_when predicate returns a non-bool value
#IGNOREsentinelMarker for pass<on_error: #IGNORE> { body } to suppress catchable halts in the body

#ok is lowercase because it is a reserved sentinel, not a user-visible error. Scripts cannot produce #ok as an error code.

Extensions can register additional atoms at factory init time using ctx.registerErrorCode(name, kind). See Extension Authoring.


Guard Recovery

guard runs a body once. If the body halts with a catchable signal, guard returns the invalid value instead of propagating the halt.

"ok" => $result
guard { $result.upper }
# Result: "OK"

When the body halts:

# guard catches the halt; script continues with invalid value
guard { #AB0x.field }
# returns invalid #R001

Use .! to check whether guard caught a halt:

"hello" => $val
guard { $val.upper } => $out
$out.!
# Result: false

Filtered Guard

Add <on: list[#CODE, ...]> to catch only specific atoms. Halts with non-matching codes propagate:

guard<on: list[#TIMEOUT]> {
  app::fetch("https://api.example.com")
}

Without a filter, guard catches every catchable halt.

guard does not catch halts from error "..." or assert. Those are non-catchable and always propagate.

# Error: non-catchable — 'error' propagates through guard
guard { error "fatal" }

Side-Effect Suppression with pass<on_error: #IGNORE>

The pass keyword has three distinct forms. Two of them, the body forms, interact with halts.

FormSuppresses catchable halts?
Bare passN/A — references current $; halts #RILL_R005 if $ is unbound
pass { body }No — runs body for side effects; pipe value flows through; halts in body propagate
pass<on_error: #IGNORE> { body }Yes — runs body; suppresses catchable halts in body; pipe value flows through

Use pass<on_error: #IGNORE> when a side-effect block (logging, metrics, audit calls) may halt and you do not want the halt to break the surrounding pipeline:

10 -> pass<on_error: #IGNORE> { 1 / 0 }
# Result: 10 (the body halt is suppressed; pipe value is unchanged)

Without on_error: #IGNORE, halts in the body propagate normally:

# Error: #RILL_R002 — body halt propagates
10 -> pass { 1 / 0 }

on_error accepts only #IGNORE. Empty pass<>, unknown option keys, and any other on_error value are parse errors (RILL-P004).

What Is and Is Not Suppressed

pass<on_error: #IGNORE> matches guard’s catchable-halt rule. Two categories always propagate out of the body:

SignalBehavior
Non-catchable halts (error "...", assert)Propagate; the pipeline halts
ControlSignal (break, return)Propagate to the enclosing construct

See Collection Slicing for the full reference of all three pass forms.


Timeout Recovery

timeout<total:> and timeout<idle:> blocks produce catchable halts on expiry. The expiry halt propagates like any other catchable halt: it must be caught by guard before ?? can supply a fallback.

Timeout kindExpiry atomRecovery pattern
timeout<total: duration>#RILL_R082guard { timeout<total: d> { body } }
timeout<idle: duration>#RILL_R083guard { timeout<idle: d> { body } }

Wrap the timeout block in guard to prevent the halt from stopping execution:

guard {
  timeout<total: duration(0, 0, 0, 0, 0, 0, 500)> {
    app::fetch("https://api.example.com/slow")
  }
} ?? "fallback"

The ?? operator after guard supplies the fallback when guard catches the expiry.

Branch on the specific atom to handle timeout distinctly from other errors:

guard {
  timeout<total: duration(0, 0, 0, 0, 0, 0, 500)> {
    app::fetch("https://api.example.com/slow")
  }
} => $result
$result.! ? {
  ($result.!code -> .eq(#RILL_R082)) ? "timed out"
  ! "other error: {$result.!message}"
} ! $result

See Control Flow for the full timeout block reference, including nesting semantics and cancellation behavior.


Retry

retry<limit: N> re-enters its body up to N times. Each failed attempt appends a guard-caught trace frame. On success, the body result is returned. If all N attempts fail, the final invalid value is returned with N trace frames.

retry<limit: 3> {
  app::fetch("https://api.example.com")
}

Attempt count rules:

NBehavior
>= 1Body runs up to N times
0Parse error: retry<limit: 0> is rejected by the parser

retry<limit: N> with a filtered on: list behaves like guard: non-matching halts propagate immediately.

retry<limit: 3, on: list[#UNAVAILABLE]> {
  app::fetch("https://api.example.com")
}

After all attempts fail, read the trace to see how many attempts ran:

retry<limit: 3> {
  app::fetch("https://api.example.com")
} => $result
$result.!trace -> .len      # up to 3 guard-caught frames

Vacancy and ??

A value is vacant when it is empty or invalid. isVacant (host API) covers both cases.

Empty values are: "", 0, false, [], [:].

The ?? operator provides a fallback when a value is vacant:

[:] => $empty
$empty.name ?? "unknown"
# Result: "unknown"
[name: "alice"] => $user
$user.name ?? "unknown"
# Result: "alice"

?? does not halt. It reads the left-hand side and returns the fallback when the result is missing or vacant.

Presence Check .?field

.?field checks whether a field exists without halting:

[name: "alice"] => $user
$user.?name
# Result: true
[name: "alice"] => $user
$user.?age
# Result: false

Combine .?field with ?? to inspect and fall back:

[name: "alice"] => $user
$user.?age ? ($user.age) ! ($user.name ?? "no name")
# Result: "alice"

Trace Model

Every access on an invalid value appends an access frame to the sidecar trace. guard and retry<limit: N> append a guard-caught frame when they intercept a halt.

Frames accumulate in append order. Prior frames are never copied; only the new frame is added (O(1) per append).

Frame fields:

FieldTypeMeaning
sitestringSource location (file.rill:line)
kindstringOne of six kinds; see table below
fnstringHost fn name, operator, or type op; "" when not applicable
wrappeddictPrior status dict; empty except on wrap frames

Frame kinds:

KindAppended when
hostExtension calls ctx.invalidate. First frame on a new invalid value.
typeA type assertion or conversion fails.
accessAn invalid value is accessed (pipe, method, arithmetic, etc.).
guard-caughtA guard or retry<limit: N> block catches a halt.
guard-rethrowA caught invalid value is re-accessed and halts again.
wraperror "..." wraps an invalid value; wrapped carries the prior status.

Read the trace with .!trace:

guard { app::fetch("https://api.example.com") } => $result
$result.!trace -> seq({
  "{$.kind} at {$.site}"
})

A trace with 3 frames from retry<limit: 3> exhaustion looks like:

[
  [kind: "access", site: "script:2", fn: "pipe"],
  [kind: "guard-caught", site: "script:1", fn: "retry"],
  [kind: "guard-caught", site: "script:1", fn: "retry"],
  [kind: "guard-caught", site: "script:1", fn: "retry"]
]

Each retry<limit: N> exhaustion adds one guard-caught frame per attempt.


Composition Patterns

Check Before Access

Test validity before accessing fields on a potentially invalid value:

"hello" => $val
$val.! ? "invalid" ! $val.upper
# Result: "HELLO"

Guard Then Inspect

Use guard to contain a halt, then inspect the result:

"hello" => $val
guard { $val.upper } => $out
$out.! ? "failed: {$out.!message}" ! $out
# Result: "HELLO"

Retry With Fallback

Combine retry<limit: N> and ?? for resilient access:

retry<limit: 3> {
  app::fetch("https://api.example.com")
} => $result
$result ?? "fallback response"

Filter by Code

Handle specific errors differently using filtered guard:

guard<on: list[#TIMEOUT]> {
  app::fetch("https://api.example.com/slow")
} => $timeout_result

guard<on: list[#AUTH]> {
  app::fetch("https://api.example.com/secure")
} => $auth_result

Read Error Code

Branch on the specific error atom:

guard { app::fetch("https://api.example.com") } => $result
$result.! ? {
  ($result.!code -> .eq(#TIMEOUT)) ? "timed out"
  ! ($result.!code -> .eq(#AUTH)) ? "auth failed"
  ! "unknown error: {$result.!message}"
} ! $result

Extension Authoring

Extensions register error codes and produce invalid values using the ExtensionFactoryCtx.

Register Error Codes

Call ctx.registerErrorCode(name, kind) at factory init time:

import type { ExtensionFactoryCtx, ExtensionFactoryResult } from '@rcrsr/rill';

function createMyExtension(
  config: MyConfig,
  ctx: ExtensionFactoryCtx
): ExtensionFactoryResult {
  ctx.registerErrorCode('MY_ERROR', 'generic');
  // ...
}

ExtensionFactoryCtx shape:

interface ExtensionFactoryCtx {
  registerErrorCode(name: string, kind: string): void;
  readonly signal: AbortSignal;
}

Produce Invalid Values

Inside a callable’s fn, use ctx.invalidate(error, meta) to return an invalid value instead of throwing:

fn: async (args, ctx) => {
  if (!args.url.startsWith('https://')) {
    return ctx.invalidate(args.url, {
      code: 'INVALID_INPUT',
      provider: 'my-ext',
      raw: { message: 'URL must use HTTPS' },
    });
  }
  return await fetch(args.url).then(r => r.text());
}

Catch Extension Throws

Use ctx.catch(thunk, detector) to reshape uncaught throws into invalid values:

fn: async (args, ctx) => {
  return ctx.catch(
    () => riskyOperation(args.input),
    (err) => err instanceof NetworkError
      ? { code: 'UNAVAILABLE', provider: 'my-ext', raw: { message: err.message } }
      : null
  );
}

ctx.catch returns the thunk’s result on success. On a matching throw, it returns an invalid RillValue. Non-matching throws propagate.

Unhandled throws are reshaped to #R999 at the extension boundary automatically.

Disposal

After dispose() is called, any extension invocation returns an invalid value with code #DISPOSED. The dispose() method is on ExtensionFactoryResult:

const ext = createMyExtension(config, ctx);
// ... use ext ...
await ext.dispose?.();

See Also

DocumentDescription
Control FlowConditionals, loops, error, and assert
Operators??, .?field, type assertions
TypesPrimitives and value types
Type SystemType checking and assertions
Error ReferenceAll error codes with causes and resolutions
Host APITypeScript embedding API
Developing ExtensionsWriting reusable host function packages
ReferenceComplete syntax and semantics