Feature #8257

Exception#cause to carry originating exception along with new one

Added by Charles Nutter about 1 year ago. Updated 2 months ago.

[ruby-core:54185]
Status:Open
Priority:Normal
Assignee:Yukihiro Matsumoto
Category:-
Target version:Ruby 2.1.0

Description

Often when a lower-level API raises an exception, we would like to re-raise a different exception specific to our API or library. Currently in Ruby, only our new exception is ever seen by users; the original exception is lost forever, unless the user decides to dig around our library and log it. We need a way to have an exception carry a "cause" along with it.

Java has getCause/setCause and standard constructors that take a cause exception. Printing out an exception's backtrace then reports both that exception and any "cause" exception.

Rubinius has added a similar feature: https://gist.github.com/dbussink/b2e01e51d0c50b27004f

The changes required for this feature are pretty benign:

  • Exception#cause and #cause= accessors.
  • A new set of Kernel#raise overloads that accept (as a trailing argument, probably) the "cause" exception.
  • Modifications to backtrace-printing logic to also display backtrace information from the "cause" exception (and in turn, from any nested "cause" exceptions).

There's some discussion here about alternatives to #cause, none of which are quite as elegant as having it built in: http://www.skorks.com/2013/04/ruby-why-u-no-have-nested-exceptions/


Related issues

Related to ruby-trunk - Bug #9338: Build failure of trunk with MSVC 2013 Closed 01/01/2014

History

#1 Updated by Charles Nutter about 1 year ago

Links to implementation of "cause" rendering and Kernel#raise logic from Rubinius:

"cause" rendering: https://github.com/rubinius/rubinius/blob/master/kernel/common/exception.rb#L72

Kernel#raise: https://github.com/rubinius/rubinius/blob/master/kernel/delta/kernel.rb#L27

Note that the changes almost exclusively affect rendering of exceptions that bubble out; no other visible aspects of exceptions change with this enhancement. This means that libraries can start using the feature without consumers having to change any code.

This feature would also be useful for associate a low-level cause with higher-level exceptions in core classes and standard library, such as wrapping a low-level IO exception in an SSLError or net/* exception.

#2 Updated by Konstantin Haase about 1 year ago

This would indeed be useful. We have built our own solutions for this many times.

Should there also be a mechanism to rescue based on cause that's transparent of wrapping exceptions?

#3 Updated by Charles Nutter about 1 year ago

rkh (Konstantin Haase) wrote:

Should there also be a mechanism to rescue based on cause that's transparent of wrapping exceptions?

Exception::=== could be enhanced to search causes, but I'm dubious about the utility of such an enhancement. It could also make exception-handling slower; even though it's obviously exception handling and doesn't necessarily need to be lightning fast, people still occasionally use it for flow control inappropriately.

I'd probably punt on that one myself and see over time how common it is that people rescue based on causes. I'd suspect it's pretty rare...such a feature has never been considered for Java (as far as I know) even though Java's Throwable has had "cause" forever.

Also want to avoid feature creep. Exception#cause is a nice, simple, low-impact feature as described by me here.

#4 Updated by Konstantin Haase about 1 year ago

I think allowing rescue lines to take other objects besides modules would this would greatly ease building something in user code:

def caused_by(matcher)
block = proc do |exception|
matcher === exception or exception.cause && block[exception.cause]
end
end

begin
...
rescue caused_by(NetworkError)
...
end

But I guess that's a separate feature request.

#5 Updated by Charles Nutter about 1 year ago

rkh (Konstantin Haase) wrote:

I think allowing rescue lines to take other objects besides modules would this would greatly ease building something in user code:
...
But I guess that's a separate feature request.

Yeah, it might be nice, but there might also be optimization concerns. You're right though, it should indeed be a separate feature request, and it's certainly applicable to more than just Exception#cause.

#6 Updated by Koichi Sasada 12 months ago

(2013/04/12 1:40), headius (Charles Nutter) wrote:

  • A new set of Kernel#raise overloads that accept (as a trailing argument, probably) the "cause" exception.

(1) introduce new keyword?

example: raise(..., cause: e)

(2) How about to use `$!' forcibly?

--
// SASADA Koichi at atdot dot net

#7 Updated by Koichi Sasada 12 months ago

(2013/04/28 7:19), SASADA Koichi wrote:

(2) How about to use `$!' forcibly?

because I think Exception#cause= is not cool.

--
// SASADA Koichi at atdot dot net

#8 Updated by Charles Nutter 12 months ago

On Sat, Apr 27, 2013 at 5:19 PM, SASADA Koichi ko1@atdot.net wrote:

(1) introduce new keyword?

example: raise(..., cause: e)

Yeah, that's not bad. Keywords allow us to avoid changing the
signature in new/incompatible/non-forward-compatible ways.

(2) How about to use `$!' forcibly?

Do you mean automatically stick $! into Exception#cause when creating
(or perhaps better, when raising) a new Exception? That's not a bad
idea.

Interestingly, java.lang.Throwable also does not allow setting the
cause. You can set it during init or after init at most once. Here's
JavaDoc for it:
http://docs.oracle.com/javase/7/docs/api/java/lang/Throwable.html#initCause(java.lang.Throwable)

I quote: "This method can be called at most once. It is generally
called from within the constructor, or immediately after creating the
throwable. If this throwable was created with Throwable(Throwable) or
Throwable(String,Throwable), this method cannot be called even once."

Java 7 also added an additional mechanism for attaching exceptions,
called "supressed exceptions".
http://docs.oracle.com/javase/7/docs/api/java/lang/Throwable.html#addSuppressed(java.lang.Throwable)

This mechanism was added for exceptions that are not really the
cause, but which are related. For example, an IO read operation
raises an error, and while handling that error and attempting to close
the stream a second error is raised. The read error did not cause the
close error, but you don't want to lose either. You raise the close
error with the read error attached as "suppressed".

I think for purposes of this feature, adding cause, which can be
initialized during construction only, would be sufficient. I have no
strong opinion about automatically using $! as the cause, but it might
be nice.

#9 Updated by Koichi Sasada 12 months ago

(2013/04/28 7:59), Charles Oliver Nutter wrote:

(2) How about to use `$!' forcibly?
Do you mean automatically stick $! into Exception#cause when creating
(or perhaps better, when raising) a new Exception? That's not a bad
idea.

Yes.

I think for purposes of this feature, adding cause, which can be
initialized during construction only, would be sufficient. I have no
strong opinion about automatically using $! as the cause, but it might
be nice.

Cool.

Summary:
(1) raise method captures $! as @cause.
(2) Adding new method Exception#cause returns @cause.

  • I'm not sure #cause is good name.
    Too short?

    // SASADA Koichi at atdot dot net

#10 Updated by Charles Nutter 12 months ago

On Sat, Apr 27, 2013 at 6:09 PM, SASADA Koichi ko1@atdot.net wrote:

Summary:
(1) raise method captures $! as @cause.
(2) Adding new method Exception#cause returns @cause.

  • I'm not sure #cause is good name. Too short?

It seems ok to me, since Java's Throwable uses the same name (but I am
perhaps a bit biased.

.NET's Exception calls it the BaseException, via GetBaseException. I
think "base" or "base_exception" not as good as "cause".

I guess the next step is a patch.

  • Charlie

#11 Updated by Konstantin Haase 12 months ago

I love the idea of having $! be the cause. It would also mean instant adoption.

#12 Updated by Charles Nutter 7 months ago

Any further comments here? I might be able to do part of the implementation, but I don't know how to automatically stick $! into cause. I'd like to see this in 2.1.

#13 Updated by Charles Nutter 7 months ago

  • Target version set to Ruby 2.1.0

#14 Updated by Koichi Sasada 7 months ago

  • Assignee set to Yukihiro Matsumoto

I'm positive about this feature.

Matz, what do you think about?

#15 Updated by Yukihiro Matsumoto 6 months ago

Hi,

  • Fundamentally accepted.
  • I am against #cause=
  • It's OK that #raise to have cause: keyword argument to specify cause
  • I am not sure automagical capturing of $! would not cause wrong capturing or not

Matz.

#16 Updated by Charles Nutter 6 months ago

matz (Yukihiro Matsumoto) wrote:

  • Fundamentally accepted.

Hooray!

  • I am against #cause=

java.lang.Throwable does not have #setCause, so I agree. It does have #initCause, which can only be called once (if cause has not already been provided via constructor)...but I have never used it.

  • It's OK that #raise to have cause: keyword argument to specify cause

Agreed. Unfortunately this would still break backward-compatibility, since it will count as an extra argument on older Ruby versions. This might be the biggest justification for $! capturing.

  • I am not sure automagical capturing of $! would not cause wrong capturing or not

There are a few small risks:

  • Many layers of exceptions being raised as an error propagates out would all be chained. This could cause more information/data/memory than necessary to be retained.
  • Lower layers may not want higher layers to have access to the causing error for security or encapsulation reasons.
  • Too much magic?

But I think the benefits may outweigh them:

  • I can't think of any concrete examples of the above risks.
  • Automatic adoption; all exceptions caused by other exceptions will immediately show their lineage.
  • Reduced backward incompatibility (other than adding #cause) since users won't have to use the constructor to capture cause exception (of course, I still think we should have the cause constructor too).

I think the output of exception backtraces should also be enhanced to show cause exception's trace, as in the JVM. This could cause a problem for tools that parse the backtrace, though (IMO nobody should ever parse backtraces and expect that to be stable).

#17 Updated by Nobuyoshi Nakada 5 months ago

Automagically captured exceptions doesn't feel `cause' to me, now.
It might be irrelevant to the previously rescued exception.

#18 Updated by Charles Nutter 5 months ago

nobu (Nobuyoshi Nakada) wrote:

Automagically captured exceptions doesn't feel `cause' to me, now.
It might be irrelevant to the previously rescued exception.

That's true, but will that be the exception or the rule? It seems to me that most "hidden" or "suppressed" exceptions will be direct triggers for the actually-raised exception.

#19 Updated by Henry Maddocks 5 months ago

How is this different to 'wrapping' an exception? Eg.

http://www.jayway.com/2011/05/25/ruby-an-exceptional-language/

#20 Updated by Charles Nutter 5 months ago

henry.maddocks (Henry Maddocks) wrote:

How is this different to 'wrapping' an exception? Eg.

http://www.jayway.com/2011/05/25/ruby-an-exceptional-language/

Not much different, but built-in and automatically available.

#21 Updated by Charles Nutter 5 months ago

I think we still need to add Exception.new(:cause => ex) to allow constructing an exception with a specific cause. The $! capturing is great but often there may be more than one exception in play.

This needs to happen before 2.1 final.

#22 Updated by Nobuyoshi Nakada 4 months ago

What about the backward compatibility?

#23 Updated by Charles Nutter 4 months ago

nobu: Because :cause is opt-in, I am not concerned about the backward compat. You'd have to be committing to 2.1 in order to start using that feature, and I think it will be slow to adopt in any case.

It would also be possible to monkey-patch older versions to just ignore the options.

I think we should add the option for future use, because it is needed, and we have to add it some time.

#24 Updated by Nobuyoshi Nakada 4 months ago

Carelessly, I implemented raise cause: ex but not Exception.new(cause: ex), and nearly committed it.
Do you prefer the latter?

#25 Updated by Koichi Sasada 4 months ago

(2013/12/10 23:24), nobu (Nobuyoshi Nakada) wrote:

Carelessly, I implemented raise cause: ex but not Exception.new(cause: ex), and nearly committed it.
Do you prefer the latter?

How about to simply add Exception#cause= only?

This is because:

  • Setting "cause" is not common usecase (nobody care about it)
    Some long steps such as
    e = Exception.new(...)
    e.cause =...
    raise e"
    is acceptable for unusual cases.

  • No compatibility issue with raise()

    However, matz is against "cause=" by .
    Because he doesn't want to introduce any states.
    I also agree this point.

    // SASADA Koichi at atdot dot net

#26 Updated by Rodrigo Rosenfeld Rosas 4 months ago

Actually, the only reason I don't use it is because it's not possible, but I'd most probably always send along the cause if this was supported, so I prefer a concise way to do that.

#27 Updated by Rodrigo Rosenfeld Rosas 4 months ago

And sometimes a Runtime exception would be fine to me, so raise "Some explanation", cause: ex would be great, but sometimes I need to wrap the original exception in some specific one. In that latter case, I'd prefer to be able to specify the cause with CustomException.new("message", cause: ex). I'm usually interested in keeping the full backtrace in a simple way when doing that...

#28 Updated by Nobuyoshi Nakada 4 months ago

rosenfeld (Rodrigo Rosenfeld Rosas) wrote:

sometimes I need to wrap the original exception in some specific one.

Just wrapping in a new exception but don't raise it?
How frequent is such case?

#29 Updated by Rodrigo Rosenfeld Rosas 4 months ago

Yes, raising it too.

#30 Updated by Rodrigo Rosenfeld Rosas 4 months ago

I believe you think I should write this instead:

raise WrappedException.new("message"), cause: ex

I wouldn't mind doing that but if I ever had to store the exception without raising it then it wouldn't be possible to do so, right? But in that case I could also store the cause separately, so it isn't really a big deal.

I wouldn't mind to add cause just to raise...

#31 Updated by Nobuyoshi Nakada 4 months ago

rosenfeld (Rodrigo Rosenfeld Rosas) wrote:

I believe you think I should write this instead:

raise WrappedException.new("message"), cause: ex

I think this is more common in ruby:
raise WrappedException, "message", cause: ex

#32 Updated by Avdi Grimm 4 months ago

I've been digging into this feature now that 2.1 is released, and I'm a
little confused. In the final implementation, can #cause only be set from
$!? None of the other methods for setting the cause discussed in this
thread seem to work, and for that matter I don't see any changes to the
#raise or Exception#initialize methods to support explicitly setting a
cause.

#33 Updated by Charles Nutter 4 months ago

Unfortunately it doesn't look like anything other than $! logic for this made it into 2.1. I was hoping we'd get either the constructor or a one-time-only #cause= but there was still some debate about which way to go.

Since the base #cause and $! logic made it in, perhaps we should call this bug closed as of 2.1 and add a new bug for additional ways to initialize the exception cause.

#34 Updated by Benoit Daloze 2 months ago

raise ErrorClass, msg, cause: cause was implemented with the rest in r44473.

e = (
begin
  raise ArgumentError, "arg error"
rescue
  nie = NotImplementedError.new("nie")
  raise StandardError, "stderr", cause: nie
end) rescue $!
e.cause # => #<NotImplementedError: nie>

But the cause is not shown in the error output, which I think is now the most important step forward.

#35 Updated by Usaku NAKAMURA 2 months ago

  • Related to Bug #9338: Build failure of trunk with MSVC 2013 added

Also available in: Atom PDF