22 Errors and Exceptions
Contracts are for broken obligations between parts of a program. Exceptions are for failures that can happen even when everyone uses the interface correctly.
If a caller violates amount <= balance, that is a contract problem. If the network is down, the file is missing, or the external service is temporarily unavailable, those are environmental failures. The program may need to report them, recover from them, or retry.
Nex provides three related constructs:
raiseto signal an exceptionrescueto handle itretryto run the protected block again
22.1 Raising an Exception
The simplest form is raise <expression>:
nex> raise "network unavailable"
Error: network unavailable
The raised value becomes the current exception. In a rescue block, it is available through the special name exception.
Raising an exception stops the current protected block immediately. Control passes to the nearest enclosing rescue.
22.2 A Scoped Rescue Block
The general form is:
do
print("trying")
rescue
print("recovering from " + exception.to_string)
end
Example:
nex> do
print("before")
raise "something went wrong"
print("after")
rescue
print("rescued: " + exception.to_string)
end
before
rescued: something went wrong
The line print("after") never runs because raise aborts the protected block.
22.3 Retrying
Some failures are temporary. In such cases, a rescue block may try again with retry.
nex> let attempts := 0
nex> do
attempts := attempts + 1
if attempts < 3 then
raise "not ready yet"
end
print("done on attempt " + attempts.to_string)
rescue
print("failed: " + exception.to_string)
retry
end
failed: not ready yet
failed: not ready yet
done on attempt 3
retry jumps back to the start of the do block and runs it again.
This is powerful, but it must be used carefully. A retry with no progress toward success becomes an infinite loop in disguise.
22.4 Rescue Inside Routines
Constructors, methods, and functions may also have rescue clauses:
nex> function read_config(path: String): String
do
raise "file not found"
rescue
result := "default-config"
end
The routine body is attempted. If an exception occurs, the rescue clause runs.
The routine should still return to a meaningful state. A rescue clause that simply swallows every error without restoring a sensible result is usually a design mistake.
22.5 Exceptions Versus Preconditions
Suppose we have:
withdraw(amount: Real)
require
positive_amount: amount > 0.0
enough: amount <= balance
do
balance := balance - amount
end
Should withdraw raise an exception when the balance is too small instead of using a precondition?
Usually, no.
Insufficient balance in this design is a caller error. The routine’s legal input space is “positive amounts no larger than the balance.” Anything else is an invalid call and should fail as a contract violation.
Use exceptions when the failure is not a misuse of the routine but a condition arising during normal correct use:
- file system denied access
- remote server timed out
- image file was corrupt
This distinction keeps designs honest. If you use exceptions for contract failures, callers can become sloppy because the interface no longer states clear obligations.
22.6 Recovery Should Be Specific
A rescue block should handle the failure in a way that makes sense for the surrounding routine.
Poor rescue:
rescue
print("error")
end
This loses information and often leaves the computation in an unknown state.
Better rescue:
rescue
print("could not load settings: " + exception.to_string)
result := default_settings()
end
Or, if the routine cannot continue meaningfully:
rescue
print("fatal: " + exception.to_string)
raise exception
end
Recovery should either:
- repair the situation
- substitute a safe fallback
- or report and re-raise
Anything else tends to hide bugs.
22.7 A Retry Loop with Limits
Unbounded retry is dangerous. Give it a stopping rule.
nex> function connect_with_retry(): String
do
let attempts := 0
do
attempts := attempts + 1
if attempts < 3 then
raise "temporary connection error"
end
result := "connected"
rescue
if attempts < 3 then
retry
else
raise exception
end
end
end
This routine retries twice, then gives up. The rescue logic is controlled and explicit.
22.8 A Worked Example: Parsing a Positive Integer
Here is a small example that separates routine obligations from environmental uncertainty:
nex> function parse_positive(text: String): Integer
require
not_empty: text.length > 0
do
let value := text.to_integer
if value <= 0 then
raise "number must be positive"
end
result := value
rescue
raise "invalid positive integer: " + text
end
The routine uses a precondition for one issue and an exception for another:
- empty input is a caller error here, so it is a precondition
- malformed or unacceptable content after conversion is handled as an exception
That balance is not arbitrary. It reflects the routine’s role in the design. If the caller is expected to pass non-empty strings, make it a contract. If the content may legitimately fail to parse, raise or handle an exception.
22.9 Summary
- Exceptions are for failures that can occur during otherwise valid execution
raisesignals an exception;rescuehandles it;retrytries the protected block again- Contract violations and exceptions are different kinds of failure and should not be confused
- A rescue block should repair, substitute, or re-raise, not merely hide the error
- Retry should usually have a clear stopping condition
- Good error handling preserves meaning rather than blurring it
22.10 Exercises
1. Write a do ... rescue ... end block that raises "too small" until a counter reaches 5, then prints "ok". Use retry.
2. Define a function safe_reciprocal(x: Real): Real that raises an exception when x = 0.0. Then wrap a call in a rescue block that prints a fallback message.
3. Rewrite a routine of your own choosing so that it distinguishes clearly between a contract violation and an exception-producing environmental failure.
4. Improve the connect_with_retry routine so that it prints the attempt number each time it retries.
5.* Design a small class File_Cache whose load(path: String): String routine first tries to read from memory, then from disk, and uses rescue logic to recover from a missing file by returning a built-in default. State what should be a precondition, what should be an exception, and why.