Ideas for more convenient exception handling

131 views
Skip to first unread message

samoconnor

unread,
Aug 8, 2014, 9:52:07 AM8/8/14
to juli...@googlegroups.com
I've been playing with macros to extend Julia's basic try/throw/catch exception handling mechanism.

First I have a question about the return value of "try expr end".

julia> try 1 == 1 end
true

julia
> try 1 == 0 end
false

julia
> try error() end
false

I like that "try expr end" returns false if expr throws an error. It's great being able to write "if try e.message == "Busy" end ..." where "e" may not even have a field named "message".
Is this a documented feature of "try"?

Lately I've been using Tcl's try / throw / trap / finally mechanism a lot for flow control in a amazon web services library. I've also been using a custom "retry" variant of "try" in Tcl.
An AWS library has to deal with all kinds of things that can go wrong with networks, unreliable pools of servers, eventual consistency models, etc. The library tries to hide as much of this stuff as possible and provide the illusion of a simple reliable interface. This means lots of exception handling, retry loops, etc. On one hand this could be a simple "retry on network timeout", but at a higher level, it can be refreshing expired security tokens, or even destroying and re-creating a cluster of virtual Linux boxes that are suffering from degraded hardware.

In summary I have lots of code wrapped in "try" blocks and "retry" blocks, often with handling of three or more specific exception cases.

My initial reaction to reading about Julia's try/throw/catch was to be shocked that "catch" catches everything. In my past experience of exception handling it's really important to catch only the exceptions you can meaningfully deal with and nothing else.

So, I started thinking about ways to extend try/throw/catch to:
  • be safer with regard to re-trhowing un-handled exceptions,
  • provide a convenient way to try again if something went wrong, and
  • reduce the amount of code needed to filter for specific exceptions in the catch block.
Has anyone else been playing with exception handling syntax sugar?

@safe try

My first extension is @safe try... -- automatic rethrow() if control reaches the end of the catch block (unless e is set to nothing).
and @trap e if condition ... -- handle "e" if condition is true (and don't rethrow()).
The idea is to avoid having a whole bunch of chained else-ifs with a rethrow() at the end.

e.g.
@safe try

    r
= s3_get(url)

catch e
   
if typeof(e) == UVError
        println
("ignoring socket error: " * string(e))
        e = nothing
   
end

   
@trap e if e.code in {"NoSuchKey", "AccessDenied"}
       
r = ""
   
end

    log
("Ignoring exception thrown while fetching $url: " * string(e))
end

The UVerror is not re-thorwn because "e" is set to nothing.

If the error has a "code" field, and it is equal to NoSuchKey or AccessDenied, the error is not re-trhown because of the @trap macro.

All other errors are logged and then rethrown up the stack.

The ignore UVError handling above could be re-written to use @trap like this:
@trap e if typeof(e)== UVError end

Another simple example where a non-existent queue error is ignored on delete:
@safe try

    println
("Deleting SQS Queue $(aws["path"])")
    sqs
(aws, "DeleteQueue")

catch e
   
@trap e if e.code == "AWS.SimpleQueueService.NonExistentQueue" end
end


Here is the implementation:

# @safe try... re-writes "try_expr" to automatically rethrow()
# at end of "catch" block (unless exception has been set to nothing).
#
# @trap e if... re-writes "if_expr" to ignore exceptions thrown by the if "condition"
# and to set "exception" = nothing if the "condition" is true.

macro safe
(try_expr::Expr)

   
@assert string(try_expr.head) == "try"

   
(try_block, exception, catch_block) = try_expr.args

    push
!(catch_block.args, :($exception == nothing || rethrow($exception)))

   
return try_expr
end

macro trap
(exception::Symbol, if_expr::Expr)

   
@assert string(if_expr.head) == "if"

   
(condition, action) = if_expr.args

    quote
       
if try $(esc(condition)) end
            $
(esc(action))
            $
(esc(exception)) = nothing
       
end
   
end
end


@with_retry_limit n try

My second try macro trys to execute the try block n times. On the first n - 1 attempts, the catch block can do @retry to try again. On the nth attempt, the catch block is disabled and errors are thrown upwards.

e.g.
@with_retry_limit 4 try

   
return http_get(url)

catch e
   
if (typeof(e) == UVError)
       
@retry
   
end
end

The http_get call is tried up to 4 times in the case of a UVError. Any error other than a UVError is rethrown immediately. If the UVError still occurs on the last try, then it is rethrown too.

Here is a real example from my library. This is the low-level exponential back-off / retry loop for handling network layer errors and server-side HTTP errors...

function http_attempt!(uri::URI, request::Request)

    delay
= 0.05

   
@with_retry_limit 4 try

       
return do_http!(uri, request)

   
catch e

       
if (typeof(e) == UVError
       
||  typeof(e) == HTTPException && !(200 <= status(e) < 500))

            sleep
(delay * (0.8 + (0.4 * rand())))
            delay
*= 10

           
@retry
       
end
   
end

   
assert(false) # Unreachable.
end


Here is the next layer up, handling HTTP Redirect (with new signature computed for new URL request) and automatic security token refresh...

function aws_attempt(request::AWSRequest)

   
# Try request 3 times to deal with possible Redirect and ExiredToken...
   
@with_retry_limit 3 try

       
if !haskey(request.aws, "access_key_id")
            request
.aws = get_aws_ec2_instance_credentials(request.aws)
       
end
        sign_aws_request
!(request)
       
return http_attempt(request)

   
catch e
       
if typeof(e) == HTTPException

           
# Try again on HTTP Redirect...
           
if (status(e) in {301, 302, 307}
           
&&  haskey(e.response.headers, "Location"))
                request
.url = e.response.headers["Location"]
               
@retry
           
end

            e
= AWSException(e)

           
# Try again on ExpiredToken error...
           
if e.code == "ExpiredToken"
               
delete(request.aws, "access_key_id")
               
@retry
           
end
       
end
   
end

   
assert(false) # Unreachable.
end

Note that the above is not just a blind-retry. in both the @retry cases, something is altered before the @retry.
On Redirect, the url is changed.
Then, the HTTPException is promoted to an AWSException (the constructor does XML or JSON parsing of the AWS response).
If the AWSException indicates ExpiredToken, then the token is cleared, causing a new token to be requested for the retry.


Here is a higher-level example where we delete the queue and try again if it already exists; and the we handle a back-off request if the service complains about trying to re-create a deleted queue too fast...

    @with_retry_limit 4 try

        r
= sqs(aws, "CreateQueue", merge(attributes, {"QueueName" => name}))
        url
= get(parse_xml(r.data), "QueueUrl")
       
return merge(aws, {"path" => URI(url).path})

   
catch e

       
if typeof(e) == AWSException

           
if (e.code == "QueueAlreadyExists")
                sqs_delete_queue
(aws, name)
               
@retry
           
end

           
if (e.code == "AWS.SimpleQueueService.QueueDeletedRecently")
                println
("""Waiting 1 minute to re-create Queue "$name"...""")
                sleep
(60)
               
@retry
           
end
       
end
   
end


Implementation:

macro with_retry_limit(max::Integer, try_expr::Expr)


   
@assert string(try_expr.head) == "try"


   
# Split try_expr into component parts...
   
(try_block, exception, catch_block) = try_expr.args


   
# Insert a rethrow() at the end of the catch_block...
    push
!(catch_block.args, :($exception == nothing || rethrow($exception)))


   
# Build retry expression...
    retry_expr
= quote


       
# Loop one less than "max" times...
       
for i in [1 : $max - 1]


           
# Execute the "try_expr"...
           
# (It can "continue" if it wants to try again)
            $
(esc(try_expr))


           
# Only get to here if "try_expr" executed cleanly...
           
return
       
end


       
# On the last of "max" attempts, execute the "try_block" naked
       
# so that exceptions get thrown up the stack...
        $
(esc(try_block))
   
end
end

macro
retry() :(continue) end

Reply all
Reply to author
Forward
0 new messages