Error handling β try / rescue / finally + match + the error-kind enum¶
What this enables. Catch op failures, branch on what kind of error happened, run cleanup unconditionally, and re-raise after you've decided to. Composes with
retry,parallel,timeout, and every other block op β errors propagate UP through blocks until something catches them.
Status β try / rescue / finally ships
The full error model on this page is live: the error-kind enum, the ${err.*} bindings, match (including bare match err.kind), and the try / rescue / finally block. The block is built on capy's block_sections (capy β₯ 5102dec). A try whose only failure handling is finally (no rescue) correctly re-raises after cleanup; only a non-empty rescue arm swallows the error.
TL;DR¶
try
body = http_get "${url}"
rescue err
match "${err.kind}"
case http_5xx
throw "${err.message}" # let an outer retry handle it
case http_4xx
print "bad request: ${err.code}"
case http_ssrf_blocked
alert "-msg=security: ${err.detail}"
else
throw "${err.message}" # unknown β re-raise
end
finally
rm "${tmpfile}"
end
The block shape¶
try
OP_THAT_MIGHT_FAIL
OP_AGAIN
rescue ERR_NAME
# runs only if the try body errored
# ${ERR_NAME.kind}, ${ERR_NAME.message}, ${ERR_NAME.code}, ${ERR_NAME.op}, ${ERR_NAME.detail}
finally
# runs always (success or fail), AFTER rescue, BEFORE propagation
end
Both rescue and finally are optional. A bare try ... end parses but is pointless β with no rescue it just runs the body and propagates any error (same as not wrapping it). Add rescue to handle, finally to clean up, or both.
Why rescue and not catch?¶
perch already has a top-level catch unknown ... end for declaring catch-all CLI commands ("forward unknown verbs to git"). That's structurally different from exception handling. To keep both clear, perch uses Ruby-style rescue for the try-arm; catch stays the catch-all-command keyword.
What you can do inside rescue¶
| Behavior | |
|---|---|
Run normally (no throw, no fail) |
Error is considered handled; execution continues after the try block (after finally runs). |
throw "msg" |
Re-raise. Error propagates after finally runs. Spelled throw (alias for fail) to read clearly. |
fail "msg" |
Same as throw β produces user_fail. |
| Any other op errors | That error replaces the original and propagates. |
What finally does¶
- Runs unconditionally β after a successful
trybody, after a successfulrescue, after a failingrescue. - Errors in
finallyoverride the original error (otherwise nobody could see a cleanup failure). - Common use: cleanup of resources allocated in the try (temp dirs, kube context switches, sentinel files).
The error-kind enum¶
${err.kind} is one of these. match against the bare identifier (no quotes β the grammar accepts both case user_fail and case "user_fail").
Shell (4)¶
| Kind | When it fires |
|---|---|
shell_exit_nonzero |
A shell / shell_output / try_shell op's process exited with a non-zero status. ${err.code} is the exit code. |
shell_metachars_denied |
--no-shell-metachars was set and the command contained one of |, >, <, &, ;, `, $(. |
shell_bin_not_allowed |
--allow-bin was set and the first token of the command isn't in the allowlist. |
shell_signal_killed |
The shell process was killed by a signal (e.g. SIGKILL by OOM, SIGTERM by timeout). |
HTTP (6)¶
| Kind | When it fires |
|---|---|
http_4xx |
(Reserved β currently http_get returns body+status without raising on 4xx. A future http_get_strict op will use this.) |
http_5xx |
Same as above. |
http_redirect_refused |
A 3xx response would have redirected to a host outside --allow-host, or downgraded httpsβhttp without --allow-scheme-downgrade. |
http_ssrf_blocked |
Destination hostname resolves to a private / loopback / link-local / unspecified IP. The SSRF guard runs on every request AND every redirect hop. |
http_dns_failed |
Hostname couldn't be resolved. |
http_timeout |
Request exceeded the client timeout (30s default; overridable via timeout block). |
File (4)¶
| Kind | When it fires |
|---|---|
file_not_found |
read_file / stat / similar on a path that doesn't exist. |
file_permission_denied |
OS refused read or write. |
file_path_disallowed |
A write hit a path outside the active sandbox's allowed write roots (or --allow-fs-write). |
file_already_exists |
An op that refuses to clobber found an existing file. |
Capability (4)¶
These fire when the runtime restriction layer refuses an op outright (before the op runs).
| Kind | When it fires |
|---|---|
cap_shell_denied |
--no-shell was set; a shell op was attempted. |
cap_network_denied |
--no-network was set; an HTTP / DNS op was attempted. |
cap_subprocess_denied |
--no-subprocess was set; pkg_install / kill_by_name / etc. |
cap_write_denied |
--no-write was set; any FS-write op was attempted. |
WASM (4)¶
| Kind | When it fires |
|---|---|
wasm_compile_failed |
The module bytes aren't a valid WASM module. |
wasm_module_exited |
The module called os.Exit(N) with N != 0. ${err.code} is the exit code the module produced. |
wasm_capability_denied |
The module tried something not declared in the wasm_run body (file outside mounts, env var outside allowlist, etc.). |
wasm_http_refused |
The module called perch.http_get but had no matching wasm_allow_host, or the host wasn't in the intersection of allowlists. |
Interpolation (2)¶
| Kind | When it fires |
|---|---|
unresolved_var |
${name} had no binding (no arg, no global, no let, not in the env allowlist). |
unresolved_template |
a bare NAME args⦠invocation where NAME isn't a declared template (or command/op/bin). |
Runtime (4)¶
| Kind | When it fires |
|---|---|
timeout_exceeded |
Wall-clock deadline (--max-runtime or timeout block) was hit. |
signal_received |
SIGINT or SIGTERM received while running. Usually you handle this via on_signal HANDLER on the command, not via rescue. |
user_fail |
Explicit fail "msg" or throw "msg" in the .perch source. |
assert_failed |
An assert_* op didn't match. ${err.detail} carries the actual vs expected. |
Lookup (2)¶
| Kind | When it fires |
|---|---|
command_not_found |
a bare X invocation where X isn't a declared command. |
bin_not_found |
has_bin returned false in a context that required the binary (e.g. inside require_bin). |
Requires-manifest (4)¶
These fire only in files that declared a requires ... end block. See docs/requires.md.
| Kind | When it fires |
|---|---|
bin_not_declared |
A shell op's first token β or an exec / pipe-stage binary β isn't listed under bin in the requires block. |
env_not_declared |
get_env "X" where X isn't listed under env. |
host_not_declared |
An HTTP op targets a host not listed under host (or its *.suffix form). |
read_not_declared |
A filesystem read op touches a path outside every declared read (and write) root. |
write_not_declared |
A filesystem write op touches a path outside every declared write root. |
requirement_unmet |
Preflight failure β required bin missing, version doesn't satisfy comparator, required env not set, or host OS/arch not in declared list. |
Capability gate (8)¶
The default-deny vocabulary from sandboxed-by-design.md Β§4. The runtime *_not_permitted kinds fire when an effectful op's capability was never granted (distinct from the *_not_declared kinds above, which mean "the capability is on but this specific bin/host/env/path isn't allowlisted"). The two static kinds are surfaced by perch --check for the named-handle layer (Β§3.1). These are reserved as the model lands across its phased plan; today's enforcement still primarily uses the *_not_declared kinds.
| Kind | When it fires |
|---|---|
shell_not_permitted |
A shell/exec op ran without the shell capability granted. |
read_not_permitted |
A read op's path is outside every granted read root. |
write_not_permitted |
A write op's path is outside every granted write root. |
net_not_permitted |
A network op ran without the net capability granted. |
env_not_permitted |
An env op ran without the env capability granted. |
subprocess_not_permitted |
A non-shell subprocess ran without the subprocess capability granted. |
unknown_capability |
(static) A named handle referenced in the body was never declared in requires. |
capability_kind_mismatch |
(static) A handle used in the wrong position (e.g. a path handle where a bin is expected). |
Catch-all (1)¶
| Kind | When it fires |
|---|---|
unclassified |
An error from an op whose handler hasn't yet been migrated to tagged errors. Treat this as a "real bug we should fix" signal β open an issue with the audit log. |
The ${err.*} bindings¶
Inside a rescue ERR_NAME arm, five bindings are populated:
| Binding | Type | Example |
|---|---|---|
${err.kind} |
enum (string) | http_5xx |
${err.message} |
string | "500 Internal Server Error" |
${err.code} |
string | "500" (exit code, status code, or "" if N/A) |
${err.op} |
string | "http_get" (op kind that failed) |
${err.detail} |
string | structured extra info (e.g. blocked URL, denied binary name) |
${err} |
string | shorthand for ${err.message} β useful for throw "${err}" |
(ERR_NAME is conventionally err; you can use any identifier.)
match β value-driven dispatch¶
# Bare ident (no quotes, no ${...}) β works for plain binding names
match os
case darwin
...
case linux
...
end
# String form β required for dotted bindings (err.kind, err.message, etc.)
# because capy's tokenizer treats `.` as a separator.
match "${err.kind}"
case literal_1
OP_A
case literal_2
OP_B
case "with quotes" # both forms accepted
OP_C
else
OP_D # fallback
end
When to use which form:
| Target binding | Form |
|---|---|
os, arch, status, result |
match os |
err.kind, err.code, err.message |
match "${err.kind}" (dotted β string form required) |
Semantics:
- First matching case wins. No fall-through to subsequent cases.
- Exact string match against the post-interpolation value of
VALUE. No regex, no prefix matching (yet). elseis the fallback. If no case matches and noelseis present, the block is a no-op.- Case values can be bare identifiers OR quoted strings. Bare idents are captured as their string spelling (so
case user_failmatches the string"user_fail").
match works on any string, not just err.kind:
match "${os}"
case darwin
brew install jq
case linux
apt-get install -y jq
case windows
choco install jq -y
else
fail "unsupported os: ${os}"
end
Composition with other blocks¶
Errors propagate UP through blocks until something catches them.
With retry¶
The classic "retry on transient errors, fail loudly on permanent ones":
retry max=5 delay=2s
try
body = http_get "${url}"
rescue err
match "${err.kind}"
case http_5xx
throw "transient" # retry will catch + re-run
case http_timeout
throw "transient"
case http_dns_failed
throw "transient"
case http_4xx
# 4xx isn't transient β fail loudly
fail "client error ${err.code}: ${err.message}"
else
throw "${err.message}"
end
end
end
With parallel¶
Each branch's error propagates to the surrounding parallel. To prevent one branch's failure from cancelling the others, wrap each in its own try:
parallel max=3
try
deploy_region "-region=a"
rescue err
alert "-msg=a failed: ${err.message}"
end
try
deploy_region "-region=b"
rescue err
alert "-msg=b failed: ${err.message}"
end
end
With timeout¶
try
timeout secs=10
body = http_get "${slow_url}"
end
rescue err
match "${err.kind}"
case timeout_exceeded
print "skipped β too slow"
else
throw "${err.message}"
end
end
Common patterns¶
1. Cleanup with finally¶
tmp = mktemp_dir
try
cp "./src/big-file" "${tmp}/staged"
process ${tmp}/staged
mv "${tmp}/staged" "./out/"
finally
rm "${tmp}" # always cleans up, even if process failed
end
2. Optional dependency with graceful degrade¶
try
cached = http_get "http://redis:6379/value"
print "from cache: ${cached}"
rescue err
# Cache down β proceed without
print "cache miss / unreachable: ${err.kind}"
cached = "${default_value}"
end
# ${cached} is set either way
3. Discriminate on multiple cases¶
try
shell "deploy.sh ${target}"
rescue err
match "${err.kind}"
case shell_exit_nonzero
# Distinguish by exit code
match "${err.code}"
case "1"
fail "generic deploy failure"
case "2"
print "deploy: target unreachable β retrying"
throw "transient"
case "127"
fail "deploy script not found"
else
fail "deploy failed with code ${err.code}"
end
case shell_bin_not_allowed
fail "deploy.sh isn't in --allow-bin (sandbox misconfigured)"
else
throw "${err.message}"
end
end
4. Re-raise from inside rescue¶
try
OP_THAT_MIGHT_FAIL
rescue err
# Log + alert, then re-raise
print "[ERROR] ${err.kind}: ${err.message}"
alert "-msg=${err.message}"
throw "${err.message}" # propagates after finally runs
finally
rm "${tmp}"
end
Honest limits¶
- No exception hierarchies. Error kinds are flat β there's no "all
http_*matchescase http_error." If you want grouping, list each kind explicitly or useelse. matchis exact-match only. No regex, no prefix matching, no guards. Future work.tryis only useful withrescueand/orfinally. A baretry ... endparses but does nothing beyond running its body (errors propagate as if unwrapped).- Capability denials currently produce
unclassified. Tagging in progress; existing CLI flag combinations still work, just without the precise kind name. http_4xxandhttp_5xxare reserved kinds β they exist in the enum but the currenthttp_getop returns the body without raising on non-2xx status. A futurehttp_get_strictop will surface them.- Errors in
finallyoverride the original error. If you want the original to take precedence, don't let finally throw β wrap it in its owntry.
See also¶
- The complete guide β Error handling β the broader pattern context
- docs/language.md β full grammar reference
- docs/sandbox.md β capability-denial kinds in context