Dragon

Exceptions

Every language has to answer one question: when something goes wrong deep inside a call, how does the failure get back to the code that can do something about it? Go threads an error value through every return. Rust wraps it in a Result<T, E> and makes you unwrap it. Python - and Dragon - use exceptions: a separate channel that unwinds the stack until a handler catches it, leaving the happy path uncluttered.

Dragon's syntax is Python's, with brace blocks: try / except / else / finally, raise, and a built-in exception hierarchy. Under the hood there's no zero-cost landing-pad machinery and no Result monad - a raise lowers to setjmp/longjmp, so the throwing path pays the cost and the normal path is free. You write the same code you'd write in Python; it compiles to a long jump.

try / except

A try block guards a region. If a raise fires anywhere inside it - directly or down a chain of calls - control jumps to the matching except clause:

def parse_port(s: str) -> int {
    try {
        return int(s)
    } except ValueError {
        return 8080
    }
}

print(parse_port("9090"))       # 9090
print(parse_port("not-a-port")) # 8080

int("not-a-port") raises ValueError; the except ValueError clause catches it. A clause only fires for the type it names (or a subtype); anything it doesn't match keeps unwinding to an outer handler.

Binding the exception: except E as e

To inspect the error, bind it with as. str(e) returns the message it was raised with - the form that works for every exception type:

try {
    raise ValueError("temperature out of range")
} except ValueError as e {
    print("caught: " + str(e))   # caught: temperature out of range
}

Multiple except clauses

Stack clauses to handle different failures differently; they're tried top to bottom, first match wins. Put specific types first and broad ones last - a leading except Exception would swallow everything:

def handle(s: str) -> None {
    try {
        classify(s)
    } except ValueError as e {
        print("value: " + str(e))
    } except TypeError as e {
        print("type: " + str(e))
    } except Exception as e {
        print("fallback: " + str(e))
    }
}

To handle several types in one clause, group them in a tuple - the same except (ValueError, KeyError) as e form Python uses:

def classify(s: str) -> None {
    if s == "v" { raise ValueError("bad value") } else { raise KeyError("bad key") }
}

try { classify("v") } except (ValueError, KeyError) as e {
    print("caught: " + str(e))   # caught: bad value
}

The exception hierarchy

Dragon ships Python's exception tree, and matching is range-based on the hierarchy, not exact-name: catching a type also catches every type beneath it. So except Exception is the universal "catch anything recoverable" net, and except ArithmeticError catches ZeroDivisionError, OverflowError, and FloatingPointError alike. The tree, abridged:

BaseException
└── Exception
    ├── ArithmeticError → ZeroDivisionError, OverflowError, FloatingPointError
    ├── LookupError → IndexError, KeyError
    ├── OSError → FileNotFoundError, PermissionError, ConnectionError → ...
    ├── RuntimeError → NotImplementedError, RecursionError
    ├── TypeError
    └── ValueError → UnicodeError

A specific type is caught by its base:

try {
    raise ConnectionRefusedError("port 5432 closed")
} except OSError as e {
    print("an OS-level error: " + str(e))   # an OS-level error: port 5432 closed
}

raise and re-raising

raise E("message") throws; the argument is what str(e) returns in a handler and what an uncaught exception prints before the program exits with a nonzero status. A bare raise inside an except block re-throws the current exception - log at one level, let an outer handler decide:

try {
    try {
        raise ValueError("disk full")
    } except ValueError as e {
        print("logging: " + str(e))
        raise                            # re-throw to the outer handler
    }
} except ValueError as e {
    print("recovered: " + str(e))
}
# logging: disk full
# recovered: disk full

Translating a low-level failure into a domain-specific one - raise MyError(...) from e

finally and else

A finally block runs no matter how the try exits - normal completion, caught exception, or an exception still propagating. It's where cleanup goes. An else block runs only if the try completed without raising - the success continuation, kept out of the except's reach:

def parse(s: str) -> None {
    try {
        const n: int = int(s)
    } except ValueError {
        print("not a number: " + s)
    } else {
        print("parsed ok: " + s)
    } finally {
        print("done")
    }
}

parse("42")     # parsed ok: 42 / done
parse("abc")    # not a number: abc / done

For tying cleanup to a resource rather than a code block, the more focused tool is the with statement - see Context Managers.

Built-in runtime errors are catchable

Runtime checks raise like everything else, through the same path your own raise uses - so try/except catches them. Integer division by zero raises ZeroDivisionError, an out-of-range index raises IndexError, a missing key via d[k] raises KeyError:

try {
    const x: int = 10 // 0
} except ZeroDivisionError as e {
    print("caught: " + str(e))   # caught: ZeroDivisionError: integer division by zero
}

The one error that still aborts the process - printing to stderr and exiting, bypassing try/except - is a failed assert:

assert False, "boom"     # prints "AssertionError: boom", exits 1 - NOT catchable

That's by design: assert is for invariants that must hold, not recoverable conditions. If you want a catchable failure, raise AssertionError(...) explicitly.

Running out of memory: MemoryError

Most Dragon code allocates without ever saying so. Every one of these asks the runtime for heap memory behind the scenes - you never write malloc, Dragon does it for you:

s: str = a + b               # concat allocates a new string
c: str = chr(code)           # a one-character string
u: str = name.upper()        # the upper-cased copy
blob: str = json.dumps(rec)  # built in a growing buffer

So you do not control every place memory gets requested - the allocation is hidden inside ordinary, correct-looking code. When the system cannot satisfy one of those hidden requests - a caller sends a body larger than you planned for, the process hits a container memory cap, a long-running server fills up - the operation raises MemoryError, through the same path as every other exception. It is catchable, so the same loop that might exhaust memory can degrade instead of dying:

def build_response(parts: list[str]) -> str {
    try {
        out: str = ""
        for p in parts {
            out = out + p       # any of these concats may run out of memory
        }
        return out
    } except MemoryError {
        return "<response too large>"   # recover instead of crashing
    }
}

This matters most in a long-running server: one request that hits the memory ceiling raises an exception the handler catches, and the process keeps serving everything else. Contrast a runtime that instead used the failed allocation as if it had succeeded: it would write to a null pointer and the OS would kill the process with a segfault - taking down every in-flight request with it, with no traceback and nothing try/except could do, because a segfault happens below the exception machinery. Dragon checks the allocation and raises, so out-of-memory is a recoverable event in your code, not a process death.

Guarding still beats catching

Even though these errors are catchable, guarding the operation is often the better discipline - it's faster than throwing and makes the failure case explicit. Use dict.get(key, default) and the in operator when "absent" is a normal outcome, and check if b != 0 before dividing. Reach for try/except when the failure is genuinely exceptional - corrupt input, a violated invariant, an error that should propagate several frames up - and for a return value (a default, a sentinel, an int | None) when "not found" is a normal, expected result.

At a glance

You want to...Write
Guard a regiontry { ... } except E { ... }
Inspect the errorexcept E as e { print(str(e)) }
Handle several typesexcept (A, B) as e { ... } or a clause per type
Catch anything recoverableexcept Exception as e { ... }
Throwraise ValueError("message")
Re-throw in a handlerbare raise
Always-run cleanupfinally { ... }
Success-only continuationelse { ... }
Avoid a missing keyd.get(key, default) / if key in d

Exceptions are colorless - no throws annotation, no function "color," just like Dragon's functions need no async marker. Next, defining your own error types: Custom Exceptions.