8.4 Handling conditions

Every condition has default behaviour: errors stop execution and return to the top level, warnings are captured and displayed in aggregate, and messages are immediately displayed. Condition handlers allow us to temporarily override or supplement the default behaviour.

Two functions, tryCatch() and withCallingHandlers(), allow us to register handlers, functions that take the signalled condition as their single argument. The registration functions have the same basic form:

tryCatch(
  error = function(cnd) {
    # code to run when error is thrown
  },
  code_to_run_while_handlers_are_active
)

withCallingHandlers(
  warning = function(cnd) {
    # code to run when warning is signalled
  },
  message = function(cnd) {
    # code to run when message is signalled
  },
  code_to_run_while_handlers_are_active
)

They differ in the type of handlers that they create:

  • tryCatch() defines exiting handlers; after the condition is handled, control returns to the context where tryCatch() was called. This makes tryCatch() most suitable for working with errors and interrupts, as these have to exit anyway.

  • withCallingHandlers() defines calling handlers; after the condition is captured control returns to the context where the condition was signalled. This makes it most suitable for working with non-error conditions.

But before we can learn about and use these handlers, we need to talk a little bit about condition objects. These are created implicitly whenever you signal a condition, but become explicit inside the handler.

8.4.1 Condition objects

So far we’ve just signalled conditions, and not looked at the objects that are created behind the scenes. The easiest way to see a condition object is to catch one from a signalled condition. That’s the job of rlang::catch_cnd():

cnd <- catch_cnd(stop("An error"))
str(cnd)
#> List of 2
#>  $ message: chr "An error"
#>  $ call   : language force(expr)
#>  - attr(*, "class")= chr [1:3] "simpleError" "error" "condition"

Built-in conditions are lists with two elements:

  • message, a length-1 character vector containing the text to display to a user. To extract the message, use conditionMessage(cnd).

  • call, the call which triggered the condition. As described above, we don’t use the call, so it will often be NULL. To extract it, use conditionCall(cnd).

Custom conditions may contain other components, which we’ll discuss in Section 8.5.

Conditions also have a class attribute, which makes them S3 objects. We won’t discuss S3 until Chapter 13, but fortunately, even if you don’t know about S3, condition objects are quite simple. The most important thing to know is that the class attribute is a character vector, and it determines which handlers will match the condition.

8.4.2 Exiting handlers

tryCatch() registers exiting handlers, and is typically used to handle error conditions. It allows you to override the default error behaviour. For example, the following code will return NA instead of throwing an error:

f3 <- function(x) {
  tryCatch(
    error = function(cnd) NA,
    log(x)
  )
}

f3("x")
#> [1] NA

If no conditions are signalled, or the class of the signalled condition does not match the handler name, the code executes normally:

tryCatch(
  error = function(cnd) 10,
  1 + 1
)
#> [1] 2

tryCatch(
  error = function(cnd) 10,
  {
    message("Hi!")
    1 + 1
  }
)
#> Hi!
#> [1] 2

The handlers set up by tryCatch() are called exiting handlers because after the condition is signalled, control passes to the handler and never returns to the original code, effectively meaning that the code exits:

tryCatch(
  message = function(cnd) "There",
  {
    message("Here")
    stop("This code is never run!")
  }
)
#> [1] "There"

The protected code is evaluated in the environment of tryCatch(), but the handler code is not, because the handlers are functions. This is important to remember if you’re trying to modify objects in the parent environment.

The handler functions are called with a single argument, the condition object. I call this argument cnd, by convention. This value is only moderately useful for the base conditions because they contain relatively little data. It’s more useful when you make your own custom conditions, as you’ll see shortly.

tryCatch(
  error = function(cnd) {
    paste0("--", conditionMessage(cnd), "--")
  },
  stop("This is an error")
)
#> [1] "--This is an error--"

tryCatch() has one other argument: finally. It specifies a block of code (not a function) to run regardless of whether the initial expression succeeds or fails. This can be useful for clean up, like deleting files, or closing connections. This is functionally equivalent to using on.exit() (and indeed that’s how it’s implemented) but it can wrap smaller chunks of code than an entire function.

path <- tempfile()
tryCatch(
  {
    writeLines("Hi!", path)
    # ...
  },
  finally = {
    # always run
    unlink(path)
  }
)

8.4.3 Calling handlers

The handlers set up by tryCatch() are called exiting handlers, because they cause code to exit once the condition has been caught. By contrast, withCallingHandlers() sets up calling handlers: code execution continues normally once the handler returns. This tends to make withCallingHandlers() a more natural pairing with the non-error conditions. Exiting and calling handlers use “handler” in slighty different senses:

  • An exiting handler handles a signal like you handle a problem; it makes the problem go away.

  • A calling handler handles a signal like you handle a car; the car still exists.

Compare the results of tryCatch() and withCallingHandlers() in the example below. The messages are not printed in the first case, because the code is terminated once the exiting handler completes. They are printed in the second case, because a calling handler does not exit.

tryCatch(
  message = function(cnd) cat("Caught a message!\n"), 
  {
    message("Someone there?")
    message("Why, yes!")
  }
)
#> Caught a message!

withCallingHandlers(
  message = function(cnd) cat("Caught a message!\n"), 
  {
    message("Someone there?")
    message("Why, yes!")
  }
)
#> Caught a message!
#> Someone there?
#> Caught a message!
#> Why, yes!

Handlers are applied in order, so you don’t need to worry getting caught in an infinite loop. In the following example, the message() signalled by the handler doesn’t also get caught:

withCallingHandlers(
  message = function(cnd) message("Second message"),
  message("First message")
)
#> Second message
#> First message

(But beware if you have multiple handlers, and some handlers signal conditions that could be captured by another handler: you’ll need to think through the order carefully.)

The return value of a calling handler is ignored because the code continues to execute after the handler completes; where would the return value go? That means that calling handlers are only useful for their side-effects.

One important side-effect unique to calling handlers is the ability to muffle the signal. By default, a condition will continue to propagate to parent handlers, all the way up to the default handler (or an exiting handler, if provided):

# Bubbles all the way up to default handler which generates the message
withCallingHandlers(
  message = function(cnd) cat("Level 2\n"),
  withCallingHandlers(
    message = function(cnd) cat("Level 1\n"),
    message("Hello")
  )
)
#> Level 1
#> Level 2
#> Hello

# Bubbles up to tryCatch
tryCatch(
  message = function(cnd) cat("Level 2\n"),
  withCallingHandlers(
    message = function(cnd) cat("Level 1\n"),
    message("Hello")
  )
)
#> Level 1
#> Level 2

If you want to prevent the condition “bubbling up” but still run the rest of the code in the block, you need to explicitly muffle it with rlang::cnd_muffle():

# Muffles the default handler which prints the messages
withCallingHandlers(
  message = function(cnd) {
    cat("Level 2\n")
    cnd_muffle(cnd)
  },
  withCallingHandlers(
    message = function(cnd) cat("Level 1\n"),
    message("Hello")
  )
)
#> Level 1
#> Level 2

# Muffles level 2 handler and the default handler
withCallingHandlers(
  message = function(cnd) cat("Level 2\n"),
  withCallingHandlers(
    message = function(cnd) {
      cat("Level 1\n")
      cnd_muffle(cnd)
    },
    message("Hello")
  )
)
#> Level 1

8.4.4 Call stacks

To complete the section, there are some important differences between the call stacks of exiting and calling handlers. These differences are generally not important but I’m including them here because I’ve occasionally found them useful, and don’t want to forget about them!

It’s easiest to see the difference by setting up a small example that uses lobstr::cst():

f <- function() g()
g <- function() h()
h <- function() message("!")

Calling handlers are called in the context of the call that signalled the condition:

withCallingHandlers(f(), message = function(cnd) {
  lobstr::cst()
  cnd_muffle(cnd)
})
#>      █
#>   1. ├─base::withCallingHandlers(...)
#>   2. ├─global::f()
#>   3. │ └─global::g()
#>   4. │   └─global::h()
#>   5. │     └─base::message("!")
#>   6. │       ├─base::withRestarts(...)
#>   7. │       │ └─base:::withOneRestart(expr, restarts[[1L]])
#>   8. │       │   └─base:::doWithOneRestart(return(expr), restart)
#>   9. │       └─base::signalCondition(cond)
#>  10. └─(function (cnd) ...
#>  11.   └─lobstr::cst()

Whereas exiting handlers are called in the context of the call to tryCatch():

tryCatch(f(), message = function(cnd) lobstr::cst())
#>     █
#>  1. └─base::tryCatch(f(), message = function(cnd) lobstr::cst())
#>  2.   └─base:::tryCatchList(expr, classes, parentenv, handlers)
#>  3.     └─base:::tryCatchOne(expr, names, parentenv, handlers[[1L]])
#>  4.       └─value[[3L]](cond)
#>  5.         └─lobstr::cst()

8.4.5 Exercises

  1. What extra information does the condition generated by abort() contain compared to the condition generated by stop() i.e. what’s the difference between these two objects? Read the help for ?abort to learn more.

    catch_cnd(stop("An error"))
    catch_cnd(abort("An error"))
  2. Predict the results of evaluating the following code

    show_condition <- function(code) {
      tryCatch(
        error = function(cnd) "error",
        warning = function(cnd) "warning",
        message = function(cnd) "message",
        {
          code
          NULL
        }
      )
    }
    
    show_condition(stop("!"))
    show_condition(10)
    show_condition(warning("?!"))
    show_condition({
      10
      message("?")
      warning("?!")
    })
  3. Explain the results of running this code:

    withCallingHandlers(
      message = function(cnd) message("b"),
      withCallingHandlers(
        message = function(cnd) message("a"),
        message("c")
      )
    )
    #> b
    #> a
    #> b
    #> c
  4. Read the source code for catch_cnd() and explain how it works.

  5. How could you rewrite show_condition() to use a single handler?