Exception Filters and the When Clause

The when clause on a catch block adds a condition โ€” the exception is only caught if both the type matches AND the condition is true. This is more powerful than checking a condition inside the catch block because when filters are evaluated before the stack is unwound. If the condition is false, the exception continues searching for another handler, keeping the original stack trace intact. This matters for debugging: the exception is observable at its origin, not at some outer catch point.

Exception Filters with When

// โ”€โ”€ Basic when clause โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
try
{
    await CallExternalApiAsync();
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    // Only catches 404 Not Found โ€” other HttpRequestExceptions propagate
    return null;
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.ServiceUnavailable)
{
    // Only catches 503 Service Unavailable โ€” retry or queue
    await Task.Delay(TimeSpan.FromSeconds(5));
    return await CallExternalApiAsync();
}
catch (HttpRequestException ex)
{
    // Catches all other HttpRequestExceptions
    throw new ExternalServiceException("External API call failed.", ex);
}

// โ”€โ”€ Filter on error message โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
catch (SqlException ex) when (ex.Message.Contains("Violation of UNIQUE constraint"))
{
    throw new ConflictException("An entry with this title already exists.");
}

// โ”€โ”€ Logging filter (observe without catching) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// when with a side-effect method โ€” always returns false so exception propagates
catch (Exception ex) when (LogAndContinue(ex))
{
    // This block is never reached โ€” LogAndContinue always returns false
    // But the exception IS logged before unwinding!
}

bool LogAndContinue(Exception ex)
{
    _logger.LogError(ex, "Exception observed: {Message}", ex.Message);
    return false;   // returning false means "don't catch this exception"
}
Note: The key difference between a when filter and an if check inside the catch block is when stack unwinding occurs. With a when filter, the stack is not unwound until a matching catch is found. If the condition is false, the exception continues searching up the call stack with the original stack trace fully intact. With an if inside catch, the stack is already unwound by the time you check, and rethrowing with throw still loses one frame. The when approach is more debuggable and more correct.
Tip: The “logging observer” pattern using when (LogAndContinue(ex)) is a useful technique for observing exceptions at a high level (e.g., in a base service class or middleware) without actually handling them. The exception is logged at its origin (with the full stack trace) before it propagates further. This avoids the “catch, log, rethrow” pattern which can affect stack trace quality. It is particularly useful in retry policies and telemetry collection.
Warning: Exception filters with side effects (code that modifies state inside the when condition) are dangerous because the condition may be evaluated multiple times. If the filter is evaluated by multiple exception handlers on the stack (nested try/catch), the side-effecting code runs more than once. Keep when conditions pure (read-only checks) or limit side effects to idempotent operations like logging. Never modify state or attempt recovery in a when condition.

Retry Pattern with Exception Filters

// Retry with exception filter โ€” more expressive than nested try/catch
public async Task<T> RetryAsync<T>(
    Func<Task<T>> operation,
    int maxAttempts = 3,
    Predicate<Exception>? shouldRetry = null)
{
    int attempt = 0;
    while (true)
    {
        try
        {
            return await operation();
        }
        catch (Exception ex)
            when (attempt++ < maxAttempts - 1
                  && (shouldRetry?.Invoke(ex) ?? IsTransient(ex)))
        {
            int delayMs = (int)Math.Pow(2, attempt) * 100;
            _logger.LogWarning(ex,
                "Attempt {Attempt} failed โ€” retrying in {Delay}ms", attempt, delayMs);
            await Task.Delay(delayMs);
        }
        // After maxAttempts, the exception propagates to the caller
    }
}

bool IsTransient(Exception ex) => ex is TimeoutException or HttpRequestException
    or { HResult: unchecked((int)0x80070005) };   // ACCESS_DENIED HRESULT

Common Mistakes

Mistake 1 โ€” Side-effecting when conditions (may run multiple times)

โŒ Wrong โ€” retryCount may be incremented more than expected:

catch (Exception ex) when (++retryCount < 3)   // ++ in when condition โ€” risky!

โœ… Safer โ€” use the post-increment pattern: when (retryCount++ < 3) which reads the value before incrementing.

Mistake 2 โ€” Using when to check a condition that belongs inside the catch body

โŒ Wrong โ€” when is the right tool when you want the exception to propagate if false; use a normal if when you always want to handle the exception:

โœ… Correct โ€” use when for conditional catching; use if inside catch for conditional behaviour after catching.

🧠 Test Yourself

What happens when a when filter evaluates to false?