📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are exception filters (catch ... when) in C#? How do they differ from catching and rethrowing, why do they keep the stack unwound state intact, and what are good and bad uses of them?

Exception filters in C#: two-pass handling, preserving state for dumps, conditional catching by property, and pitfalls.

Mid-Level Theory Intermediate debugging error-handling exception-filters exceptions when-clause

🎯 Key points

  • `catch (T ex) when (cond)` only handles the exception if the condition is true
  • Filters run in the first pass, before the stack unwinds and before inner `finally` blocks
  • Unmatched exceptions are not caught, so crash dumps keep original frames and locals
  • Filters are better than catch-check-rethrow, which unwinds the stack and throws again
  • Good uses: status codes, SQL error numbers, excluding cancellation, retry limits
  • `when (Log(ex))` returning `false` logs with the full stack without handling
  • An exception thrown inside a filter is swallowed and the filter counts as `false`
  • Keep filters cheap and free of side effects other than deliberate logging

⏱ 30-second answer

An exception filter is a when (condition) clause on a catch: catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound). The catch is only chosen if the condition is true; otherwise the runtime keeps searching up the stack as if that catch did not exist. Filters run during the runtime's first pass, before the stack is unwound, so when no handler matches, a crash dump still shows the original frames and local state. That is better than catch-check-rethrow. Good uses: catching by status code or error number, logging without catching (when (Log(ex)) returning false), and excluding cancellation. Keep filters cheap and side-effect free (except deliberate logging), and remember an exception thrown inside a filter is swallowed and treated as false.

🗣 Full answer (senior)

How I would answer this in an interview

An exception filter is a when clause on a catch: catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests). The catch only handles the exception if the filter returns true. The key difference from "catch, check, rethrow" is when the filter runs: during the runtime's first pass, before the stack is unwound — so if the filter returns false, the exception continues as if the catch didn't exist, with the original stack and state fully intact.

1. Syntax and basic use

try
{
    await _client.SendAsync(request, ct);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable)
{
    throw new TransientDependencyException("Payments API is throttling", ex);
}
catch (SqlException ex) when (ex.Number is 1205 or -2)   // deadlock, timeout
{
    // retry logic
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    // caller cancelled: expected, don't log as error
    throw;
}

2. Filters vs catching and rethrowing

// Catch-and-rethrow
catch (SqlException ex)
{
    if (ex.Number != 1205) throw;   // stack is ALREADY unwound to here; inner finally blocks have run
    Retry();
}

// Filter
catch (SqlException ex) when (ex.Number == 1205)
{
    Retry();                        // only entered for deadlocks; others pass through untouched
}

Differences:

Catch + throw; when filter
Stack unwound before deciding? Yes — frames between throw and catch are gone No — decision made during first pass
Inner finally blocks run before deciding? Yes No (they run later, if/when a handler is chosen)
Crash dump / debugger state if ultimately unhandled Shows the rethrow point Shows the original throw point with locals intact
"First-chance" break in debugger Breaks at rethrow too Only original throw
Code clarity Imperative Declarative: handler applies only to matching cases

The preserved state matters most for crash dumps and debugging: if no handler matches and the process crashes, the dump shows the exact frame and local variables where the exception was thrown — not a rethrow site.

3. Good uses

  • Selecting by property: status codes (HttpRequestException.StatusCode), SQL error numbers, IOException.HResult, gRPC StatusCode.
  • Distinguishing cancellation: catch (OperationCanceledException) when (ct.IsCancellationRequested) (caller cancelled) vs a timeout from a different token.
  • Logging without catching — a filter that logs and returns false, so you add context at this layer but don't handle the exception:
catch (Exception ex) when (LogAndContinue(ex, orderId)) { }   // never entered

private bool LogAndContinue(Exception ex, int orderId)
{
    _logger.LogError(ex, "Failure while processing order {OrderId}", orderId);
    return false;
}

(Use sparingly — logging scopes, Activity tags or a global handler are usually cleaner for context.)

  • Environment-specific handling: when (_env.IsDevelopment()) to add diagnostics.
  • Retry loops: catch (Exception ex) when (attempt < MaxAttempts && IsTransient(ex)).

4. Bad uses

  • Side effects that change program state in filters (modifying variables, writing to databases): filters run in the first pass, before inner finally blocks, at a surprising time. Keep them pure and cheap (logging is the accepted exception).
  • Filters that can throw: an exception thrown inside a filter is swallowed and treated as false — the filter silently stops matching.
  • Expensive filters (database calls, network) — they run while the stack is suspended.
  • Overly clever filters that hide control flow, or when (true) tricks.
  • Using filters to swallow broad exception types conditionally without logging.

5. A production example

An integration client caught all HttpRequestExceptions, inspected the status code, and rethrew non-retryable ones. When an unexpected 400 occurred in production, the crash dump captured by Azure App Service's auto-heal showed the stack at the rethrow site, and the request details held in locals of the inner method were gone. Rewriting the handler with catch (HttpRequestException ex) when (IsTransient(ex.StatusCode)) meant non-transient failures propagated without being caught at all; the next dump showed the original frame inside the serialisation code, including the payload that caused the 400 — the root cause was found in minutes.

🔍 What the interviewer is checking

Checks deeper knowledge of how .NET exception handling works.

  • Green flags: explains two-pass handling, why filters preserve the stack, compares with catch-rethrow, gives property-based examples and the log-without-catch trick, knows filter exceptions are swallowed.
  • Red flags: thinks a filter is just syntax sugar for if inside catch, or puts heavy logic in filters.

📚 Learn in depth

1. The two-pass model and where filters run

When an exception is thrown, the CLR:

  1. Pass 1 (search): walks up the stack frame by frame. For each try region with handlers, it checks catch types and runs filter blocks for clauses with when. Nothing is unwound; finally blocks have not run. The first clause whose type matches and whose filter returns true is the chosen handler.
  2. Pass 2 (unwind): unwinds from the throw site to the chosen handler's frame, running finally/fault blocks on the way, then runs the catch body.

If pass 1 finds no handler, the exception is unhandled; the runtime raises AppDomain.UnhandledException and terminates. Because pass 2 never ran, a crash dump taken at that moment contains the full original stack with all locals — the main diagnostic benefit of filters.

2. Filter execution order surprise

static void Inner()
{
    try { throw new InvalidOperationException(); }
    finally { Console.WriteLine("inner finally"); }
}

try { Inner(); }
catch (Exception) when (Print("filter")) { Console.WriteLine("catch"); }

// Output:
// filter
// inner finally
// catch

The filter runs before the inner finally. If the filter depends on state that the inner finally would reset (e.g. a flag, a lock release), it sees the pre-cleanup state. This is why filters must not assume cleanup has happened and shouldn't mutate shared state.

3. IL representation

A catch (T ex) when (cond) compiles to a filter block in the method's exception table:

.try { ... }
filter
{
    isinst T
    // if not T -> push 0
    // else evaluate cond -> push 0/1
    endfilter
}
{   // handler
    ...
}

The type test is part of the filter. VB.NET and F# have had filters since .NET 1.0; C# gained the syntax in C# 6, though the runtime always supported them.

4. Exceptions inside filters

If a filter throws, the runtime catches that exception internally and treats the filter as returning false, then continues searching. The new exception is not reported anywhere. A filter like when (ex.Data["Code"].ToString() == "X") that throws NRE when the key is missing will just never match — a silent bug. Write filters defensively: when (ex.Data["Code"] is "X").

5. Interaction with async

In async methods, exceptions from awaited tasks are rethrown at the await (via ExceptionDispatchInfo), and filters on enclosing catches run then. The "unwound" state is relative to the async method's current frame — the original synchronous stack of the task's execution is already gone, but the preserved stack trace text is intact. Filters still avoid creating an additional rethrow site in your method.

6. Debugging with filters

  • Visual Studio's "break when thrown" (first-chance) stops at the original throw. With catch-and-rethrow, it can stop twice.
  • dotnet-dump analyze on a crash dump: clrstack -a shows locals of each frame; with filters (no catch), frames from the original throw remain.
  • Azure App Service and Application Insights snapshot debugging capture snapshots at the throw site when exceptions are first thrown; fewer catch/rethrow layers mean clearer snapshots.

7. Pattern: transient error classification

static bool IsTransient(Exception ex) => ex switch
{
    HttpRequestException { StatusCode: HttpStatusCode.TooManyRequests or >= HttpStatusCode.InternalServerError } => true,
    HttpRequestException { StatusCode: null } => true,          // network failure, no response
    SqlException sql => sql.Number is 1205 or -2 or 40613 or 40501 or 49918,  // deadlock, timeout, Azure SQL throttling
    TimeoutException => true,
    _ => false
};

catch (Exception ex) when (attempt < 3 && IsTransient(ex))
{
    await Task.Delay(Backoff(attempt), ct);
}

In production I'd usually let a resilience pipeline (Polly / Microsoft.Extensions.Http.Resilience) handle retries, with the same classification as its ShouldHandle predicate.

Try it yourself

  1. Run the filter-vs-finally example and confirm the output order.
  2. Write a filter that throws and observe that the catch is silently skipped.
  3. Compare a crash dump (or Visual Studio exception helper) for catch-and-rethrow vs filter.
  4. Implement the logging filter pattern and check that the exception still reaches the global handler.

💻 Code

using System.Net;
using Microsoft.Data.SqlClient;

// 1. Catch only what you can handle
for (int attempt = 1; ; attempt++)
{
    try
    {
        await repository.SaveAsync(order, ct);
        break;
    }
    catch (SqlException ex) when (ex.Number is 1205 or -2 && attempt < 3)   // deadlock or timeout
    {
        await Task.Delay(TimeSpan.FromMilliseconds(100 * attempt), ct);
    }
}

// 2. Branch on HTTP status without catching everything
try
{
    return await http.GetFromJsonAsync<Product>($"products/{id}", ct);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    return null;
}

// 3. Do not treat shutdown as an error
try
{
    await worker.RunAsync(ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
    logger.LogError(ex, "Worker failed");
    throw;
}

// 4. Log with the stack still intact, without handling
try
{
    Process();
}
catch (Exception ex) when (LogAndReturnFalse(ex))
{
    // never reached
}

bool LogAndReturnFalse(Exception ex)
{
    logger.LogError(ex, "Unhandled in Process");
    return false;
}

⚠ Common mistakes

1. Using catch-and-rethrow where a filter fits

What happens: catch (SqlException ex) { if (ex.Number != 1205) throw; ... }. Why it is wrong: unwinds the stack and runs inner finally blocks before deciding, losing debugging state. Do instead: catch (SqlException ex) when (ex.Number == 1205).

2. Side effects in filters

What happens: filters update counters, change state, or call services. Why it is wrong: they run in the first pass, before inner finally blocks, at surprising times; behaviour becomes hard to reason about. Do instead: keep filters pure and cheap; logging-and-returning-false is the accepted exception.

3. Filters that can throw

What happens: when (((MyData)ex.Data["x"]).Code == 5). Why it is wrong: an exception in a filter is swallowed and treated as false — the handler silently stops working. Do instead: write defensive filters with pattern matching (when (ex.Data["x"] is MyData { Code: 5 })).

4. Swallowing broad exceptions conditionally

What happens: catch (Exception) when (!_options.StrictMode) { }. Why it is wrong: failures vanish in some environments, making bugs environment-specific. Do instead: handle specific types, and always log what you swallow.

5. Treating all cancellations the same

What happens: catch (OperationCanceledException) without checking which token was cancelled. Why it is wrong: a timeout from an internal CancellationTokenSource looks like a client cancellation, hiding real timeouts. Do instead: when (ct.IsCancellationRequested) for caller cancellation; treat other cancellations as timeouts.

6. Expensive filters

What happens: filters that query a database or call an API to decide. Why it is wrong: they run while the stack is suspended during exception dispatch, adding latency and risk of nested failures. Do instead: decide based on exception properties already available.

7. Overusing the logging-filter trick

What happens: when (Log(ex)) at every layer. Why it is wrong: duplicate logs, just like log-and-rethrow. Do instead: log once at the boundary; use logging scopes or Activity tags to add context.

8. Matching on exception messages

What happens: when (ex.Message.Contains("deadlock")). Why it is wrong: messages change between versions and languages (localised messages on some systems). Do instead: match on types, error numbers, status codes or HResults.

9. Assuming the filter sees cleaned-up state

What happens: a filter checks a flag that an inner finally resets. Why it is wrong: inner finally blocks haven't run yet when the filter executes. Do instead: don't make filters depend on cleanup side effects.

10. Not knowing filters exist

What happens: complex nested if/throw; logic inside catch blocks. Why it is wrong: harder to read and worse for diagnostics. Do instead: use when for conditional handling — supported since C# 6.

🏛 Senior insight: trade-offs

Seniors use filters to keep diagnostic information intact and to express precise handling rules.

Goal Filter use
Transient retry when (IsTransient(ex) && attempt < max)
Better crash dumps avoid catch-rethrow; let unmatched exceptions stay unhandled
Clean shutdown logs exclude OperationCanceledException
Log at the boundary with full stack when (Log(ex)) returning false
Status-specific logic HttpRequestException.StatusCode, SqlException.Number

They also know that resilience libraries (Polly / Microsoft.Extensions.Resilience) use predicates that play the same role, and prefer those for retries in production instead of hand-written loops.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate compares filters with catch-and-rethrow Why: Checks rethrow semantics
    How to answer

    throw; rethrows the current exception and keeps its original stack trace. throw ex; throws the same object as if it were new, so the stack trace is reset to the line of throw ex, and you lose where it really failed. Analyzer CA2200 flags it. To add context, throw a new exception with the original as InnerException. To rethrow an exception you captured earlier, for example from another thread, use ExceptionDispatchInfo.Capture(ex).Throw(), which keeps the original trace and appends the new one. Throw expressions (x ?? throw new ...) and guard helpers such as ArgumentNullException.ThrowIfNull keep validation short.

  2. When: Candidate says filters keep crash dumps useful Why: Probes production dump analysis
    How to answer

    When I can't attach a debugger, I take a dump: a snapshot of the process's memory, threads and stacks that I analyse offline. For a hang or a leak, dotnet-dump collect -p <pid> does it in seconds. For crashes, I enable automatic crash dumps with the DOTNET_DbgEnableMiniDump environment variables. On App Service I use Diagnose and solve problems, and in Kubernetes dotnet-monitor. Then I analyse with dotnet-dump analyze: clrstack -all and syncblk for hangs, dumpheap -stat and gcroot for leaks, pe for exceptions. Or I open the dump in Visual Studio and use the Threads and Parallel Stacks windows. Dumps contain customer data, so they're handled as sensitive.

🛠 Real-world problems: how you handle them

Retry wrapper hid the real SQL errors in crash dumps
Situation
A data access retry helper caught every `SqlException`, retried deadlocks and rethrew everything else. When a real failure crashed a worker, the dump and logs always pointed at the helper.
Signals
Crash dumps with the top frame in `RetryHelper`; locals of the failing query unavailable; duplicated error logs from the rethrow.
Investigation
Read the helper and saw `catch (SqlException ex) { if (ex.Number != 1205) throw; ... }`. The catch unwound the stack before deciding, so the frames of the original query were gone from the dump.
Resolution
Rewrote the catch as `catch (SqlException ex) when (ex.Number is 1205 or -2 && attempt < max)`. Non-transient errors are no longer caught, so dumps show the failing frame and its locals. Later moved retries to a resilience pipeline with the same predicate.
Prevention
Code review rule: no catch-check-rethrow; use filters. Added tests for which SQL error numbers are retried.

How to tell it (STAR)

**Situation:** At [company] our worker crashed on database errors and every dump pointed at our retry helper, not the failing query. **Task:** I was asked to make these crashes diagnosable. **Action:** The helper caught every `SqlException`, checked for a deadlock, and rethrew the rest. That unwinds the stack before the decision, so the dump lost the original frames. I changed it to an exception filter that only catches deadlocks and timeouts while attempts remain. Everything else now stays uncaught, so the dump keeps the original stack and locals. I added tests for the retried error numbers. **Result:** The next crash dump showed [the exact query and parameters] immediately, and we fixed the root cause the same day. The pattern became our standard for conditional catches.

↩ Usually asked after

More in Exception Handling

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me