ExceptionUnwrapping.ExceptionUnwrapping โ€” Module

ExceptionUnwrapping.jl

ExceptionUnwrapping.jl provides exception handling utilities to allow inspecting and unwrapping "wrapped exceptions," by which we mean any Exception type that itself embeds another Exception.

The most common example is a TaskFailedException, which wraps a Task and the exception that caused that Task to fail. Another example is the exception types in Salsa.jl.

API

  • has_wrapped_exception(e, ExceptionType)::Bool

  • is_wrapped_exception(e)::Bool

  • unwrap_exception(exception_wrapper) -> wrapped_exception

  • unwrap_exception(normal_exception) -> normal_exception

  • unwrap_exception_until(e, ExceptionType)::ExceptionType

  • unwrap_exception_to_root(exception_wrapper) -> wrapped_exception

  • unwrap_exception_to_root(normal_exception) -> normal_exception

Usage

If your library provides a wrapped exception type, you should register it with this package by simply adding a method to unwrap_exception:

ExceptionUnwrapping.unwrap_exception(e::MyWrappedException) = e.exception

In client code, you should use has_wrapped_exception and unwrap_exception_until in catch blocks:

try
    ...
catch e
    if has_wrapped_exception(e, BoundsError)
        be = unwrap_exception_until(e, BoundsError)
        # ...Use BoundsError...
    else
        rethrow()
    end
end

Finally, you can improve robustness in client tests via @test_throws_wrapped:

@test_throws_wrapped AssertionError my_possibly_multithreaded_function()

Motivating Example: Stable Exception Handling

A Problem: Adding Concurrency to a Library Can Break Users' Exception Handling

As we all start using concurrency more, exception handling can get a bit weird. Julia's cooperative multithreading is designed to be composable as a fundamental principle, but changing syncronous code to run concurrently in a Task changes the types of Exceptions that code will throw!

Consider for example this silly program, which wants to handle a certain type of Exception (BoundsErrors) in order to take meaningful action (ask the user to try again):

function get_and_sort_names_by_first_letter(n)
    try
        names = [readline() for _ in 1:n]
        # Use this libary's sort function because it's supposed to be wicked fast ๐Ÿค˜
        return library_sort(names, by=a->a[1])
    catch e
        if e isa BoundsError
            println("Oops! You entered an empty name. Please try again!")
            # Give the user another shot
            return get_and_sort_names_by_first_letter(n)
        else
            rethrow()  # Unknown error
        end
    end
end

All is well and good:

julia> get_and_sort_names_by_first_letter(2)

Valentin
Oops! You entered an empty name. Please try again!
Valentin
Jane
2-element Array{String,1}:
 "Jane"
 "Valentin"

But what happens if that library decides to parallelize its sorting function, so now its even wicked faster? (๐Ÿค˜๐Ÿค˜?)

# lol, well, this won't make it any faster, but it demonstrates the point.
library_sort(args...; kwargs...) = fetch(Threads.@spawn sort(args...; kwargs...))

What happens is the library has inadvertently broken its caller:

julia> get_and_sort_names_by_first_letter(2)

Nathan
ERROR: TaskFailedException:
BoundsError: attempt to access String
  at index [1]
Stacktrace:
 [1] checkbounds at ./strings/basic.jl:193 [inlined]
 [2] codeunit at ./strings/string.jl:89 [inlined]
 [3] getindex at ./strings/string.jl:210 [inlined]
 [4] #10 at /Users/nathan.daly/.julia/dev/ExceptionUnwrapping/src/ExceptionUnwrapping.jl:125 [inlined]
 [5] lt(::Base.Order.By{var"#10#12"}, ::String, ::String) at ./ordering.jl:51
 [6] sort!(::Array{String,1}, ::Int64, ::Int64, ::Base.Sort.InsertionSortAlg, ::Base.Order.By{var"#10#12"}) at ./sort.jl:468
 [7] sort!(::Array{String,1}, ::Int64, ::Int64, ::Base.Sort.MergeSortAlg, ::Base.Order.By{var"#10#12"}, ::Array{String,1}) at .

The library never promised to return a BoundsError, so it can't know it's supposed to handle and unwrap any TaskFailedException it encounters; maybe the user would want to see the TaskFailedException. And the user's code felt comfortable depending on the BoundsError, since it's coming from the lambda it provided directly, so it thought it would know what kind of exceptions could be produced.

And since this code path is error handling, it's quite possibly it's poorly tested!

What a conundrum! And so, we present here a solution: ExceptionUnwrapping.jl

The Solution: ExceptionUnwrapping.jl

If the user always structures their execption checks using ExceptionUnwrapping, then it will continue working despite any changes to the underlying concurrency model:

function get_and_sort_names_by_first_letter(n)
    try
        names = [readline() for _ in 1:n]
        # Use this libary's sort function because it's supposed to be wicked fast ๐Ÿค˜
        return library_sort(names, by=a->a[1])
    catch e
      # Use ExceptionUnwrapping's check to see whether `e` either _is_ a BoundsError _or_
      # if it is _wrapping_ a BoundsError.
      if has_wrapped_exception(e, BoundsError)
            println("Oops! You entered an empty name. Please try again!")
            # Give the user another shot
            return get_and_sort_names_by_first_letter(n)
        else
            rethrow()  # Unknown error
        end
    end
end

Now it will work again, regardless of whether library_sort is using Tasks internally or not, which is exactly what we want from composable multithreading! :)

julia> get_and_sort_names_by_first_letter(2)

Nathan
Oops! You entered an empty name. Please try again!
Nathan
Martin
2-element Array{String,1}:
 "Martin"
 "Nathan"

Terminology:

"Wrapped Exceptions" vs "Exception Causes"

In julia, one exception can be "caused by" another exception if a new exception is thrown from within an catch-block (or finally-block). This is not the situation that this package is addressing.

For example:

julia> try
           throw(ErrorException("1"))
       catch e
           throw(ErrorException("2"))
       end
ERROR: 2
Stacktrace:
 [1] top-level scope at REPL[1]:4
caused by [exception 1]
1
Stacktrace:
 [1] top-level scope at REPL[1]:2

This is situation already well covered by Julia's standard library, which has functions like Base.catch_stack() which will return the above stack of exceptions that were thrown (and is used to print the caused by display above).

Instead, this package is for dealing with "wrapped exceptions", which is a term we are coining to refer to Exceptions that embed another Exception inside of them, either to add information or context, or because the exception mechanism cannot cross the boundary between Tasks.

ExceptionUnwrapping._summarize_exception โ€” Method
_summarize_exception(io::IO, e::TaskFailedException, _)
_summarize_exception(io::IO, e::CompositeException, stack)
_summarize_exception(io::IO, e::Exception, stack)

The secret sauce that lets us unwrap TaskFailedExceptions and CompositeExceptions, and summarize the actual exception.

TaskFailedException simply wraps a task, so it is just unwrapped, and processed by summarizetask_exceptions().

CompositeException simply wraps a Vector of Exceptions. Each of the individual Exceptions is summarized.

All other exceptions are printed via Base.showerror(). The first stackframe in the backtrace is also printed.

ExceptionUnwrapping.has_wrapped_exception โ€” Function
has_wrapped_exception(e, ExceptionType)::Bool

Returns true if the given exception instance, e, contains an exception of type T anywhere in its chain of unwrapped exceptions.

Application code should prefer to use has_wrapped_exception(e, T) instead of e isa T in catch-blocks, to keep code from breaking when libraries wrap user's exceptions.

This makes application code resilient to library changes that may cause wrapped exceptions, such as e.g. changes to underlying concurrency decisions (thus maintaining concurrency's cooperative benefits).

Example

try
    # If this becomes concurrent in the future, the catch-block doesn't need to change.
    library_function(args...)
catch e
    if has_wrapped_exception(e, MyExceptionType)
        unwrapped = unwrap_exception_until(e, MyExceptionType)
        handle_my_exception(unwrapped, caught=e)
    else
        rethrow()
    end
end
ExceptionUnwrapping.is_wrapped_exception โ€” Function
is_wrapped_exception(e)::Bool

Returns true if the given exception instance, e is a wrapped exception, such that unwrap_exception(e) would return something different than e.

ExceptionUnwrapping.summarize_current_exceptions โ€” Function
summarize_current_exceptions(io::IO = Base.stderr, task = current_task())

Print a summary of the [current] task's exceptions to io.

This is particularly helpful in cases where the exception stack is large, the backtraces are large, and CompositeExceptions with multiple parts are involved.

ExceptionUnwrapping.unwrap_exception โ€” Function
unwrap_exception(exception_wrapper) -> wrapped_exception
unwrap_exception(normal_exception) -> normal_exception

# Add overrides for custom exception types
ExceptionUnwrapping.unwrap_exception(e::MyWrappedException) = e.wrapped_exception

Unwraps a wrapped exception by one level. New wrapped exception types should add a method to this function.

One example of a wrapped exception is the TaskFailedException, which wraps an exception thrown by a Task with a new Exception describing the task failure.

It is useful to unwrap the exception to test what kind of exception was thrown in the first place, which is useful in case you need different exception handling behavior for different types of exceptions.

Authors of new wrapped exception types can overload this to indicate what field their exception is wrapping, by adding an overload, e.g.:

ExceptionUnwrapping.unwrap_exception(e::MyWrappedException) = e.wrapped_exception

This is used in the implementations of the other functions in the module:

ExceptionUnwrapping.unwrap_exception_to_root โ€” Function
unwrap_exception_to_root(exception_wrapper) -> wrapped_exception
unwrap_exception_to_root(normal_exception) -> normal_exception

Unwrap a wrapped exception to its bottom layer.

ExceptionUnwrapping.@test_throws_wrapped โ€” Macro
@test_throws_wrapped exception expr

Similar to Test.@test_throws, but this tests that the expression expr either throws exception, OR that it throws an expression wrapping exception.

Users can use this function if they aren't concerned about whether an exception is wrapped or not, e.g. not caring whether the user is using concurrency.

Examples

julia> @test_throws_wrapped BoundsError [1, 2, 3][4]
Test Passed
      Thrown: BoundsError

julia> @test_throws_wrapped DimensionMismatch fetch(@async [1, 2, 3] + [1, 2])
Test Passed
      Thrown: DimensionMismatch