Skip to content

Instantly share code, notes, and snippets.

@Araq
Last active June 4, 2024 08:33
Show Gist options
  • Save Araq/3323cd15c8ccce1f42853bd586f24ebe to your computer and use it in GitHub Desktop.
Save Araq/3323cd15c8ccce1f42853bd586f24ebe to your computer and use it in GitHub Desktop.

Cheap exceptions

Goals:

  • Avoid Rust's Option/Either manual error handling strategy.
  • Make exceptions cheaper.
  • Avoid error translation between Nim libraries by construction. The new vocabulary type ErrorCode is what should be used. Wrappers should translate errors to ErrorCode.
  • Make the error out of memory easier to handle.
  • Resolve once and for all the "error codes vs exceptions" choice.

The core of this proposal is a new enum called ErrorCode that covers everybody's use case. The different error states have been modelled to map reasonably clearly to POSIX errno values as well as to HTTP error codes.

Inspirations

Furthermore the following documents have been a source of inspiration:

Software system Link
Godot Game Engine https://docs.godotengine.org/en/stable/classes/class_@globalscope.html#enum-globalscope-error
SQLite https://www.sqlite.org/rescode.html
POSIX Errno values https://pubs.opengroup.org/onlinepubs/9699919799.2016edition/basedefs/errno.h.html
Maria DB https://mariadb.com/kb/en/mariadb-error-code-reference/
OpenCL error codes https://gist.github.com/bmount/4a7144ce801e5569a0b6
Mongo DB https://www.mongodb.com/docs/manual/reference/error-codes/
Windows API https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-
HTTP status codes https://www.rfc-editor.org/rfc/rfc9110.html#name-status-codes

ErrorCode enum

Nim enum Posix HTTP description
Success 0'i32 200, 201, 202 Operation completed successfully.
OverflowError EOVERFLOW Integer overflow or underflow error.
Failure 500 General failure, unknown error, etc.
BugError Programming bug detected.
IndexError Array index out of bounds.
RangeError ERANGE, EDOM 416 Range check error.
OverlapError Source and destination memory overlaps.
SyntaxError 422 A general parsing error.
OutOfMemError ENOMEM Not enough memory left.
DiskFullError ENOSPC 507 No space left on device.
StackOverflow No stack space left.
IOError EIO I/O error.
ValueError EINVAL, EBADMSG, EILSEQ, ENOMSG, EDESTADDRREQ Invalid argument. Or: Bad/missing message.
KeyError Invalid key.
EndOfStreamError End of stream/file reached.
SkipError Skip to next item.
FullError E2BIG, ENOBUFS No space left in the buffer. Or: Argument list too long.
EmptyError ENODATA No message is available.
BusyError EBUSY, ETXTBSY 429 Device is busy. Too many requests.
DeadResource ECHILD, EOWNERDEAD Dead thread/owner/child.
ResourceExhaustedError ENOLCK Thread/process/etc creation failed.
DescriptorExhaustedError ENFILE Too many files open in system.
PermissionDenied EACCES, EPERM 403, 401, 407, 405, 406 Permission denied.
RetryError EAGAIN, EWOULDBLOCK Resource unavailable, try again.
TimeoutError ETIMEDOUT, ETIME 408, 504 Connection timed out.
InterruptedError EINTR Interrupted function.
DeadlockError EDEADLK 508 Resource deadlock would occur.
LockedError  Resource is locked.
FormatMismatch  Source and destination have incompatible formats.
AlreadyConnected EADDRINUSE, EISCONN Address in use. Socket is connected.
AddressNotAvailable EADDRNOTAVAIL Address not available.
AddressFamilyUnsupported EAFNOSUPPORT Address family not supported.
BadOperation EOPNOTSUPP, ENOTSUP, ENOSYS, EPROTONOSUPPORT, ENOTTY, ESPIPE, EISDIR, ENOTEMPTY 400, 415 Operation not supported. Bad Request.
AbortedOperation ECANCELED, ECONNABORTED, ENETRESET Operation canceled. Connection aborted.
UnimplementedOperation 501 Operation is not implemented.
AlreadyInProgress EALREADY, EINPROGRESS Operation already in progress.
NameTooLong ENAMETOOLONG 414, 431 Path/Filename/URL too long.
NameExists EEXIST Name file/directory already exists.
NameNotFound ENOENT, EIDRM, ENODEV, ENOTDIR 404 No such file or directory or device.
ContentTooLong EFBIG, EMSGSIZE 413 File/content too large.
BadDescriptor EPIPE, EBADF, EMFILE, ENOSTR, ENOTSOCK, ENOSR, ENXIO, ESRCH Bad file descriptor/pipe/process/etc.
BadExecutable ENOEXEC Executable file format error.
BadLink ELOOP, EMLINK, EXDEV 421 Too many levels of symbolic links. Too many links. Cross-device link. HTTP: Misdirected request.
BadProtocol EPROTOTYPE, ENOPROTOOPT 505 Protocol wrong type for socket. Protocol not available. HTTP version not supported.
ProtocolError EPROTO Protocol error.
ReadonlyProtection EROFS Cannot write to readonly data.
SegFault EFAULT Bad address. Segmentation fault. Nil pointer derefence.
DiskCorruption Corrupted disk/file/table.
Disconnected ENETDOWN, ENETUNREACH, ECONNRESET, ENOTCONN Network is down. Network unreachable. Connection reset. The socket is not connected.
RefusedConnection ECONNREFUSED Connection refused.
UnreachableHost EHOSTUNREACH 502 Host is unreachable. Bad Gateway.
UnrecoverableState ENOTRECOVERABLE State not recoverable.
AuthenticationRequired 511 Network authentication required.
RedirectError 308, 307 Redirect to other URL/path.
Reserved1 Reserved for future extensions. This field will then be renamed!
Reserved2 Reserved for future extensions. This field will then be renamed!
Reserved3 Reserved for future extensions. This field will then be renamed!
Reserved4 Reserved for future extensions. This field will then be renamed!
Reserved5 Reserved for future extensions. This field will then be renamed!
Reserved6 Reserved for future extensions. This field will then be renamed!
Reserved7 Reserved for future extensions. This field will then be renamed!
Reserved8 Reserved for future extensions. This field will then be renamed!
Reserved9 Reserved for future extensions. This field will then be renamed!

A routine can be annoted with {.raises: ErrorCode.} if it raises such a cheap exception. Likewise except ErrorCode as e exists. The raise statement can be used to raise a cheap exception.

Enforced error handling

Routines that are not annotated with {.raises: ErrorCode.} cannot use raise ErrorCode.x nor can they call into routines that are marked with {.raises: ErrorCode.} unless of course such calls happen within an try except environment.

I consider this to be the sweet spot in language design. Individual callsites are not annotated but routine headers have to be. The granularity feels just right for Nim.

Interaction with builtin defects/panics

There is a new switch called --panics:errorcode.

Existing errors "raised" directly by the Nim runtime esp RangeDefect, IndexDefect and OverflowDefect are mapped to the corresponding ErrorCode by the compiler if a new mode --panics:errorcode is enabled.

This means that an operation like a + b that can overflow inside a routine annotated with {.raises: ErrorCode.}. The justification for this is that the routine clearly communicates that it is an operation that can fail. The caller prepares for a failure already. Adding the "bug" reason to the failure mode has no downside in practice.

a + b can produce an OverflowError that is reported back to the caller as if a statement like raise OverflowError would have been written in the code directly:

proc canOverflow() {.raises: ErrorCode.} =
  echo a + b

try:
  canOverflow()
except ErrorCode as e:
  assert e in {IOError, OverflowError}

With --panics:off (the default mode) canOverflow continues to produce an OverflowDefect.

The same is true for "out of memory" errors:

proc canRaiseOOM() {.raises: ErrorCode.} =
  discard alloc(HugeVal)


proc canPanicOnOOM() =
  discard alloc(HugeVal)

try:
  canRaiseOOM()
except ErrorCode as e:
  case e
  of OutOfMemError:
    echo "Could gracefully recover from OOM!"

try:
  canPanicOnOOM()
except:
  echo "Damn, it quit()ed instead."

Cheap overflow checking

Translating between a carry flag and ErrorCode is cheap and obvious:

For example:

# NEW BUILTINs offered by system.nim
proc checkedAdd(a, b: int): (int, int) {.raises: [].}
proc checkedMul(a, b: int): (int, int) {.raises: [].}

proc `*`(a, b: int): int {.raises: ErrorCode.} =
  # Possible implementation of builtin `*`.
  let (lo, hi) = checkedMul(a, b)
  if hi != 0:
    raise OverflowError
  else:
    return lo

ABI

proc p(args) {.raises: ErrorCode.} is translated to proc p(args): ErrorCode.

proc p(args): T {.raises: ErrorCode.} is translated to proc p(args): (ErrorCode, T) when T is an integral type (int etc.). It is translated to proc p(result: out T; args): ErrorCode otherwise. This seems to produce the best code for the common architectures (x86, x86_64, ARM, RISC V). The errors are propagated through CPU registers.

Examples

Fibonacci

proc fib(n: int): int {.raises: ErrorCode.} =
  if n < 0:
    raise RangeError
  if n < 2:
    n
  else:
    fib(n-1) + fib(n-2)

try:
  let x = fib()
except ErrorCode as e:
  echo "Problem: ", e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment