Cancellation and catching exceptions

When catching exceptions from cancellable operations, a distinction should be made between exceptions thrown due to cancellation of the operation, and exceptions thrown for other reasons.

A cancellable operation is one that is passed a CancellationToken as an argument. For example:

await foo.Bar(cancellationToken).ConfigureAwait(false);

An exception thrown by this operation represents cancellation only when its type inherits from OperationCanceledException and when the CancellationToken.IsCancellationRequested property is true. Conversely, the exception does not represent cancellation when its type does not inherit from OperationCanceledException or when the CancellationToken.IsCancellationRequested property is false.

Note that an OperationCanceledException thrown when CancellationToken.IsCancellationRequested is false, does not represent cancellation. All this means is that foo.Bar threw an OperationCanceledException for some reason other than cancellation, and the operation should be treated as a failure.

Catching System.Exception

Most of the time, when System.Exception is caught, the assumption is that the operation has failed, not that it has been canceled. In these cases, the correct way to catch the Exception is to add a filter which excludes exceptions that represent cancellation:

try
{
    await foo.Bar(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
    // foo.Bar failed — take appropriate action, including re-throwing the exception if appropriate
}

Note that, in the above example, exceptions that represent cancellation are not caught. This is desirable behavior because cancellation should be propagated to the caller of the current method.

Catching System.OperationCanceledException

In most cases, exceptions which represent cancellation should not be caught, and should be allowed to propagate to the caller of the current method. In some cases, it may be necessary to catch these exceptions to take specific actions. The correct way to catch these exceptions is to add a filter which includes only exceptions which represent cancellation:

try
{
    await foo.Bar(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
{
    // foo.Bar was cancelled — take appropriate action

    // re-throw the exception to propagate the cancellation to the caller of the current method
    throw;
}
catch (Exception ex)
{
    // this catch (if it is required) will now catch only exceptions which do NOT
    // represent cancellation, so it does not require a filter
}

Helper methods

If exception handling is widespread, it may be helpful to introduce an IsCausedBy extension method:

public static bool IsCausedBy(this Exception ex, CancellationToken cancellationToken) =>
    ex is OperationCanceledException && cancellationToken.IsCancellationRequested;

Using this method, the catch filters are much simpler in both cases:

try
{
    await foo.Bar(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (!ex.IsCausedBy(cancellationToken))
{
    // foo.Bar failed — take appropriate action, including re-throwing the exception if appropriate
}
try
{
    await foo.Bar(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex.IsCausedBy(cancellationToken))
{
    // foo.Bar was cancelled — take appropriate action

    // re-throw the exception to propagate the cancellation to the caller of the current method
    throw;
}

Note that the second example is catching Exception rather than OperationCanceledException and the IsCausedBy method is filtering on the exception type instead of the catch clause itself. This results in one extra type comparison (isinst) in the resulting IL code, but the performance cost is negligible and the code is simpler to read.

Inside the message processing pipeline

For code inside the message processing pipeline, such as a message handler, saga, or pipeline behavior, the above considerations are still valid. The only difference is that the CancellationToken is provided by the context.CancellationToken property.

However, it is generally preferred to not catch exceptions within message handlers and sagas, and instead let exceptions be handled by the recoverability process.

Related Articles


Last modified