Conditions

Learning objectives

  • What conditions are
  • How to generate (“signal”) them
  • How to consume (“handle”) them

R’s condition system allows us to handle things going wrong

An error might occur due to events outside of your program—a missing file, a full disk, a crashed server, etc.

“Errors aren’t caused by bugs, but neglecting to handle an error is almost certainly a bug.” —Seibel (2003)

Conditions help us maintain the illusion of “black box” functions.

There are three types of conditions

  1. ❌ Errors
  2. ⚠️ Warnings
  3. 💬 Messages

Function authors signal conditions

Function users handle conditions

Signaling conditions

Errors indicate that a problem occurred and the function cannot continue

f <- function() {
  cat("starting f()\n")
  g()
  cat("finishing f()\n")
}

g <- function() {
  cat("starting g()\n")
  stop("This is an error!")
  cat("finishing g()\n")
}

f()
#> starting f()
#> starting g()
#> Error in g(): This is an error!

Pass objects to stop() to print error messages

  • stop(...) can accept zero or more objects

    • If the first object is not a condition object1, its arguments are converted to character and pasted together with no separator
  • Pass call. = FALSE to prevent the function that raised the error from being added to the message

  • rlang::abort() signals errors with a different interface

Tracebacks display the call stack at the time of the last error

f()
traceback()
#> 4: stop("This is an error!") at #2
#> 2: g() at #3
#> 1: f()

This is one reason to prefer stop(..., call. = FALSE)

rlang::last_trace() has prettier printing

f()
rlang::last_trace()
#> <error/rlang_error>
#> Error in `g()`:
#> ! This is an error!
#> ---
#> Backtrace:
#>     ▆
#>  1. └─global f()
#>  2.   └─global g()
#> Run rlang::last_trace(drop = FALSE) to see 1 hidden frame.

Warnings indicate that a problem occurred but the function can continue

f <- function() {
  warning("F1")
  g()
  warning("F2")
  cat("finishing f()\n")
}

g <- function() {
  warning("G1")
  cat("finishing g()\n")
}

f()
#> finishing g()
#> finishing f()
#> Warning messages:
#> 1: In f() : F1
#> 2: In g() : G1
#> 3: In f() : F2

Use warnings sparingly

  • Ideal for deprecating functions
  • Good when “you are reasonably certain” that a problem is recoverable
  • “Base R tends to overuse warnings, and many warnings in base R would be better off as errors”

Messages indicate that something noteworthy has happened

f <- function() {
  message("F1")
  g()
  message("F2")
  cat("finishing f()\n")
}

g <- function() {
  message("G1")
  cat("finishing g()\n")
}

f()
#> F1
#> G1
#> finishing g()
#> F2
#> finishing f()

Handling conditions

Ignore errors with try()

bad_log <- function(x) {
  try(log(x))
  10
}

bad_log("bad")
#> Error in log(x) : non-numeric argument to mathematical function
#> [1] 10

try() with assignment can support default values in case of failure

file_contents <- NULL
try(file_contents <- read.csv("possibly-bad-input.csv"), silent = TRUE)
is.null(file_contents)
#> [1] TRUE

Warnings and messages can be independently suppressed

chatty_function <- function() {
  warning("warning 1")
  message("message 1")
  warning("warning 2")
  message("message 2")
  42
}

suppressWarnings(chatty_function())
#> message 1
#> message 2
#> [1] 42
suppressMessages(chatty_function())
#> [1] 42
#> Warning messages:
#> 1: In chatty_function() : warning 1
#> 2: In chatty_function() : warning 2

Every condition has a default behavior

  • ❌ Errors halt execution
  • ⚠️ Warnings are collected during execution and displayed in bulk after execution
  • 💬 Messages are displayed immediately

These can be changed through exiting and calling handlers

tryCatch() specifies or modifies exiting handlers

exp_log <- function(x) {
  tryCatch(
    error = function(cnd) NA,
    {
      y <- log(x)
      cat("Reconstituting 'x'\n")
      exp(y)
    }
  )
}

exp_log(23)
#> Reconstituting 'x'
#> [1] 23
exp_log("x")
#> [1] NA

Exiting handlers never return control to the original code

tryCatch(
  message = function(cnd) {
    cat("Caught a message condition:", conditionMessage(cnd))
    "The return value of the message handler"
  },
  {
    message("This is a message")
    cat("This code won't be run inside 'tryCatch()' if messages are caught\n")
    "The return value of the original expression"
  },
  finally = {
    cat("The code in 'finally' is always run\n")
  }
)
#> Caught a message condition: This is a message
#> The code in 'finally' is always run
#> [1] "The return value of the message handler"

withCallingHandlers() specifies or modifies calling handlers

withCallingHandlers(
  message = function(cnd) {
    cat("Caught a message condition:", conditionMessage(cnd))
    "The return value of the message handler is ignored"
  },
  {
    message("This is a message")
    cat("This code should run\n")
    message("This is another message")
    "The return value of the original expression"
  }
  # No finally option
)
#> Caught a message condition: This is a message
#> This code should run
#> Caught a message condition: This is another message
#> [1] "The return value of the original expression"

Conditions “bubble up” to other calling handlers by default

withCallingHandlers(
  message = function(cnd) cat("Level 2\n"),
  withCallingHandlers(
    message = function(cnd) cat("Level 1\n"),
    {
      message("Hello")
      cat("Finishing code block\n")
    }
  )
)
#> Level 1
#> Level 2
#> Finishing code block

Conditions also bubble up to tryCatch()

tryCatch(
  message = function(cnd) cat("Level 2\n"),
  withCallingHandlers(
    message = function(cnd) cat("Level 1\n"),
    {
      message("Hello")
      cat("This exiting handler prevents this from running\n")
    }
  )
)
#> Level 1
#> Level 2

rlang::cnd_muffle() will stop the propagation to calling handlers “higher up”

withCallingHandlers(
  message = function(cnd) cat("Level 2\n"),
  withCallingHandlers(
    message = function(cnd) {
      cat("Level 1\n")
      rlang::cnd_muffle(cnd)
    },
    message("Hello")
  )
)
#> Level 1

Calling handlers are called in the context of the call that signaled the condition

f <- function() g()
g <- function() message("hello")

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

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 (local) tryCatchList(expr, classes, parentenv, handlers)
#>  3.     └─base (local) tryCatchOne(expr, names, parentenv, handlers[[1L]])
#>  4.       └─value[[3L]](cond)
#>  5.         └─lobstr::cst()

Condition objects

Built-in conditions are lists with two elements

cnd <- rlang::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"
  • message can be accessed with conditionMessage(cnd)
  • call is not generally useful
  • The class attribute will be explained alongside S3

Custom conditions can introduce additional attributes

cnd <- rlang::catch_cnd(rlang::abort("An error!"))
str(cnd)
#> List of 5
#>  $ message: chr "An error!"
#>  $ trace  :Classes 'rlang_trace', 'rlib_trace', 'tbl' and 'data.frame':  8 obs. of  6 variables:
#>   ..$ call       :List of 8
#>   .. ..$ : language rlang::catch_cnd(rlang::abort("An error!"))
#>   .. ..$ : language eval_bare(rlang::expr(tryCatch(!!!handlers, {     force(expr) ...
#>   .. ..$ : language tryCatch(condition = `<fn>`, {     force(expr) ...
#>   .. ..$ : language tryCatchList(expr, classes, parentenv, handlers)
#>   .. ..$ : language tryCatchOne(expr, names, parentenv, handlers[[1L]])
#>   .. ..$ : language doTryCatch(return(expr), name, parentenv, handler)
#>   .. ..$ : language force(expr)
#>   .. ..$ : language rlang::abort("An error!")
#>   ..$ parent     : int [1:8] 0 1 1 3 4 5 1 0
#>   ..$ visible    : logi [1:8] FALSE FALSE FALSE FALSE FALSE FALSE ...
#>   ..$ namespace  : chr [1:8] "rlang" "rlang" "base" "base" ...
#>   ..$ scope      : chr [1:8] "::" "::" "::" "local" ...
#>   ..$ error_frame: logi [1:8] FALSE FALSE FALSE FALSE FALSE FALSE ...
#>   ..- attr(*, "version")= int 2
#>  $ parent : NULL
#>  $ rlang  :List of 1
#>   ..$ inherit: logi TRUE
#>  $ call   : NULL
#>  - attr(*, "class")= chr [1:3] "rlang_error" "error" "condition"

rlang::abort() can compose and throw custom conditions

We create a function that will signal an error condition

abort_missing_file <- function(file_path) {
  rlang::abort(
    "error_not_found",
    message = glue::glue("Path `{file_path}` not found"),
    path = file_path
  )
}
abort_missing_file("blah.csv")
#> Error in `abort_missing_file()`:
#> ! Path `blah.csv` not found

This makes it easy to format messages

Deal with custom conditions by creating specific handlers

my_csv_reader <- function(file) {
  if (!file.exists(file)) abort_missing_file(file)
  read.csv(file)
}

dat <- tryCatch(
  error_not_found = function(cnd) data.frame(),
  error = function(cnd) NULL,
  my_csv_reader("blah.csv")
)

dat
#> data frame with 0 columns and 0 rows

Applications

See the sub-section in the book for more excellent examples.

Resources