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

📚 Interview question

How do you handle unhandled exceptions globally in .NET? Explain UseExceptionHandler, IExceptionHandler and ProblemDetails in ASP.NET Core, AppDomain.UnhandledException, TaskScheduler.UnobservedTaskException, BackgroundService failures and async void, and what a good global strategy looks like.

Global exception handling across ASP.NET Core, worker services and the runtime: last-chance hooks, ProblemDetails, and what each mechanism can and cannot do.

Senior Architecture Advanced aspnet-core background-service exceptions global-exception-handling problem-details

🎯 Key points

  • Handle locally only to recover or add context; otherwise let exceptions reach one boundary
  • ASP.NET Core: `AddProblemDetails`, `AddExceptionHandler<T>` and `UseExceptionHandler` early in the pipeline
  • `IExceptionHandler.TryHandleAsync` returns `true` when handled, `false` to pass to the next handler
  • Never leak stack traces or internal messages in production; include a `traceId`
  • `AppDomain.UnhandledException` is a last-chance notification; the process still terminates
  • `TaskScheduler.UnobservedTaskException` reports faulted tasks nobody awaited; no crash by default
  • An exception escaping `BackgroundService.ExecuteAsync` stops the host by default (.NET 6+)
  • `async void` exceptions crash the process; use `async Task` except for event handlers

⏱ 30-second answer

In ASP.NET Core, register AddProblemDetails(), one or more IExceptionHandler implementations (.NET 8+) with AddExceptionHandler<T>(), and app.UseExceptionHandler() early in the pipeline. Handlers log once and map known exceptions to the right status code and an RFC 9457 ProblemDetails body, and never leak stack traces in production. For the process as a whole, AppDomain.CurrentDomain.UnhandledException is a last-chance notification: you can log and flush, but the process still terminates. TaskScheduler.UnobservedTaskException fires when a faulted task that nobody awaited is garbage-collected; it does not crash the process by default. In a BackgroundService, an unhandled exception in ExecuteAsync stops the host by default (.NET 6+). async void exceptions go to the synchronisation context and usually crash the process, so avoid async void except for event handlers.

🗣 Full answer (senior)

How I would answer this in an interview

A good global strategy has layers: handle expected failures close to where they happen (often without exceptions), let unexpected exceptions bubble to a single boundary per entry point that logs once, returns a safe, consistent response (ProblemDetails) and keeps the process healthy — and make sure background work and fire-and-forget code can't fail silently or crash the host. I'd walk through each mechanism and where it fits.

1. ASP.NET Core: UseExceptionHandler, IExceptionHandler, ProblemDetails

builder.Services.AddProblemDetails(o =>
    o.CustomizeProblemDetails = ctx =>
        ctx.ProblemDetails.Extensions["traceId"] = Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier);

builder.Services.AddExceptionHandler<ValidationExceptionHandler>();   // order matters
builder.Services.AddExceptionHandler<ConflictExceptionHandler>();
builder.Services.AddExceptionHandler<FallbackExceptionHandler>();     // logs + 500

var app = builder.Build();
app.UseExceptionHandler();      // first in the pipeline so it wraps everything after it
app.UseStatusCodePages();       // ProblemDetails for 404/405 etc. without bodies
  • UseExceptionHandler is middleware that catches unhandled exceptions from later middleware/endpoints, clears the response (if not started), and re-executes to produce an error response.
  • IExceptionHandler (.NET 8) lets you register multiple handlers in DI; each TryHandleAsync returns true if it handled the exception. Typical handlers map domain exceptions to status codes (404, 409, 422) and a fallback logs and returns 500.
  • ProblemDetails (RFC 9457, formerly 7807) gives a consistent JSON error shape: type, title, status, detail, instance, plus extensions such as traceId and validation errors. IProblemDetailsService writes it and respects content negotiation.
  • In production, never return exception messages or stack traces to clients (information disclosure, OWASP). UseDeveloperExceptionPage is only for Development.
  • If the response has already started (streaming), the handler can't change it; the connection is aborted — design streaming endpoints accordingly.
  • Logging: the exception handler middleware logs unhandled exceptions; avoid also logging in each layer.

2. Process-level last resorts

  • AppDomain.CurrentDomain.UnhandledException: raised when an exception is unhandled on any thread. It's a notification, not a handler — the process will still terminate. Use it to log/flush telemetry (e.g. Log.CloseAndFlush()), not to recover.
  • TaskScheduler.UnobservedTaskException: raised when a faulted Task is garbage-collected without its exception being observed (fire-and-forget). Since .NET 4.5 it doesn't crash the process by default; call e.SetObserved() after logging. Its timing depends on GC, so it's a safety net, not a mechanism.

3. BackgroundService failures

  • Since .NET 6, an unhandled exception escaping ExecuteAsync stops the host by default (BackgroundServiceExceptionBehavior.StopHost), logging the error. Before .NET 6 the service died silently while the app kept running.
  • That's usually right for critical workers (the orchestrator restarts the container), but a worker loop should handle per-item failures so one bad message doesn't kill the host:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    await foreach (var message in _channel.Reader.ReadAllAsync(stoppingToken))
    {
        try
        {
            await _processor.HandleAsync(message, stoppingToken);
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            break;                                         // shutting down
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to process message {MessageId}", message.Id);
            await _deadLetter.SendAsync(message, ex, stoppingToken);   // don't lose it
        }
    }
}
  • Also note that a synchronous, long-running start of ExecuteAsync (before the first await) blocks host start-up — begin with await Task.Yield() or keep the start fast.

4. async void

Exceptions from an async void method can't be caught by the caller — they're posted to the SynchronizationContext or, in ASP.NET Core/console apps, rethrown on the thread pool, which crashes the process via UnhandledException. Rules: never write async void except for event handlers, and wrap their bodies in try/catch. Analysers (VSTHRD100, CA2012-adjacent rules) flag it.

5. Fire-and-forget and other entry points

  • _ = Task.Run(...) or un-awaited async calls lose exceptions (and, in ASP.NET Core, may outlive the request's scoped services — ObjectDisposedException on DbContext). Use a background queue (Channel<T> + BackgroundService) with its own error handling, or a durable queue.
  • Message consumers (Azure Service Bus, Functions): rely on the platform's retry + dead-letter semantics; catch only to add context or to classify poison messages.
  • gRPC: interceptors map exceptions to RpcException status codes. SignalR: hub filters.

6. What a good global strategy looks like

  1. Expected outcomes (validation, not found, conflicts) don't use exceptions where possible: result types or validation filters producing ValidationProblemDetails (400/422).
  2. Domain exceptions that do occur are mapped by specific IExceptionHandlers to stable ProblemDetails types.
  3. Everything else → fallback handler: log once with full exception + trace ID, return generic 500 ProblemDetails.
  4. Correlation: include traceId in responses so support can find logs; OpenTelemetry/Application Insights link logs, traces and exceptions.
  5. Background workers handle per-item failures, dead-letter poison messages, and let truly fatal errors stop the host so the orchestrator restarts it; health checks reflect worker health.
  6. Process safety nets: UnhandledException and UnobservedTaskException logging, with log flushing.
  7. Alerting on 5xx rates and specific exception types, not on every logged exception.
  8. No sensitive data in error responses or logs (Privacy Act, APP 11).

7. A production example

An API returned three different error shapes (MVC's default, a custom { error: "..." } from a middleware, and raw exception text in some minimal API endpoints), and a background email worker had died silently months earlier because an exception escaped ExecuteAsync under .NET 5 — nobody noticed until customers complained about missing invoices. We standardised on AddProblemDetails + IExceptionHandlers, added traceId to every error response, upgraded the worker (.NET 8's StopHost behaviour plus per-message try/catch and dead-lettering), added a health check reporting the worker's last successful run, and set Application Insights alerts on failed-request rate and on worker health.

🔍 What the interviewer is checking

Checks production experience with error handling at the application level.

  • Green flags: uses IExceptionHandler and ProblemDetails, knows UnhandledException is notification only, explains UnobservedTaskException and BackgroundService behaviour, avoids async void, mentions not leaking details and logging once.
  • Red flags: try/catch in every action, returns stack traces, thinks a global hook can prevent crashes, or swallows all exceptions to keep the service up.

📚 Learn in depth

1. How ExceptionHandlerMiddleware works

  1. Calls next(context) inside a try/catch.
  2. On exception: if the response has started, it logs and rethrows (the server aborts the connection). Otherwise:
  3. Stores the exception in IExceptionHandlerFeature/IExceptionHandlerPathFeature (with the original path and endpoint).
  4. Clears the response (headers, status) and resets the endpoint.
  5. Calls registered IExceptionHandlers in registration order until one returns true.
  6. If none handled it, invokes the configured ExceptionHandler delegate or re-executes the pipeline at ExceptionHandlingPath (e.g. /error), or writes a default ProblemDetails via IProblemDetailsService when AddProblemDetails is registered.
  7. Logs the exception (unless suppressed via ExceptionHandlerOptions.SuppressDiagnosticsCallback in .NET 10, which lets you skip logging for handled exceptions).

Because it re-executes the pipeline, error endpoints must not depend on state that caused the failure, and they must handle any HTTP method.

2. ProblemDetails in detail

{
  "type": "https://errors.example.com.au/order-conflict",
  "title": "Order already exists",
  "status": 409,
  "detail": "An order with this idempotency key was already submitted.",
  "instance": "/orders",
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
  • type is the stable machine-readable identifier clients should switch on (a URI, ideally pointing to documentation).
  • ValidationProblemDetails adds an errors dictionary (field → messages), produced automatically by [ApiController] model validation and minimal API validation (.NET 10 adds built-in validation for minimal APIs).
  • TypedResults.Problem(...) / Results.ValidationProblem(...) produce them from endpoints for expected outcomes.

3. Exception flow for unhandled exceptions in .NET

Source What happens
Request pipeline Caught by Kestrel/hosting, logged, connection gets 500 (or exception middleware handles it)
async Task method not awaited Exception stored in task; if never observed → UnobservedTaskException on GC
async void method Rethrown on thread pool → AppDomain.UnhandledException → process terminates
Thread / timer callback (System.Threading.Timer) Unhandled → process terminates
BackgroundService.ExecuteAsync .NET 6+: logged, host stops (configurable to Ignore)
IHostedService.StartAsync Host fails to start
Static constructor TypeInitializationException on every access
Environment.FailFast Immediate termination, no finally blocks

4. Host behaviour configuration

builder.Services.Configure<HostOptions>(o =>
{
    o.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.StopHost; // default
    o.ShutdownTimeout = TimeSpan.FromSeconds(30);   // time for graceful shutdown (draining)
});

Ignore restores the old behaviour (worker stops, app continues) — dangerous unless the worker is optional and health checks report it.

In Kubernetes or Azure Container Apps, stopping the host leads to a restart, which is often the healthiest recovery for corrupted state. Combine with readiness/liveness probes and alerts on restart counts.

5. Observability

  • OpenTelemetry: AddAspNetCoreInstrumentation(o => o.RecordException = true) records exceptions as span events; Activity.Current?.AddException(ex) for custom spans (.NET 9).
  • Application Insights: unhandled exceptions are collected automatically from the exception middleware; TelemetryClient.TrackException for handled exceptions you want visible.
  • Logs: use structured logging with the exception object; set sampling and retention appropriately.
  • Metrics: http.server.request.duration with error.type attribute (.NET 8 metrics) lets you alert on error rates by exception type without parsing logs.

6. Graceful degradation vs fail-fast

A global handler is not a licence to keep running in a corrupted state. Guidelines:

  • Request-level failures → handle, return 500, keep serving other requests.
  • Invariants of the process broken (corrupted caches, failed static initialisation, OutOfMemoryException) → fail fast and let the orchestrator restart.
  • Environment.FailFast for truly unrecoverable states where continuing could corrupt data.

Try it yourself

  1. Build a minimal API with two IExceptionHandlers and AddProblemDetails; throw different exceptions and inspect responses in Development vs Production.
  2. Throw from ExecuteAsync in a BackgroundService and observe host shutdown; then switch the behaviour to Ignore and add a health check.
  3. Call an async void method that throws in a console app and watch the process terminate.
  4. Create an unobserved faulted task, force GC.Collect(); GC.WaitForPendingFinalizers(); and log the UnobservedTaskException event.

💻 Code

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails(o =>
    o.CustomizeProblemDetails = ctx =>
        ctx.ProblemDetails.Extensions["traceId"] = ctx.HttpContext.TraceIdentifier);

builder.Services.AddExceptionHandler<DomainExceptionHandler>();   // runs first
builder.Services.AddExceptionHandler<FallbackExceptionHandler>(); // runs if first returns false

var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();

// Process-level last chance
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
    app.Logger.LogCritical(e.ExceptionObject as Exception, "Unhandled exception, terminating");
TaskScheduler.UnobservedTaskException += (_, e) =>
{
    app.Logger.LogError(e.Exception, "Unobserved task exception");
    e.SetObserved();
};

app.Run();

public sealed class DomainExceptionHandler(IProblemDetailsService problems, ILogger<DomainExceptionHandler> log)
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext http, Exception ex, CancellationToken ct)
    {
        if (ex is not DomainException domain) return false;

        log.LogWarning(ex, "Domain rule {Code} violated", domain.Code);
        http.Response.StatusCode = StatusCodes.Status409Conflict;
        return await problems.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = http,
            Exception = ex,
            ProblemDetails = { Title = "Request conflicts with current state", Type = domain.Code }
        });
    }
}

public sealed class FallbackExceptionHandler(ILogger<FallbackExceptionHandler> log) : IExceptionHandler
{
    public ValueTask<bool> TryHandleAsync(HttpContext http, Exception ex, CancellationToken ct)
    {
        log.LogError(ex, "Unhandled exception for {Path}", http.Request.Path);
        return ValueTask.FromResult(false);   // let the default write a generic 500 ProblemDetails
    }
}

// Worker: catch per item, let fatal errors stop the host
public sealed class OrderWorker(IQueue queue, ILogger<OrderWorker> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await foreach (var msg in queue.ReadAllAsync(ct))
        {
            try { await HandleAsync(msg, ct); }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                log.LogError(ex, "Message {Id} failed; dead-lettering", msg.Id);
                await queue.DeadLetterAsync(msg, ct);
            }
        }
    }
}

⚠ Common mistakes

1. Try/catch in every controller action

What happens: each action catches Exception, logs it and returns BadRequest(ex.Message) or StatusCode(500). Why it is wrong: duplicated code, inconsistent responses, leaked messages and wrong status codes. Do instead: use UseExceptionHandler with IExceptionHandlers and ProblemDetails.

2. Returning exception details to clients

What happens: detail = ex.ToString() or the developer exception page in production. Why it is wrong: leaks stack traces, SQL, internal paths and possibly personal data (OWASP information disclosure). Do instead: generic messages with a traceId; full details only in logs.

3. Using UnhandledException to keep the process alive

What happens: expecting the event handler to "handle" errors. Why it is wrong: it's a notification — the process terminates afterwards. Do instead: log and flush telemetry there; prevent unhandled exceptions at their sources.

4. async void methods

What happens: async void SendEmail() called from services. Why it is wrong: exceptions can't be caught by callers and crash the process. Do instead: return Task; use async void only for event handlers with internal try/catch.

5. Fire-and-forget in request handlers

What happens: _ = _emailService.SendAsync(...) in a controller. Why it is wrong: exceptions are lost; scoped services (DbContext) may be disposed when the request ends. Do instead: enqueue work to a background queue or durable message broker.

6. Background workers without per-item error handling

What happens: one malformed message throws out of ExecuteAsync. Why it is wrong: stops the host (or, pre-.NET 6, silently kills the worker), halting all processing. Do instead: catch per item, log, dead-letter, and continue; let truly fatal errors stop the host.

7. Treating cancellation as server errors

What happens: client disconnects (OperationCanceledException) logged as errors and counted as 500s. Why it is wrong: noisy alerts for normal behaviour. Do instead: filter cancellation when HttpContext.RequestAborted is cancelled; log at Debug/Information.

8. Logging the same exception multiple times

What happens: repository, service, handler and middleware all log it. Why it is wrong: inflated error counts and confusing investigations. Do instead: log once at the boundary; add context via scopes and tracing.

9. Inconsistent error formats

What happens: different endpoints return different error JSON shapes. Why it is wrong: clients can't handle errors generically. Do instead: standardise on ProblemDetails with stable type URIs.

10. Ignoring UnobservedTaskException

What happens: no subscription; fire-and-forget failures vanish. Why it is wrong: silent failures in production. Do instead: subscribe, log and call SetObserved(), and remove the fire-and-forget code it reveals.

🏛 Senior insight: trade-offs

Seniors design error handling as a cross-cutting architecture concern: one boundary per entry point, one log per failure, one response format.

Entry point Boundary Outcome
HTTP IExceptionHandler chain ProblemDetails + traceId
Message consumer per-message catch retry / dead-letter
Background loop per-iteration catch continue; fatal -> stop host
Process UnhandledException log, flush, crash, restart
Fire-and-forget queue or continuation never unobserved

They also link this to observability: the traceId in the response matches the trace in logs and distributed tracing, so support can find the failure from a customer's screenshot. And they treat a crash-restart as a valid strategy for corrupted state, rather than catching everything to stay up.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate maps exception types to status codes Why: Checks domain exception design
    How to answer

    Create a custom exception only when callers need to catch it specifically or need extra data that built-in exceptions do not carry. Otherwise use the standard ones: ArgumentException family for bad input, InvalidOperationException for wrong state, NotSupportedException, KeyNotFoundException, TimeoutException. A good custom exception derives from Exception (or a more specific built-in type), not ApplicationException, ends in Exception, provides the three standard constructors (none, message, message plus inner), carries domain properties such as OrderId, and is usually sealed. Do not add the old binary-serialisation constructor: that API is obsolete in modern .NET (SYSLIB0051). For expected business outcomes, a result type is often better than any exception.

  2. When: Candidate says controllers should not catch exceptions Why: Tests applying the strategy in a code review
    How to answer

    The code shows the classic problems: throw ex destroys the stack trace; catching Exception and returning null swallows real failures; logging ex.Message loses the chain; catch-log-rethrow at every layer duplicates logs; .Result blocks and wraps errors in AggregateException; async void crashes the process; throwing Exception gives callers nothing to catch; clean-up outside finally leaks the lock; exceptions used for control flow (parse in try/catch); and cancellation logged as an error. The rewrite uses async Task all the way, using/finally for clean-up, Try methods for expected failures, a specific domain exception with an inner exception, filters for transient retries, and one global handler that logs once.

🛠 Real-world problems: how you handle them

Inconsistent error responses leaked stack traces in a security review
Situation
A multi-tenant API had try/catch blocks in most controller actions, each returning its own JSON shape; some returned `ex.ToString()`. A penetration test flagged the information disclosure.
Signals
Pen test finding (information disclosure), frontend code full of special cases for different error shapes, duplicate error logs for the same request.
Investigation
Grepped for `catch (Exception` in controllers ([dozens] of hits), catalogued the response shapes, and found that some handlers also swallowed exceptions and returned 200 with an error flag.
Resolution
Removed controller-level catches, added `IExceptionHandler`s for domain, validation and cancellation cases, a fallback that logs and returns a generic 500 `ProblemDetails` with `traceId`, and `UseStatusCodePages` for empty responses.
Prevention
Architecture test that fails if controllers catch `Exception`, integration tests that assert error responses match `ProblemDetails` and contain no stack traces, and a re-test by the security team.

How to tell it (STAR)

**Situation:** A penetration test at [company] found our API returning stack traces in some error responses, and our frontend had special cases for [five] different error formats. **Task:** I led the fix, aiming for one consistent and safe error format across the API. **Action:** I removed the try/catch blocks from controllers, introduced `IExceptionHandler`s that map domain, validation and cancellation exceptions to the right status codes, and a fallback that logs the full exception and returns a generic 500 `ProblemDetails` with a trace ID. I added architecture and integration tests so the old pattern could not come back. **Result:** The re-test passed, the frontend deleted its special cases, duplicate logs disappeared, and support could find any failure from the trace ID a customer reported.

↩ Usually asked after

More in Exception Handling

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me