Skip to content

Commit

Permalink
feat: Add shiny.error.unhandled error handler (#3989)
Browse files Browse the repository at this point in the history
* feat(shiny.error.unhandled): Allow users to provide an unhandled error handler

* Extract `shinyUserErrorUnhandled()` to use in MockSession too

* tests(shiny.error.unhandled): Test that unhandled errors are handled safely

* docs: Clarify that session still ends with an unhandled error

* docs: Add news item
  • Loading branch information
gadenbuie authored Mar 8, 2024
1 parent c73978c commit e2b7f91
Show file tree
Hide file tree
Showing 7 changed files with 88 additions and 0 deletions.
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

* Added a new `ExtendedTask` abstraction, for long-running asynchronous tasks that you don't want to block the rest of the app, or even the rest of the session. Designed to be used with new `bslib::input_task_button()` and `bslib::bind_task_button()` functions that help give user feedback and prevent extra button clicks. (#3958)

* Added a `shiny.error.unhandled` option that can be set to a function that will be called when an unhandled error occurs in a Shiny app. Note that this handler doesn't stop the error or prevent the session from closing, but it can be used to log the error or to clean up session-specific resources. (thanks @JohnCoene, #3989)

## Bug fixes

* Notifications are now constrained to the width of the viewport for window widths smaller the default notification panel size. (#3949)
Expand Down
1 change: 1 addition & 0 deletions R/mock-session.R
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@ MockShinySession <- R6Class(
#' @description Called by observers when a reactive expression errors.
#' @param e An error object.
unhandledError = function(e) {
shinyUserErrorUnhandled(e)
self$close()
},
#' @description Freeze a value until the flush cycle completes.
Expand Down
6 changes: 6 additions & 0 deletions R/shiny-options.R
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ getShinyOption <- function(name, default = NULL) {
#' \item{shiny.error (defaults to `NULL`)}{This can be a function which is called when an error
#' occurs. For example, `options(shiny.error=recover)` will result a
#' the debugger prompt when an error occurs.}
#' \item{shiny.error.unhandled (defaults to `NULL`)}{A function that will be
#' called when an unhandled error that will stop the app session occurs. This
#' function should take the error condition object as its first argument.
#' Note that this function will not stop the error or prevent the session
#' from ending, but it will provide you with an opportunity to log the error
#' or clean up resources before the session is closed.}
#' \item{shiny.fullstacktrace (defaults to `FALSE`)}{Controls whether "pretty" (`FALSE`) or full
#' stack traces (`TRUE`) are dumped to the console when errors occur during Shiny app execution.
#' Pretty stack traces attempt to only show user-supplied code, but this pruning can't always
Expand Down
2 changes: 2 additions & 0 deletions R/shiny.R
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,8 @@ ShinySession <- R6Class(
return(private$inputReceivedCallbacks$register(callback))
},
unhandledError = function(e) {
"Call the user's unhandled error handler and then close the session."
shinyUserErrorUnhandled(e)
self$close()
},
close = function() {
Expand Down
24 changes: 24 additions & 0 deletions R/utils.R
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,30 @@ shinyCallingHandlers <- function(expr) {
)
}

shinyUserErrorUnhandled <- function(error, handler = NULL) {
if (is.null(handler)) {
handler <- getShinyOption(
"shiny.error.unhandled",
getOption("shiny.error.unhandled", NULL)
)
}

if (is.null(handler)) return()

if (!is.function(handler) || length(formals(handler)) == 0) {
warning(
"`shiny.error.unhandled` must be a function ",
"that takes an error object as its first argument",
immediate. = TRUE
)
return()
}

tryCatch(
shinyCallingHandlers(handler(error)),
error = printError
)
}

#' Register a function with the debugger (if one is active).
#'
Expand Down
6 changes: 6 additions & 0 deletions man/shinyOptions.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions tests/testthat/test-test-server.R
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,53 @@ test_that("session ended handlers work", {
})
})

test_that("shiny.error.unhandled handles unhandled errors", {
caught <- NULL
op <- options(shiny.error.unhandled = function(error) {
caught <<- error
stop("bad user error handler")
})
on.exit(options(op))

server <- function(input, output, session) {
observe({
req(input$boom > 1) # This signals an error that shiny handles
stop("unhandled error") # This error is *not* and brings down the app
})
}

testServer(server, {
session$setInputs(boom = 1)
# validation errors *are* handled and don't trigger the unhandled error
expect_null(caught)
expect_false(session$isEnded())
expect_false(session$isClosed())

# All errors are caught, even the error from the unhandled error handler
# And these errors are converted to warnings
expect_no_error(
expect_warning(
expect_warning(
# Setting input$boom = 2 throws two errors that become warnings:
# 1. The unhandled error in the observe
# 2. The error thrown by the user error handler
capture.output(
session$setInputs(boom = 2),
type = "message"
),
"unhandled error"
),
"bad user error handler"
)
)

expect_s3_class(caught, "error")
expect_equal(conditionMessage(caught), "unhandled error")
expect_true(session$isEnded())
expect_true(session$isClosed())
})
})

test_that("session flush handlers work", {
server <- function(input, output, session) {
rv <- reactiveValues(x = 0, flushCounter = 0, flushedCounter = 0,
Expand Down

0 comments on commit e2b7f91

Please sign in to comment.