ExceptionUnwrapping.jl

"I started by producing, and the rapping came second to that, because I wanted to fill out the beat." - Awkwafina
Author NHDaly
Popularity
1 Star
Updated Last
1 Year Ago
Started In
March 2020

ExceptionUnwrapping.jl

Build Status

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

  • 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.