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

Alpha (v0.1). Breaking changes expected.

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:

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.