How to keep the stacktrace when rethrowing an exce

2019-06-16 16:11发布

TL;DR: how to raise a previously caught exception later on, while preserving the original exception's stacktrace.

Since I think this is useful with the Result monad or computation expression, esp. since that pattern is often used for wrapping an exception without throwing it, here's a worked out example of that:

type Result<'TResult, 'TError> =
    | Success of 'TResult
    | Fail of 'TError

module Result =
    let bind f = 
        function
        | Success v -> f v
        | Fail e -> Fail e

    let create v = Success v

    let retnFrom v = v

    type ResultBuilder () =
        member __.Bind (m , f) = bind f m
        member __.Return (v) = create v
        member __.ReturnFrom (v) = retnFrom v
        member __.Delay (f) = f
        member __.Run (f) = f()
        member __.TryWith (body, handler) =
            try __.Run body
            with e -> handler e

[<AutoOpen>]
module ResultBuilder =
    let result = Result.ResultBuilder()

And now let's use it:

module Extern =
    let calc x y = x / y


module TestRes =
    let testme() =
        result {
            let (x, y) = 10, 0
            try
                return Extern.calc x y
            with e -> 
                return! Fail e
        }
        |> function
        | Success v -> v
        | Fail ex -> raise ex  // want to preserve original exn's stacktrace here

The problem is that the stacktrace will not include the source of the exception (here namely the calc function). If I run the code as written, it will throw as follows, which gives no information to the origin of the error:

System.DivideByZeroException : Attempted to divide by zero.
   at Microsoft.FSharp.Core.Operators.Raise[T](Exception exn)
   at PlayFul.TestRes.testme() in D:\Experiments\Play.fs:line 197
   at PlayFul.Tests.TryItOut() in D:\Experiments\Play.fs:line 203

Using reraise() won't work, it wants a catch-context. Obviously, the following kind-a works, but makes debugging harder because of the nested exceptions and could get pretty ugly if this wrap-reraise-wrap-reraise pattern gets called multiple times in a deep stack.

System.Exception("Oops", ex)
|> raise

Update: TeaDrivenDev suggested in the comments to use ExceptionDispatchInfo.Capture(ex).Throw(), which works, but requires to wrap the exception in something else, complicating the model. However, it does preserve the stacktrace and it can be made into a fairly workable solution.

2条回答
Luminary・发光体
2楼-- · 2019-06-16 16:46

For those who missed the point about "out of catch-context" (like me) - you can use reraise() to preserve the stack when throwing from a catch block.

查看更多
何必那么认真
3楼-- · 2019-06-16 17:02

One of the things I was afraid of is that once you treat an exception as a normal object and pass it around, you won't be able to raise it again and keep its original stacktrace.

But that's only true if you do, in-between or at the end, a raise excn.

I have taken all the ideas from the comments and show them here as three solutions to the problem. Choose whichever feels most natural to you.

Capture the stacktrace

The following example shows TeaDrivenDev's proposal in action, using ExceptionDispatchInfo.Capture.

type Ex =
    /// Capture exception (.NET 4.5+), keep the stack, add current stack. 
    /// This puts the origin point of the exception on top of the stacktrace.
    /// It also adds a line in the trace:
    /// "--- End of stack trace from previous location where exception was thrown ---"
    static member inline throwCapture ex =
        ExceptionDispatchInfo.Capture ex
        |> fun disp -> disp.Throw()
        failwith "Unreachable code reached."

With the example in the original question (replace raise ex), this will create the following trace (note the line with "--- End of stack trace from previous location where exception was thrown ---"):

System.DivideByZeroException : Attempted to divide by zero.
   at Playful.Ex.Extern.calc(Int32 x, Int32 y) in R:\path\Ex.fs:line 118
   at Playful.Ex.TestRes.testme@137-1.Invoke(Unit unitVar) in R:\path\Ex.fs:line 137
   at Playful.Ex.Result.ResultBuilder.Run[b](FSharpFunc`2 f) in R:\path\Ex.fs:line 103
   at Playful.Ex.Result.ResultBuilder.TryWith[a](FSharpFunc`2 body, FSharpFunc`2 handler) in R:\path\Ex.fs:line 105
   --- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at Playful.Ex.TestRes.testme() in R:\path\Ex.fs:line 146
   at Playful.Ex.Tests.TryItOut() in R:\path\Ex.fs:line 153

Preserve the stacktrace completely

If you don't have .NET 4.5, or don't like the added line in the middle of the trace ("--- End of stack trace from previous location where exception was thrown ---"), then you can preserve the stack and add the current trace in one go.

I found this solution by following TeaDrivenDev's solution and happened upon Preserving stacktrace when rethrowing exceptions.

type Ex =
    /// Modify the exception, preserve the stacktrace and add the current stack, then throw (.NET 2.0+).
    /// This puts the origin point of the exception on top of the stacktrace.
    static member inline throwPreserve ex =
        let preserveStackTrace = 
            typeof<Exception>.GetMethod("InternalPreserveStackTrace", BindingFlags.Instance ||| BindingFlags.NonPublic)

        (ex, null) 
        |> preserveStackTrace.Invoke  // alters the exn, preserves its stacktrace
        |> ignore

        raise ex

With the example in the original question (replace raise ex), you will see that the stacktraces are nicely coupled and that the origin of the exception is on the top, where it should be:

System.DivideByZeroException : Attempted to divide by zero.
   at Playful.Ex.Extern.calc(Int32 x, Int32 y) in R:\path\Ex.fs:line 118
   at Playful.Ex.TestRes.testme@137-1.Invoke(Unit unitVar) in R:\path\Ex.fs:line 137
   at Playful.Ex.Result.ResultBuilder.Run[b](FSharpFunc`2 f) in R:\path\Ex.fs:line 103
   at Playful.Ex.Result.ResultBuilder.TryWith[a](FSharpFunc`2 body, FSharpFunc`2 handler) in R:\path\Ex.fs:line 105
   at Microsoft.FSharp.Core.Operators.Raise[T](Exception exn)
   at Playful.Ex.TestRes.testme() in R:\path\Ex.fs:line 146
   at Playful.Ex.Tests.TryItOut() in R:\path\Ex.fs:line 153

Wrap the exception in an exception

This was suggested by Fyodor Soikin, and is probably the .NET default way, as it is used in many cases in the BCL. However, it results in a less-then-useful stacktrace in many situations and, imo, can lead to confusing topsy-turvy traces in deeply nested functions.

type Ex = 
    /// Wrap the exception, this will put the Core.Raise on top of the stacktrace.
    /// This puts the origin of the exception somewhere in the middle when printed, or nested in the exception hierarchy.
    static member inline throwWrapped ex =
        exn("Oops", ex)
        |> raise

Applied in the same way (replace raise ex) as the previous examples, this will give you a stacktrace as follows. In particular, note that the root of the exception, the calc function, is now somewhere in the middle (still quite obvious here, but in deep traces with multiple nested exceptions, not so much anymore).

Also note that this is a trace dump that honors the nested exception. When you are debugging, you need to click through all nested exceptions (and realize is it nested to begin with).

System.Exception : Oops
  ----> System.DivideByZeroException : Attempted to divide by zero.
   at Microsoft.FSharp.Core.Operators.Raise[T](Exception exn)
   at Playful.Ex.TestRes.testme() in R:\path\Ex.fs:line 146
   at Playful.Ex.Tests.TryItOut() in R:\path\Ex.fs:line 153
   --DivideByZeroException
   at Playful.Ex.Extern.calc(Int32 x, Int32 y) in R:\path\Ex.fs:line 118
   at Playful.Ex.TestRes.testme@137-1.Invoke(Unit unitVar) in R:\path\Ex.fs:line 137
   at Playful.Ex.Result.ResultBuilder.Run[b](FSharpFunc`2 f) in R:\path\Ex.fs:line 103
   at Playful.Ex.Result.ResultBuilder.TryWith[a](FSharpFunc`2 body, FSharpFunc`2 handler) in R:\path\Ex.fs:line 105

Conclusion

I'm not saying one approach is better than another. To me, just mindlessly doing raise ex is not a good idea, unless ex is a newly created and not previously raised exception.

The beauty is that reraise() effectively does the same as the the Ex.throwPreserve does above. So if you think reraise() (or throw without arguments in C#) is a good programming pattern, you can use that. The only difference between reraise() and Ex.throwPreserve is that the latter does not require a catch context, which I believe is a huge usability gain.

I guess in the end this is a matter of taste and what you're used to. To me, I just want the cause of the exception prominently on top. Major thanks for the first commenter, TeaDrivenDev who directed me to the .NET 4.5 enhancement, which itself led to the 2nd approach above.

(apologies for answering my own question, but since none of the commenters did it, I decided to step up ;)

查看更多
登录 后发表回答