Skip to main content

Exceptions and Disposables

These methods help at both ends of error handling. The exception helpers read an exception chain: every message on one line, the first inner exception of a given type, the innermost cause. They also rethrow a stored exception without losing its stack trace. The disposable helpers turn an Action into an IDisposable and dispose a group of items so that one failure does not stop the others.

Both classes live in the System namespace, so you do not need a using directive.

At a glance​

MethodWhat it does
GetAllMessagesJoins the messages of the exception and all its inner exceptions into one string.
FindInnerException<T>Returns the first exception of type T in the chain, or null.
GetRootCauseReturns the innermost exception, following the first branch of an AggregateException.
GetAllExceptionsLists the exception and every inner exception, outermost first.
RethrowThrows a stored exception again and keeps its original stack trace.
AsDisposableTurns an Action into an IDisposable that runs it once.
DisposeAllDisposes every item in a sequence, even when some of them throw.

How the chain is walked​

GetAllMessages, FindInnerException<T> and GetAllExceptions walk the same tree:

  • They start with the exception itself, then follow InnerException.
  • At an AggregateException they visit every entry of InnerExceptions, not only the first.
  • The order is depth-first, outermost first: one branch is walked to the bottom before the next branch starts.

For this exception:

An exception with an AggregateException inside
InvalidOperationException first = new InvalidOperationException("a");
TimeoutException deep = new TimeoutException("c");
FormatException second = new FormatException("b", deep);
AggregateException aggregate = new AggregateException(first, second);
Exception exception = new InvalidOperationException("top", aggregate);

the walk order is exception, aggregate, first, second, deep.

GetRootCause works differently. It follows a single path down, and at an AggregateException it takes only the first inner exception.

Reading an exception chain​

GetAllMessages​

GetAllMessages(string separator = " --> ") joins the message of the exception and of every inner exception, outermost first. Use it for a one-line log entry or an error column in a report, where ex.Message alone often hides the real reason.

One line per failure
Exception failure = new InvalidOperationException(
"Could not save order 1042",
new InvalidCastException("Invalid total", new FormatException("'12,5' is not a number")));

string message = failure.GetAllMessages();
// → "Could not save order 1042 --> Invalid total --> '12,5' is not a number"

string piped = failure.GetAllMessages(" | ");
// → "Could not save order 1042 | Invalid total | '12,5' is not a number"

An AggregateException contributes the messages of all its branches, but not its own message, because that message only repeats the inner ones:

Every branch of an AggregateException
AggregateException aggregate = new AggregateException(
"aggregate message",
new InvalidOperationException("a"),
new FormatException("b", new TimeoutException("c")));
Exception exception = new InvalidOperationException("top", aggregate);

string message = exception.GetAllMessages();
// → "top --> a --> b --> c"
  • An exception with no inner exception returns its own message.
  • An empty AggregateException has no message to contribute, so the result is "".
  • exception or separator being null throws ArgumentNullException.
tip

Exception messages can contain user data. See JSON and safe logging for masking helpers before you write them to a log.

FindInnerException<T>​

FindInnerException<TException>() returns the first exception of type TException in the chain, or null when there is none. The search includes the exception itself and every branch of an AggregateException. Use it to react to one specific cause, such as a timeout, whatever wraps it.

React to a timeout deep in the chain
TimeoutException timeout = new TimeoutException("Payment gateway did not answer in 30 s");
Exception failure = new InvalidOperationException(
"Checkout failed",
new FormatException("Could not read the gateway reply", timeout));

TimeoutException? found = failure.FindInnerException<TimeoutException>();
// → timeout (the same instance)

if (failure.FindInnerException<TimeoutException>() is { } gatewayTimeout)
{
// retry the payment, or show a "try again" message
}

Matching uses the type hierarchy, so asking for a base type finds a derived exception:

A base type matches derived types
ArgumentNullException missingName = new ArgumentNullException("customerName");
Exception failure = new InvalidOperationException("Could not create the user", missingName);

ArgumentException? argumentError = failure.FindInnerException<ArgumentException>();
// → missingName

TimeoutException? timeout = failure.FindInnerException<TimeoutException>();
// → null
  • If the exception itself is a TException, it is returned.
  • When several exceptions match, the first one in walk order wins.
  • exception being null throws ArgumentNullException.

GetRootCause​

GetRootCause() returns the innermost exception: the one that was thrown first and has no inner exception of its own. Use it when you want the original reason, not the wrappers added on the way up.

The original reason
FormatException root = new FormatException("'12,5' is not a number");
Exception failure = new InvalidOperationException(
"Could not save order 1042",
new InvalidCastException("Invalid total", root));

Exception cause = failure.GetRootCause();
// → root

string reason = failure.GetRootCause().Message;
// → "'12,5' is not a number"

At an AggregateException it follows the first inner exception and ignores the other branches:

Only the first branch is followed
TimeoutException root = new TimeoutException("deep");
AggregateException aggregate = new AggregateException(
new FormatException("first", root),
new InvalidOperationException("second"));
Exception exception = new InvalidOperationException("top", aggregate);

Exception cause = exception.GetRootCause();
// → root
  • An exception with no inner exception returns itself.
  • An empty AggregateException returns itself.
  • exception being null throws ArgumentNullException.
GetRootCause or GetBaseException?

Exception.GetBaseException() also walks to the innermost exception, and when an AggregateException sits in the middle of the chain it follows the first inner exception too. The difference appears when you call it on an AggregateException: that type's GetBaseException() override returns the aggregate itself when it holds more than one inner exception. GetRootCause() always continues into the first inner exception, so it returns a leaf exception every time:

Called on an AggregateException
AggregateException aggregate = new AggregateException(
new FormatException("first", new TimeoutException("deep")),
new InvalidOperationException("second"));

Exception baseException = aggregate.GetBaseException();
// → aggregate

Exception cause = aggregate.GetRootCause();
// → the TimeoutException "deep"

When you need every branch, use GetAllExceptions.

GetAllExceptions​

GetAllExceptions() returns the exception and every inner exception as an IReadOnlyList<Exception>: outermost first, depth-first, with every branch of an AggregateException. Use it when you need to inspect or log each exception, for example its type or its Data.

Every exception in a simple chain
FormatException inner = new FormatException("'12,5' is not a number");
InvalidCastException middle = new InvalidCastException("Invalid total", inner);
Exception failure = new InvalidOperationException("Could not save order 1042", middle);

IReadOnlyList<Exception> chain = failure.GetAllExceptions();
// → [failure, middle, inner]

int count = chain.Count;
// → 3
Every branch of an AggregateException
InvalidOperationException first = new InvalidOperationException("a");
TimeoutException deep = new TimeoutException("c");
FormatException second = new FormatException("b", deep);
AggregateException aggregate = new AggregateException(first, second);
Exception exception = new InvalidOperationException("top", aggregate);

IReadOnlyList<Exception> all = exception.GetAllExceptions();
// → [exception, aggregate, first, second, deep]
  • The list always starts with the exception itself, so it is never empty.
  • Unlike GetAllMessages, the list includes the AggregateException instances.
  • exception being null throws ArgumentNullException.

Rethrowing​

Rethrow​

Rethrow() throws the exception again and keeps its original stack trace. A plain throw failure; would reset the stack trace to the line of that throw. Use it for an exception you caught and stored, and want to throw later, outside its catch block, for example after clean-up has run.

Run clean-up, then throw the original failure
Exception? failure = null;
try
{
importer.ImportInvoices(batch);
}
catch (Exception exception)
{
failure = exception;
}

importer.ReleaseLock();
failure?.Rethrow();

The thrown exception is the same instance that was stored, and its stack trace still contains the frames where it was first thrown. .NET appends the location of the rethrow after a --- End of stack trace from previous location --- line.

  • The method is marked [DoesNotReturn], so nullable analysis treats code after failure.Rethrow(); as unreachable and reports no null warnings there. It does not change the return-path check: a method that returns a value still needs a return or throw after the call (otherwise CS0161).
  • On .NET 5 and later it calls ExceptionDispatchInfo.Throw. The netstandard2.1 build calls ExceptionDispatchInfo.Capture(exception).Throw(). The behavior is the same.
  • exception being null throws ArgumentNullException.
tip

Inside a catch block, keep using throw;. Rethrow() is for an exception that has already left its catch block.

Disposables​

AsDisposable​

AsDisposable() turns an Action into an IDisposable. The action runs on the first Dispose(); later calls do nothing. It is thread-safe: when many threads dispose at the same time, the action still runs once. Use it to put "undo" code in a using block, so that it runs even when the block throws.

C# cannot call a method on a lambda, so assign the lambda to an Action variable first:

Restore a setting at the end of a block
OrderImportSettings settings = new OrderImportSettings { ValidateTotals = true };

bool previous = settings.ValidateTotals;
Action restore = () => settings.ValidateTotals = previous;

using (restore.AsDisposable())
{
settings.ValidateTotals = false;
importer.Import(legacyOrders, settings);
}

bool afterwards = settings.ValidateTotals;
// → true
Disposing twice runs the action once
int releases = 0;
Action release = () => releases++;
IDisposable handle = release.AsDisposable();

handle.Dispose();
handle.Dispose();

int count = releases;
// → 1
  • The action is cleared before it runs, so if it throws, a later Dispose() does not run it again.
  • onDispose being null throws ArgumentNullException.

DisposeAll​

DisposeAll() disposes every item of an IEnumerable<IDisposable?>, in sequence order. A Dispose() that throws does not stop the others. When all items are done:

  • no failure: the method returns normally;
  • one failure: that exception is rethrown with its original stack trace (see Rethrow);
  • several failures: an AggregateException is thrown, and its InnerExceptions hold every failure in sequence order.

Use it when you own several resources and must release all of them, for example in a Dispose method or a finally block.

Release a reader, a transaction and a connection
IDisposable?[] resources = { reader, transaction, connection };

resources.DisposeAll();

null items are skipped, so you can pass fields that may never have been created:

Skip resources that were never opened
public sealed class InvoiceExport : IDisposable
{
private StreamWriter? _writer;
private FileStream? _archive;

public void Dispose()
{
new IDisposable?[] { _writer, _archive }.DisposeAll();
}
}

When more than one item fails, catch AggregateException to see each failure:

Several failures become one AggregateException
try
{
new IDisposable?[] { ordersCache, auditLog, paymentClient }.DisposeAll();
}
catch (AggregateException aggregate)
{
int failures = aggregate.InnerExceptions.Count;
// → 2 (when ordersCache and paymentClient both throw)
}
  • An empty sequence does nothing.
  • The sequence is enumerated once.
  • A list or array of reference-type disposables works directly, for example List<FileStream>, because IEnumerable<T> is covariant. A sequence of struct disposables does not convert; put the items in an IDisposable?[] first.
  • disposables being null throws ArgumentNullException.
note

Items are disposed in the order you list them. Resources are usually released in reverse order of creation, so list the one you opened last first.

See also​

  • Tasks: timeouts, retries and other helpers for async work.
  • Functions: delegate helpers, which also need a delegate variable instead of a lambda.
  • JSON and safe logging: log objects without leaking sensitive data.
  • Conventions: null handling and argument validation across the library.