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.
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.
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 [email protected](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
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 [email protected](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
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 [email protected](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
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 ;)
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With