Exception Handling and Resilience Patterns
Picture a CI pipeline failure report landing in the team chat at 3am. The message reads: "Test failed: System.Exception: An error occurred." No selector. No page URL. No screenshot. No indication of which test step triggered the failure. The on-call engineer opens the test suite, runs it locally – and it passes. The next morning, six engineers spend the first hour of their day trying to reproduce an issue that two well-structured lines of exception context would have explained immediately.
Exception handling in test automation carries a responsibility that goes beyond crash prevention. It's the mechanism by which failures communicate. The quality of that communication determines whether a failed test leads to a one-minute diagnosis or a two-hour investigation. This lesson covers the full exception toolkit for professional test automation: designing custom exception hierarchies that carry diagnostic context, using C#'s when clause to filter exceptions without unwinding the call stack, implementing retry logic with exponential backoff for transient failures, handling the AggregateException produced by parallel async operations, and ensuring test teardown completes safely even when the test itself fails.
The patterns here aren't about writing more exception code – they're about writing exception code once, writing it well, and reaping the diagnostic benefits across every test failure the suite ever produces. Done well, exception design makes failures self-diagnosing.
Why Exception Quality Defines Test Value
Tests fail. The fundamental question is what happens next. In a well-designed test infrastructure, a failure is a complete communication: it tells the engineer exactly what operation was in progress, what state the system was in, what was expected versus what was observed, and where artifacts like screenshots or response bodies can be found. In a poorly-designed infrastructure, a failure is a riddle – a type name and a message string that might not be enough to reconstruct what actually happened.
The gap between these outcomes is almost entirely determined by exception design decisions made during framework construction. Consider two versions of the same failure:
// Version A – information-poor (the common default)
throw new Exception("Element not found.");
// Version B – information-rich (the professional standard)
throw new ElementInteractionException(
message: "Element not found after 10 seconds of polling.",
selector: "#checkout-submit-btn",
pageUrl: driver.Url,
screenshotPath: CaptureScreenshot(driver, "checkout_failure"),
testStep: "Clicking submit button on checkout page");
Version A requires reproduction to understand. Reproduce means running the test again, waiting for the environment to reach the same state, and hoping the failure recurs. Version B answers every reproduction question before anyone picks up a keyboard: what element, on what page, at what state, with a screenshot proving it.
The key insight is that exception design is interface design. Your custom exceptions are the API your test infrastructure exposes to the engineers who read failure reports. Every property you add is a question pre-answered in every future failure that exception type represents. Every property you omit is a question that will require manual investigation to answer.
A practical way to evaluate an exception design is to imagine receiving its output in a CI failure report with no additional context. If that report contains everything needed to diagnose the failure without running the test, the exception design is good. If it leaves questions open, more context belongs on the exception.
Designing a Test Exception Hierarchy
A flat exception structure – where everything throws Exception or a single project-wide type – makes it impossible to distinguish transient infrastructure failures from genuine assertion failures, catch UI errors without also catching database errors, or provide meaningful context for different failure categories. A hierarchical structure solves all three problems at once.
The Base Exception Class
The hierarchy begins with a shared base class that establishes context common to every test failure. At minimum, this means the logical test step where the failure occurred and a timestamp. A well-designed base also overrides ToString() to lead with diagnostic context rather than raw stack frames, because CI systems typically surface only the first few lines of a failure output:
// Base class for all custom test automation exceptions.
// Carries diagnostic context shared across all failure categories.
public abstract class TestAutomationException : Exception
{
// The logical test step executing when the exception occurred.
public string TestStep { get; }
// UTC timestamp when the exception was thrown.
public DateTimeOffset OccurredAt { get; }
protected TestAutomationException(
string message,
string testStep,
Exception inner = null)
: base(message, inner)
{
TestStep = testStep;
OccurredAt = DateTimeOffset.UtcNow;
}
public override string ToString()
{
// Lead with human-readable context, then the stack trace
return $"[{OccurredAt:O}] {GetType().Name}: {Message}" +
$"\n Test Step: {TestStep}" +
$"\n{base.ToString()}";
}
}
Domain-Specific Exception Types
Concrete exception types extend the base with domain-specific diagnostic properties. A UI test exception knows about browser state; an API exception knows about the HTTP exchange. This separation allows the throwing site to provide exactly the right context without mixing concerns:
// Thrown when a UI interaction with a browser element fails.
// Captures the state of the browser at the moment of failure.
public class ElementInteractionException : TestAutomationException
{
public string Selector { get; }
public string PageUrl { get; }
public string ScreenshotPath { get; }
public ElementInteractionException(
string message,
string selector,
string pageUrl,
string screenshotPath,
string testStep,
Exception inner = null)
: base(message, testStep, inner)
{
Selector = selector;
PageUrl = pageUrl;
ScreenshotPath = screenshotPath;
}
public override string ToString()
{
return $"[{OccurredAt:O}] {GetType().Name}: {Message}" +
$"\n Test Step: {TestStep}" +
$"\n Selector: {Selector}" +
$"\n Page URL: {PageUrl}" +
$"\n Screenshot: {ScreenshotPath}" +
$"\n{StackTrace}";
}
}
// Thrown when an API response does not meet test expectations.
// Captures the HTTP exchange details for debugging.
public class ApiAssertionException : TestAutomationException
{
public string Endpoint { get; }
public int? StatusCode { get; }
public string ResponseBody { get; }
public ApiAssertionException(
string message,
string endpoint,
int? statusCode,
string responseBody,
string testStep,
Exception inner = null)
: base(message, testStep, inner)
{
Endpoint = endpoint;
StatusCode = statusCode;
ResponseBody = responseBody;
}
public override string ToString()
{
// Truncate very long response bodies – full details belong in a file artifact
var preview = ResponseBody?[..Math.Min(500, ResponseBody.Length)] ?? "(null)";
return $"[{OccurredAt:O}] {GetType().Name}: {Message}" +
$"\n Test Step: {TestStep}" +
$"\n Endpoint: {Endpoint}" +
$"\n Status Code: {StatusCode}" +
$"\n Response: {preview}" +
$"\n{StackTrace}";
}
}
Inherit, Don't Categorise with a String
A common shortcut is creating one exception class with a string Category property set to values like "UI" or "API". This eliminates the ability to catch specific failure types with typed handlers and when clauses, and it puts the diagnostic burden back on the message string. A proper hierarchy where catch (ElementInteractionException ex) is possible is minimal overhead – a few class definitions – but the diagnostic precision it enables is significant across a large suite.
The hierarchy pays dividends immediately in CI output. A failure that reports ElementInteractionException with selector, page URL, and a screenshot path is completely self-contained. The engineer reading it has everything they need without touching the test environment, which is exactly what failure communication is supposed to achieve.
Exception Filters and the when Clause
The when clause is C#'s exception filter mechanism, introduced in C# 6, and it's genuinely powerful in ways not obvious from the syntax alone. On the surface, catch (SomeException ex) when (condition) looks like a conditional catch with extra syntax. The important difference is when the condition executes: exception filters evaluate before the stack is unwound.
When an exception propagates through the call stack searching for a handler, the runtime evaluates each when clause it encounters. If the filter returns false, the exception continues propagating without entering the catch block and without modifying the call stack. The frame where the exception originated remains intact.
Filtering Transient Exceptions
The most direct application is selective handling of specific failure conditions within a broader exception type. Selenium's WebDriverException is one type that covers dozens of distinct failure modes. A when clause selects precisely the condition to handle, letting all others propagate:
// Catch only stale element references within WebDriverException.
// All other WebDriverExceptions propagate to the caller untouched.
try
{
var text = element.Text; // May throw if the DOM was rebuilt behind the scenes
}
catch (WebDriverException ex) when (ex.Message.Contains("stale element reference"))
{
// Re-locate the element and retry the read
element = driver.FindElement(By.CssSelector(selector));
var text = element.Text;
}
// Multiple conditions can be combined in a single filter
catch (WebDriverException ex)
when (ex is StaleElementReferenceException
|| ex is ElementClickInterceptedException)
{
await Task.Delay(TimeSpan.FromMilliseconds(500));
// proceed with retry logic
}
Logging Without Catching
A powerful and underused pattern combines when with a method that always returns false. Because the filter runs before stack unwinding, calling this method provides access to both the exception and the original call stack – simultaneously, without altering either. This is impossible to achieve with a catch-and-rethrow approach:
// The filter helper: records the exception and always returns false.
// Returning false means the catch block is never entered.
private bool LogAndContinue(Exception ex, string context)
{
_logger.LogError(ex,
"Exception during {Context} at {Time}",
context,
DateTimeOffset.UtcNow);
return false; // Critical: returning false allows the exception to keep propagating
}
// Usage: observe an exception in flight without catching it
try
{
var order = await _orderRepository.FindByIdAsync(orderId);
}
catch (Exception ex) when (LogAndContinue(ex, $"FindOrder({orderId})"))
{
// This block NEVER executes – LogAndContinue returns false.
// The exception propagates to the caller with its stack trace intact.
throw;
}
// The caller still receives the exception, but a log entry was recorded
// with the full stack trace, including the frame that called FindByIdAsync.
Stack Trace Preservation – Why It Matters
The distinction between throw; and throw ex; is well-known: throw; preserves the original stack trace; throw ex; resets it to the re-throw site. Less well-known is that even throw; can annotate the stack trace with the re-throw location in some runtimes. The when (LogAndContinue(...)) pattern avoids this entirely: the catch block is never entered, so the stack trace is never touched. The exception propagates exactly as if the try block did not exist.
When you do need to rethrow a caught exception while preserving its exact origin, use ExceptionDispatchInfo:
// Capture the exception – including its original stack trace
ExceptionDispatchInfo captured = ExceptionDispatchInfo.Capture(caughtException);
// ... perform some cleanup or logging work ...
// Re-throw with the original stack trace intact – no re-throw annotation
captured.Throw();
ExceptionDispatchInfo is particularly useful in teardown code that catches an exception, performs cleanup, and then needs to re-raise the original failure with perfect fidelity.
when clauses are a small syntactic feature that unlocks semantically significant capabilities: selective exception handling without type proliferation, side-effect observation without stack modification, and intent-communicating filters that make catch blocks self-documenting. They're one of C#'s underrated features and genuinely useful across all layers of a test framework.
Retry Logic and Exponential Backoff
Distributed systems are inherently unreliable. Browser automation operates against a DOM that rebuilds asynchronously. Database connections drop intermittently. HTTP services return 503 under load. A test suite that crashes on every transient failure is fragile – not a reflection of application quality, but a reflection of infrastructure noise. Retry logic with exponential backoff is the standard mechanism for distinguishing genuine failures from temporary disruptions.
Exponential backoff means waiting progressively longer between retry attempts: one second, then two, then four. This prevents retry storms – where multiple tests simultaneously hammer a slow service with rapid retries, making the overload worse rather than giving the system time to recover.
A Generic Retry Utility
A well-designed retry helper is generic, async-aware, configurable via a delegate predicate, and compatible with cancellation. The delegate-based shouldRetry predicate is the critical design choice – it keeps the mechanism generic while allowing callers to define precisely which exceptions are worth retrying:
public static class Resilience
{
// Default transient exception predicate for browser automation.
// Extend this to match the transient failures specific to your stack.
private static readonly Func<Exception, bool> DefaultTransient =
ex => ex is StaleElementReferenceException
|| ex is ElementClickInterceptedException;
/// Retries an async operation with exponential backoff until it succeeds
/// or the maximum number of attempts is exhausted.
public static async Task<T> RetryAsync<T>(
Func<Task<T>> operation,
int maxAttempts = 3,
TimeSpan? initialDelay = null,
Func<Exception, bool> shouldRetry = null,
CancellationToken ct = default)
{
var delay = initialDelay ?? TimeSpan.FromSeconds(1);
shouldRetry ??= DefaultTransient;
// Attempt 1 through maxAttempts - 1: catch and back off
for (int attempt = 1; attempt < maxAttempts; attempt++)
{
try
{
return await operation().ConfigureAwait(false);
}
catch (Exception ex) when (shouldRetry(ex) && !ct.IsCancellationRequested)
{
// Exponential backoff: 1s → 2s → 4s → ...
var backoff = delay * Math.Pow(2, attempt - 1);
await Task.Delay(backoff, ct).ConfigureAwait(false);
}
}
// Final attempt: let any exception propagate naturally to the caller
return await operation().ConfigureAwait(false);
}
// Void overload for operations that return no meaningful result
public static Task RetryAsync(
Func<Task> operation,
int maxAttempts = 3,
TimeSpan? initialDelay = null,
Func<Exception, bool> shouldRetry = null,
CancellationToken ct = default)
=> RetryAsync(
async () => { await operation().ConfigureAwait(false); return true; },
maxAttempts, initialDelay, shouldRetry, ct);
}
Calling the Retry Utility
The caller provides the operation as a lambda and, optionally, a custom predicate. This separation keeps the retry mechanism reusable across unrelated operations without coupling it to any specific exception type:
// Retry with the default transient exception predicate
var productName = await Resilience.RetryAsync(
() => _productPage.GetProductNameAsync());
// Custom predicate: only retry HTTP 503 Service Unavailable responses
var order = await Resilience.RetryAsync(
operation: () => _apiClient.GetOrderAsync(orderId),
maxAttempts: 4,
initialDelay: TimeSpan.FromMilliseconds(500),
shouldRetry: ex => ex is HttpRequestException
{ StatusCode: HttpStatusCode.ServiceUnavailable });
// Retry clicking a button that may be temporarily covered by an overlay
await Resilience.RetryAsync(
operation: async () => await _page.ClickAsync("#submit-button"),
maxAttempts: 3,
shouldRetry: ex => ex is ElementClickInterceptedException);
Never Retry Assertion Failures
The shouldRetry predicate must be precise. Retrying a StaleElementReferenceException is sensible – the DOM changed between locating and interacting with an element, and a brief wait with a fresh lookup resolves it. Retrying an AssertionException or any exception that indicates the application returned incorrect data is wrong: the assertion failed because the system's state doesn't match expectations, and retrying masks a genuine bug while wasting time. The predicate should include only exceptions that represent infrastructure noise, never exceptions that represent application behaviour.
Polly for Production Frameworks
For mature test infrastructure, the Polly NuGet package is the community standard for resilience policies. Polly provides retry, circuit breaker, timeout, bulkhead, and fallback policies with a fluent, composable configuration API. A Polly retry pipeline can combine exponential backoff with jitter (randomised delay variation that prevents coordinated retry storms), circuit breaking (pausing retries entirely when failure rate exceeds a threshold), and structured logging – all from a declarative policy definition. The custom Resilience.RetryAsync shown here is excellent for understanding the underlying pattern. Polly is the right choice when composable policies, metrics, and observability hooks are required.
The combination of a delegate-based predicate and exponential backoff produces retry logic that's both precise – it only retries what should be retried – and respectful of the systems it interacts with. A retry mechanism that catches all exceptions or retries without delay is not resilience, it's noise suppression that will eventually hide a real problem.
Handling AggregateException Correctly
AggregateException is the natural byproduct of concurrent failure. When a Task.WhenAll call includes three tasks and two of them throw, the returned task faults with an AggregateException whose InnerExceptions collection holds both failures. This is exactly the right behaviour: all failures captured, none silently discarded.
The problem is with the default handling. When you await Task.WhenAll(...) inside a try-catch, the runtime unwraps the AggregateException and rethrows only the first inner exception. The remaining failures are lost. For parallel test setup – where three independent data-seeding operations might fail independently – this means two of three failure causes are invisible to the engineer reading the CI report:
// NAIVE – loses all but the first exception from parallel failures
var tasks = new[]
{
CreateTestUserAsync(), // Fails: DB connection refused
CreateCategoriesAsync(), // Fails: Permission denied
CreateProductsAsync() // Succeeds
};
try
{
await Task.WhenAll(tasks); // AggregateException holds both failures
}
catch (Exception ex)
{
// ex is only "DB connection refused" – the permission error is silently gone.
// The engineer investigates the wrong failure cause first.
throw;
}
Capturing All Failures
The correct approach separates the task reference from the await. Holding a reference to the Task returned by Task.WhenAll allows inspection of its Exception property, which always contains the complete AggregateException with all inner exceptions, regardless of what the await rethrows:
// CORRECT – all parallel failures are captured and surfaced
var userTask = CreateTestUserAsync();
var categoryTask = CreateCategoriesAsync();
var productTask = CreateProductsAsync();
// Retain a reference to the WhenAll task – this is the key step
Task allTask = Task.WhenAll(userTask, categoryTask, productTask);
try
{
await allTask;
}
catch
{
// allTask.Exception contains the full AggregateException regardless of what await rethrew
var aggregate = allTask.Exception!;
if (aggregate.InnerExceptions.Count > 1)
{
// Wrap into a descriptive exception that communicates all failures at once
var details = string.Join("\n - ",
aggregate.InnerExceptions.Select(ex => $"{ex.GetType().Name}: {ex.Message}"));
throw new TestSetupException(
$"Test setup failed with {aggregate.InnerExceptions.Count} errors:\n - {details}",
aggregate);
}
// Single failure: re-raise with the original stack trace intact
ExceptionDispatchInfo.Capture(aggregate.InnerExceptions[0]).Throw();
}
// Optionally inspect individual task outcomes for targeted diagnostics
if (userTask.IsFaulted)
_logger.LogError(userTask.Exception, "User creation failed");
if (categoryTask.IsFaulted)
_logger.LogError(categoryTask.Exception, "Category creation failed");
The TestSetupException Wrapper
A dedicated exception for multi-failure setup scenarios makes CI summaries immediately actionable. When the output shows TestSetupException: Test setup failed with 2 errors followed by both messages, the engineer knows it's a setup failure – not a production bug – and can see all causes at once:
public class TestSetupException : TestAutomationException
{
public IReadOnlyList<Exception> SetupFailures { get; }
public TestSetupException(
string message,
AggregateException aggregate,
string testStep = "Test Setup")
: base(message, testStep, aggregate)
{
SetupFailures = aggregate.InnerExceptions;
}
public override string ToString()
{
var failures = string.Join("\n",
SetupFailures.Select((ex, i) =>
$" [{i + 1}] {ex.GetType().Name}: {ex.Message}"));
return $"[{OccurredAt:O}] TestSetupException: {Message}\n{failures}\n{StackTrace}";
}
}
Parallel test setup is a significant performance tool from the async/await lesson. That power comes with a corresponding responsibility: the failure mode is more complex, and sloppy AggregateException handling silently discards diagnostic information. Holding a reference to the WhenAll task is ten extra characters; the diagnostic benefit across the life of a test suite is substantial.
Guaranteed Cleanup and Safe Teardown
Test teardown must complete even when tests fail. A cleanup step skipped because of a test body exception leaves the environment dirty – contaminating subsequent tests, accumulating leaked resources, and turning a single failure into a cascade of failures in subsequent runs. Two C# mechanisms guarantee cleanup regardless of how a code block exits: finally blocks and the IDisposable/IAsyncDisposable pattern.
The finally Block
A finally block executes regardless of whether the corresponding try block exits normally, via a handled exception, or via an unhandled exception propagating to the caller. In test cleanup, this is the correct primitive for work that must always run:
// finally guarantees cleanup even when the assertion throws
[Test]
public async Task PlaceOrder_ShouldCreateInvoice()
{
int? orderId = null;
try
{
orderId = await _orderService.CreateAsync(_testUser.UserId);
await _checkoutService.SubmitAsync(orderId.Value);
var invoice = await _invoiceRepository.FindByOrderAsync(orderId.Value);
Assert.That(invoice, Is.Not.Null);
}
finally
{
// Runs whether the test passed, the assertion failed, or an unexpected exception occurred.
// Wrap cleanup in its own try-catch to prevent it from suppressing the test failure.
if (orderId.HasValue)
{
try
{
await _orderRepository.DeleteAsync(orderId.Value);
}
catch (Exception cleanupEx)
{
// Log as a warning – do NOT rethrow.
// If the test failed, the test exception is more important than this.
TestContext.WriteLine($"Warning: order cleanup failed – {cleanupEx.Message}");
}
}
}
}
Never Throw from finally Blocks
An unhandled exception thrown inside a finally block replaces the exception that was in flight. The original test failure is permanently lost, replaced by the cleanup failure. CI reports then show the wrong problem, and the actual bug in the system under test goes unreported. The pattern is clear: catch all exceptions inside finally, log them as warnings when a test is already failing, and never rethrow from a finally block. The test failure is always the primary signal; cleanup failures are secondary.
IAsyncDisposable for Structured Cleanup
For test contexts managing multiple resources that need disposal in reverse-creation order, IAsyncDisposable combined with await using provides a clean structural solution. The test context accumulates cleanup callbacks during setup and executes all of them during disposal, collecting failures rather than stopping at the first error:
// A test context that manages its own lifecycle cleanly.
// Cleanup actions are registered during setup and run in reverse order on disposal.
public sealed class TestContext : IAsyncDisposable
{
private readonly List<Func<Task>> _cleanupActions = new();
public User TestUser { get; private set; }
public int[] ProductIds { get; private set; }
public static async Task<TestContext> CreateAsync(IServiceProvider services)
{
var context = new TestContext();
var userRepo = services.GetRequiredService<IUserRepository>();
var productRepo = services.GetRequiredService<IProductRepository>();
// Create test user and register its cleanup immediately
context.TestUser = await userRepo.CreateAsync(TestDataFactory.NewUser());
context.RegisterCleanup(() => userRepo.DeleteAsync(context.TestUser.UserId));
// Create test products and register their cleanup
context.ProductIds = await CreateProductBatchAsync(productRepo);
context.RegisterCleanup(() => productRepo.DeleteManyAsync(context.ProductIds));
return context;
}
public void RegisterCleanup(Func<Task> action) => _cleanupActions.Add(action);
public async ValueTask DisposeAsync()
{
var failures = new List<Exception>();
// Execute in reverse order: last created, first deleted
foreach (var action in Enumerable.Reverse(_cleanupActions))
{
try
{
await action().ConfigureAwait(false);
}
catch (Exception ex)
{
// Collect rather than throw – ensure every cleanup action runs
failures.Add(ex);
}
}
// After all cleanup completes, surface any collected failures
if (failures.Count == 1)
ExceptionDispatchInfo.Capture(failures[0]).Throw();
if (failures.Count > 1)
throw new AggregateException("Multiple cleanup failures during test teardown.", failures);
}
}
// In the test class: await using ensures DisposeAsync runs even on test failure
[SetUp]
public async Task SetUpAsync()
{
_context = await TestContext.CreateAsync(_services);
}
[TearDown]
public async Task TearDownAsync()
{
await _context.DisposeAsync();
}
Guaranteed cleanup isn't just good hygiene – it's a prerequisite for test independence. A test that leaves database rows, open connections, or mutated shared state behind causes failures in tests that run next. Treat teardown with the same rigour as setup: design it to run completely regardless of test outcome, collect rather than swallow failures, and never let a cleanup exception silently replace a test failure.
Key Takeaways
- Exception design is interface design. Custom exceptions are the API your test framework exposes to failure reports. Every property you add is a question pre-answered in every future failure; every property you omit is a question requiring manual investigation to answer.
- Build a test exception hierarchy rooted in a shared base class carrying common diagnostic context (test step, timestamp). Extend it with domain-specific types –
ElementInteractionException,ApiAssertionException– that capture the system's state at the exact moment of failure. - Exception filters (
whenclauses) evaluate before the call stack is unwound. This makes them uniquely capable of observing an exception without catching or modifying it, enabling the log-and-continue pattern that preserves the original stack trace – impossible to achieve with catch-and-rethrow. - Retry logic must use a precise predicate. Only retry exceptions representing transient infrastructure noise:
StaleElementReferenceException, network timeouts, HTTP 503. Never retry assertion failures – a failed assertion indicates the application's behaviour is wrong, and retrying hides real bugs. - Exponential backoff prevents retry storms. Waiting progressively longer between attempts – 1s, 2s, 4s – gives overloaded services time to recover rather than hammering them with rapid retries that compound the problem.
- Awaiting
Task.WhenAllloses all but the first exception. Hold a reference to theWhenAlltask separately, then inspect its.Exception.InnerExceptionscollection after catching to see all failures from parallel operations – and surface them together with aTestSetupExceptionwrapper. - Never throw from
finallyblocks. An exception thrown infinallyreplaces the original exception in flight, hiding the actual test failure. Catch cleanup exceptions insidefinally, log them as warnings, and never rethrow. IAsyncDisposablewith a collect-then-throw pattern is the clean abstraction for test context cleanup. Collect all cleanup exceptions during the disposal loop, then surface them as anAggregateExceptionafter all cleanup actions have run – ensuring no step is skipped because an earlier one failed.
Further Reading
- Exceptions and Exception Handling (Microsoft) The official C# exceptions guide covering the exception class hierarchy, try-catch-finally mechanics, creating custom exceptions, and the platform's best practices for exception usage in .NET applications.
- Best Practices for Exceptions (Microsoft) Framework design guidelines covering when to throw versus return errors, how to design exception hierarchies, how to provide actionable error messages, and how to avoid common exception handling anti-patterns.
- Polly – .NET Resilience and Transient Fault Handling Official documentation for the Polly resilience library: retry, circuit breaker, timeout, bulkhead, and fallback policies with examples of composing policies into resilience pipelines appropriate for production test infrastructure.
- Implement a DisposeAsync Method (Microsoft) Complete guide to implementing IAsyncDisposable correctly: the safe dispose pattern, handling both synchronous and asynchronous cleanup paths, and integrating with await using blocks to guarantee resource release.