Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Error Handling

The two-tier failure model

Keel separates absence from failure:

SituationMechanismHandle with
LLM unavailable / mock / network failureReturns T? (none)?? or when
LLM gave output that didn’t match schemaThrows AiSchemaErrortry/catch
Namespace operation failed (I/O, network, parse)Throws a typed errortry/catch
Fatal config errorHard errorfix the config
Programmer fault (none!, bad cast)Throws Errortry/catch or fix the code

?? — null coalescing (simple default)

Provide a default when the result is none:

urgency = ai.classify(email.body, as: Urgency) ?? Urgency.medium
summary = ai.summarize(article, in: 3, unit: sentences) ?? "No summary"
name    = user_input ?? "anonymous"
port    = env.get("PORT")?.to_int() ?? 3000

This is the right tool when you don’t care why the result is absent — just provide a sensible default.

try / catch — typed error handling

Use try/catch when you need to distinguish failure causes. Every stdlib namespace that can fail raises a named error type; Error is the catch-all:

use std/ai
use std/csv
use std/email
use std/file
use std/http
use std/io
use std/shell

try {
  data = file.read("config.json")
} catch e: FileError {
  data = "{}"                      # handle missing/unreadable file
} catch e: Error {
  io.show("unexpected: {e.message}")
}

try {
  rows = csv.parse_records(raw)
} catch e: CsvError {
  io.show("bad CSV: {e.message}")
}

try {
  resp = http.get("https://api.example.com/data")
} catch e: HttpError {
  io.show("network error: {e.message}")
}

try {
  result = shell.run("build.sh")
} catch e: ShellError {
  io.show("shell error: {e.message}")
}

try {
  urgency = ai.classify(email.body, as: Urgency)
} catch err: AiSchemaError {
  io.notify("Unexpected LLM output: {err.got}")
  urgency = Urgency.medium
} catch err: Error {
  io.notify("Failed: {err.message}")
}

catch matches by type name — the first matching clause runs. Error is the catch-all. The bound name carries at least message: str.

Error type registry

All stdlib error types and the namespaces that raise them:

Error typeRaised byNotes
FileErrorfile.*I/O failures: not found, permission denied, etc.
CsvErrorcsv.*Parse failures, bad row structure, non-string cells
DbErrordb.*Query/exec failures, connection errors
CacheErrorcache.*Serialization errors
MathErrormath.*Domain errors: sqrt(-1), log(0), asin(2)
MemoryErrormemory.*Persistence errors, @memory none restriction
EmailErroremail.*IMAP/SMTP failures
HttpErrorhttp.*Network failures (connection refused, timeout)
ShellErrorshell.*Failed to spawn shell or wait for process
JsonErrorjson.*JSON parse errors, serialization failures
EnvErrorenv.requireRequired env variable not set
AiErrorai.*LLM config errors
AiSchemaErrorai.extract, ai.classifyLLM output didn’t match expected schema (got field contains raw output)
CapabilityErrorany @tools-restricted methodMethod not allowed by the agent’s @tools list
TimeoutErrorcontrol.with_timeoutClosure exceeded the given duration
DeadlineErrorcontrol.with_deadlineClosure ran past the deadline
UserRaisedraise statementUser-raised error
RuntimeBusyany async eventInterpreter event queue full

Catch any of these specifically, or use catch e: Error as a fallback for all.

AiSchemaError extra field:

FieldTypeValue
messagestrHuman-readable description
gotstrThe raw LLM output that didn’t match

Diagnostic codes

When an uncaught error reaches the CLI, the stable diagnostic code appears:

Error: keel::runtime::FileError
  × FileError: file.read `missing.txt`: No such file or directory

Codes follow the pattern keel::runtime::<TypeName>. Tooling and host integrations can inspect the code directly without parsing the message string.

raise — throw an error

Throw an error from any point in a task or agent handler:

task divide(a: int, b: int) -> int {
    if b == 0 {
        raise "division by zero"
    }
    return a / b
}

raise produces a UserRaised error; caught by catch err: UserRaised or the generic catch err: Error:

try {
    result = divide(10, 0)
} catch err: UserRaised {
    io.notify("Raised: {err.message}")
} catch err: Error {
    io.notify("Other failure: {err.message}")
}

Non-string values are converted using their display representation.

control.retry

Retry a failing operation:

# Retry up to 3 times; last error surfaces if all fail
control.retry(3, () => { email.send(reply, to: addr) })

# Combine with try/catch to handle specific errors after all retries fail
try {
    control.retry(3, () => { http.get(url) })
} catch e: HttpError {
    io.show("still failing after 3 tries: {e.message}")
}

control.with_timeout / control.with_deadline

try {
    result = control.with_timeout(5.seconds, () => {
        http.get("https://slow.example.com/api")
    })
} catch e: TimeoutError {
    io.show("timed out: {e.message}")
}

try {
    result = control.with_deadline("2025-12-31T23:59:59Z", () => {
        process_batch()
    })
} catch e: DeadlineError {
    io.show("deadline passed: {e.message}")
}

Null-safe access — ?.

Returns none instead of crashing if the left side is none:

subject = email?.subject           # str? — none if email is none
length  = email?.body?.length      # chained

Null assertion — !

Asserts a value is not none; throws a plain Error at runtime if it is:

subject = email!.subject

Use sparingly — prefer ?? or when for safe handling.