2022-02-13

Error handling in GNU Guile

In this post, I will outline how to create your own exception types in GNU Guile and how to handle exceptions and errors in general. This can be a difficult thing to learn using the Guile manual, which provides the building blocks for the system and leaves it up to the reader to bring it together.

This post was updated to include continuable exceptions.

History

Guile has two different exception systems, with the new with-exception-handler and raise-exception based system appearing in the year 2019. This new system allows for compound error types that can contain a lot of useful information. The old exception system is based on catch, with-throw-handler, and throw. An exception in the old system is a symbol, along with objects, typically for printing a helpful error message.

Both systems are compatible with each other, so this post will outline the both systems and provide examples on how to handle both of them using either style.

What do exceptions look like? How do I throw / raise them?

Let's throw an exception using the old system and see what it looks like.

(throw 'read-error)
/guile-code.scm:5:0: Throw to key `read-error' with args `()'.

Let's add some arguments.

(throw 'read-error "ran into trouble while reading the file")
/guile-code.scm:5:0: Throw to key `read-error' with args `("ran into trouble while reading the file")'.

As you can see, old style errors can be determined by the phrase "Throw to key ...".

Note that Guile also provides the (error ...) procedure, which throws an old style exception with the key 'misc-error.

What do new style exceptions look like when raised? I will explain how to create these types further into the blog post. For now, just know that a &read-exception contains a read-reason and a read-severity.

(raise-exception
 (make-read-exception 'full 'high))
/guile-code.scm:16:0: ERROR:
  1. &read-exception:
      read-reason: full
      read-severity: high

The new exception system also allows for compound exceptions, which are exceptions composed of multiple exceptions. This is what they look like.

(raise-exception
 (make-exception (make-read-exception 'full 'high)
                 (make-exception-with-message "the filesystem is full")))
/guile-code.scm:16:0: ERROR:
  1. &read-exception:
      read-reason: full
      read-severity: high
  2. &message: "the filesystem is full"

Note that sometimes, you are not provided information about which exception type or key was used for an exception. This usually occurs for old style exceptions with the key 'system-error, where Guile instead formats the error message for the user. We can replicate this by opening a file that does not exist.

(call-with-input-file "does-not-exist.txt" (lambda (port) #f))
ERROR: In procedure open-file:
In procedure open-file: No such file or directory: "does-not-exist.txt"

So now, you should be able to tell what exception system (new or old) an exception is using.

When in doubt, you can always catch the exception and print it. Let's do that for the above exception.

Note that we use the guard macro to keep Guile's output clean. This will be explained further down the blog post.

(use-modules (ice-9 exceptions))

(guard (ex (else (display ex) (newline)))
  (call-with-input-file "does-not-exist.txt" (lambda (port) #f)))
#<&compound-exception components: (#<&external-error> #<&origin origin: "open-file"> #<&message message: "~A: ~S"> #<&irritants irritants: ("No such file or directory" "does-not-exist.txt")> #<&exception-with-kind-and-args kind: system-error args: ("open-file" "~A: ~S" ("No such file or directory" "does-not-exist.txt") (2))>)>

Let's break this down. This is a compound exception, i.e. an exception that contains other exceptions. It contains the &external-error, &origin, &message, &irritants, and the &exception-with-kind-and-args exceptions. The fact that it is an &exception-with-kind-and-args lets us know that this is an old style exception, with the key 'system-error.

If that looks complicated, don't worry, you won't have deal with this sort of output often. You should now know what exceptions look like, and how to categorize them into "new" and "old".

Creating exception types

You saw earlier that I created an instance of a &read-exception, which is not built in to Guile. How did I do it?

Guile provides a helpful macro for this purpose, called define-exception-type. Let's look at how to use it.

(use-modules (ice-9 exceptions)) ;; for the macro and the root exception &exception
             
                       ; name of type  ; parent   ; constructor name  ; predicate to check type
(define-exception-type &read-exception &exception make-read-exception read-exception?
  ; (field-name field-accessor) ...
  (read-reason read-exception-reason)
  (read-severity read-exception-severity))

We can now run the code provided earlier and see the exception.

Why this complexity? This exception type allows for inheritance, so you can have a rich error hierarchy for your application if you need it. The predicates and accessors for the parent exception would work for the child exception. Additionally, you can now associate structured data with your exception, which can be accessed using the accessors defined by the macro.

Handling exceptions

Old-style

Let's say we only want to handle old-style exceptions. We want to catch system-error's and let all other exceptions pass. For this, we can use the catch procedure, which allows us to specify the error key. To catch errors with any key, use #t for the key. Note that all new style exceptions have the key '%exception.

The following code will read from the file if it exists, but otherwise returns #f in the case of a system-error.

(define contents
  (catch 'system-error
    (lambda ()
      (call-with-input-file "does-not-exist.txt"
        (lambda (port) (read port))))
    (lambda (key . args)
      ;; exception handler, called when an exception is thrown.
      #f)))

You can always re-throw the same exception (using (apply throw key args)) or throw a new exception in the exception handler.

New-style

Let's say that read-something is a procedure that can sometimes fail. We want contents to be #f when a &read-exception is thrown. Otherwise, we want it to be the return value of (read-something). In case another exception occurs, we want it to be thrown. This can be accomplished this way:

(use-modules (ice-9 exceptions))

(define (read-something)
  (raise-exception (make-read-exception 'damage 'low)))

(define contents
  (guard (ex
          ((read-exception? ex) #f))
    (read-something)))

In the guard form, ex is bound to thrown exception and the following forms follow the same pattern as a (cond (conditional-expr expr) ...). When a conditional evaluates to true, expr becomes the return value of the expression. If you'd like to handle all exceptions, you can add an else clause in the guard form, for e.g. (guard (ex (else #f)) body ...).

If you want to handle old-style exceptions using this form you can use the exception-kind and the exception-args procedures. For e.g.

(guard (ex
        ((eq? (exception-kind ex) 'system-error) #f))
  (call-with-input-file "does-not-exist.txt"
    (lambda (port) (read port))))

Intermediate: Continuable exceptions

In Guile Scheme, you can raise an exception at a place in the code that can be resumed by the exception handler. This is called a continuable exception, raised using raise-continuable.

One potential use case for this is when the code finds a invalid value. Instead of aborting the function and having to run the function or program again, continuable exceptions allow the program to ask the user if they'd like to provide a new value instead, and allowing the program to resume where it left off with the new valid value.

We can demonstrate this with an example. In this scenario, the code is supposed to write a file of a certain size, 1028, by default. However, we want to make sure that the file is not larger that the amount of space in the file system. To do this, we need a function to determine the amount of free space in the filesystem (disk-space-amount) and a function to check whether a size is within that (disk-space-left? query). For the purposes of this example, let's hard code the amount of space left.

(define (disk-space-amount)
  1000)

(define (disk-space-left? query)
  (< query (disk-space-amount)))

Now that we have these functions, we can implement the logic of the program. To raise a continuable exception, we need to use (raise-continuable ex). What this does is find the relevant exception handler, and runs it with the exception. If the exception handler returns, that value becomes the return value of (raise-continuable ex) exception, resuming the computation. The exception handler can choose to throw the exception again, letting other exception handlers a chance to handle the exception.

To set the exception handler for a block of code, we need to use (with-exception-handler handler thunk). Whenever the code in thunk throws an exception, handler is called with the exception.

Combining all of these together, here is the program.

(with-exception-handler
    (lambda (ex)
      (cond
       ((and (read-exception? ex)
             (eq? (read-exception-reason ex)  'almost-full))
        (format #t "the disk is almost full, only has ~a left.\n"
                (disk-space-amount))
        (format #t "please provide a different file size: ")
        (let ((new-file-size (read)))
          (if (disk-space-left? new-file-size)
              new-file-size
              (raise-exception ex))))
       (else (raise-exception ex))))
  (lambda ()
    (let ((file-size (if (disk-space-left? 1028)
                         1028
                         (raise-continuable
                          (make-read-exception 'almost-full 'medium)))))
      (format #t "writing ~a\n" file-size))))

Let's walk through this program step by step. Let's look at the code in the thunk.

(let ((file-size (if (disk-space-left? 1028)
                         1028
                         (raise-continuable
                          (make-read-exception 'almost-full 'medium)))))
      (format #t "writing ~a\n" file-size))

If the file size is within the bounds of the filesystem, return the file size. If it is not, throw a continuable exception, which is a &read-exception, with a read-reason of 'almost-full. If the exception is continued, it will return the new file size provided by the handler.

Now let's look at the code in the handler.

(lambda (ex)
      (cond
       ((and (read-exception? ex)
             (eq? (read-exception-reason ex)  'almost-full))
        (format #t "the disk is almost full, only has ~a left.\n"
                (disk-space-amount))
        (format #t "please provide a different file size: ")
        (let ((new-file-size (read)))
          (if (disk-space-left? new-file-size)
              new-file-size
              (raise-exception ex))))
       (else (raise-exception ex))))

If the exception is not a &read-exception with a read-reason of 'almost-full, the handler re-raises the exception. Otherwise, it informs the user that the disk is almost full, and asks the user to provide a new file size using (read). If the new-file-size is within the filesystem limit, then it returns the new-file-size, otherwise, it reraises the exception.

The way the code is setup, it always asks the user for a new file size. Play with the return value of disk-space-amount to gain an intuition for what's happening.

Summary

You should now be able to throw / raise exceptions using both the old and new systems and be able to handle both types of exceptions using either catch and guard forms. You should also be able to use continuable exceptions to your advantage.

Feel free to send feedback to this post via email or IRC (if I'm around). I intend this to be a resource for beginners, and would like to improve clarity anywhere possible.

For additional resources, read the following Guile manual pages.