Backend Intermediate 15 min

Error Handling and Resilience Patterns in .NET

Implement robust error handling with Result patterns, global exception handling, and Polly resilience policies in .NET applications.

By Victor Robin Updated:

When I first configured Polly circuit breakers for our external OCR service, I set the failure threshold too aggressively — three failures in ten seconds would trip the breaker for a full minute. In development this felt safe, but in production the OCR service had occasional cold-start latency spikes that looked like failures. The circuit breaker would trip during perfectly normal operation, cutting off document processing for a minute at a time. Users would upload a PDF and get an immediate error, even though the OCR service was actually healthy. Tuning those thresholds required weeks of production observation and multiple adjustments before we found the right balance between sensitivity and stability.

Introduction

Robust systems must anticipate and handle failures gracefully. In distributed architectures, errors are inevitable—services timeout, databases lock, and networks partition. Relying solely on try-catch blocks often leads to “exception-driven logic” which is hard to reason about and expensive at runtime. This guide explores implementing resilient error handling in .NET using the Result pattern for domain logic and Polly policies for infrastructure resilience, ensuring system stability under pressure.

[Polly - Resilience and transient-fault-handling library for .NET] — Polly Project , 2024-10-05

Architecture Overview

The following diagram illustrates the multi-layered error handling strategy, from the domain layer up to the API and external integrations.

flowchart TB
    Client["📱 Client"] --> API["🛡️ API Endpoint<br/>(Global Exception Handler)"]

    subgraph App["Application Layer"]
        Service["⚙️ Service Layer<br/>(Result Pattern)"]
        Retry["🔄 Polly Retry Policy"]
        Breaker["⚡ Circuit Breaker"]
    end

    subgraph Ext["External Systems"]
        DB[("🗄️ Database")]
        Remote["☁️ External API"]
    end

    API --> Service
    Service -->|Success/Failure| API

    Service --> DB
    Service --> Retry
    Retry --> Breaker
    Breaker --> Remote

    classDef primary fill:#7c3aed,color:#fff
    classDef secondary fill:#06b6d4,color:#fff
    classDef db fill:#f43f5e,color:#fff
    classDef warning fill:#fbbf24,color:#000

    class Service,API primary
    class Retry,Breaker secondary
    class Client,Remote warning
    class DB db

Implementation

Result Pattern

The Result pattern enforces explicit success and failure handling without relying on exceptions for control flow, a principle advocated by many functional programming practitioners.

[Railway Oriented Programming] — Scott Wlaschin , 2024-01-10

Result Type Implementation

// Core/Common/Result.cs
public sealed class Result<T>
{
    public T? Value { get; }
    public Error? Error { get; }
    // ...existing code...
    public bool IsSuccess => Error is null;
    public bool IsFailure => !IsSuccess;

    private Result(T value)
    {
        Value = value;
        Error = null;
    }

    private Result(Error error)
    {
        Value = default;
        Error = error;
    }

    public static Result<T> Success(T value) => new(value);
    public static Result<T> Failure(Error error) => new(error);

    public TResult Match<TResult>(
        Func<T, TResult> onSuccess,
        Func<Error, TResult> onFailure) =>
        IsSuccess ? onSuccess(Value!) : onFailure(Error!);

    public async Task<TResult> MatchAsync<TResult>(
        Func<T, Task<TResult>> onSuccess,
        Func<Error, Task<TResult>> onFailure) =>
        IsSuccess ? await onSuccess(Value!) : await onFailure(Error!);
}

public static class Result
{
    public static Result<T> Success<T>(T value) => Result<T>.Success(value);
    public static Result<T> Failure<T>(Error error) => Result<T>.Failure(error);
}

Error Type

// Core/Common/Error.cs
public sealed record Error(string Code, string Message, ErrorType Type = ErrorType.Failure)
{
    public static Error NotFound(string resource, string id) =>
        new($"{resource}.NotFound", $"{resource} with ID '{id}' was not found", ErrorType.NotFound);

    public static Error Validation(string code, string message) =>
        new(code, message, ErrorType.Validation);

    public static Error Conflict(string code, string message) =>
        new(code, message, ErrorType.Conflict);

    public static Error Forbidden(string code, string message) =>
        new(code, message, ErrorType.Forbidden);

    public static Error Internal(string message) =>
        new("Internal.Error", message, ErrorType.Internal);
}

public enum ErrorType
{
    Failure,
    Validation,
    NotFound,
    Conflict,
    Forbidden,
    Internal
}

Using Result in Services

// Application/Services/DocumentService.cs
public sealed class DocumentService : IDocumentService
{
    private readonly IDocumentRepository _repository;

    public async Task<Result<Document>> GetByIdAsync(
        DocumentId id,
        CustomId userId,
        CancellationToken ct = default)
    {
        var document = await _repository.GetByIdAsync(id, ct);

        if (document is null)
        {
            return Result.Failure<Document>(
                Error.NotFound("Document", id.Value.ToString()));
        }

        if (document.OwnerId != userId)
        {
            return Result.Failure<Document>(
                Error.Forbidden("Document.Access", "You don't have access to this document"));
        }

        return Result.Success(document);
    }

    public async Task<Result<DocumentId>> CreateAsync(
        CreateDocumentCommand command,
        CancellationToken ct = default)
    {
        // Validation
        if (string.IsNullOrWhiteSpace(command.Name))
        {
            return Result.Failure<DocumentId>(
                Error.Validation("Document.Name.Required", "Document name is required"));
        }

        var document = Document.Create(command.Name, command.OwnerId);
        await _repository.AddAsync(document, ct);

        return Result.Success(document.Id);
    }
}

FastEndpoints Error Handling

Endpoint with Result Pattern

// Api/Endpoints/Documents/GetDocumentEndpoint.cs
public sealed class GetDocumentEndpoint : Endpoint<GetDocumentRequest, DocumentResponse>
{
    private readonly IDocumentService _service;

    public override void Configure()
    {
        Get("/api/documents/{id}");
    }

    public override async Task HandleAsync(GetDocumentRequest req, CancellationToken ct)
    {
        var userId = CustomId.From(User.FindFirst("app_user_id")!.Value);
        var result = await _service.GetByIdAsync(req.Id, userId, ct);

        await result.Match(
            onSuccess: async document =>
            {
                await SendAsync(document.ToResponse(), cancellation: ct);
            },
            onFailure: async error =>
            {
                await SendErrorAsync(error, ct);
            });
    }

    private Task SendErrorAsync(Error error, CancellationToken ct)
    {
        return error.Type switch
        {
            ErrorType.NotFound => SendNotFoundAsync(ct),
            ErrorType.Validation => SendAsync(new ErrorResponse(error), 400, ct),
            ErrorType.Forbidden => SendForbiddenAsync(ct),
            ErrorType.Conflict => SendAsync(new ErrorResponse(error), 409, ct),
            _ => SendAsync(new ErrorResponse(error), 500, ct)
        };
    }
}

Global Exception Handler

Exception Handling Middleware

The IExceptionHandler interface was introduced in .NET 8 as the recommended way to handle unhandled exceptions globally, replacing the older middleware approach.

[Handle errors in ASP.NET Core] — Microsoft , 2024-11-10
// Api/Middleware/GlobalExceptionHandler.cs
public sealed class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
    {
        _logger = logger;
    }

    public async ValueTask<bool> TryHandleAsync(
        HttpContext context,
        Exception exception,
        CancellationToken ct)
    {
        _logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);

        var (statusCode, errorResponse) = exception switch
        {
            ValidationException ex => (400, new ProblemDetails
            {
                Status = 400,
                Title = "Validation Error",
                Detail = ex.Message,
                Extensions = { ["errors"] = ex.Errors }
            }),

            NotFoundException ex => (404, new ProblemDetails
            {
                Status = 404,
                Title = "Not Found",
                Detail = ex.Message
            }),

            UnauthorizedAccessException => (403, new ProblemDetails
            {
                Status = 403,
                Title = "Forbidden",
                Detail = "You don't have permission to access this resource"
            }),

            OperationCanceledException => (499, new ProblemDetails
            {
                Status = 499,
                Title = "Request Cancelled",
                Detail = "The request was cancelled"
            }),

            _ => (500, new ProblemDetails
            {
                Status = 500,
                Title = "Internal Server Error",
                Detail = "An unexpected error occurred"
            })
        };

        context.Response.StatusCode = statusCode;
        context.Response.ContentType = "application/problem+json";

        await context.Response.WriteAsJsonAsync(errorResponse, ct);

        return true;
    }
}

The ProblemDetails format follows RFC 9457, which standardizes error responses for HTTP APIs and improves interoperability with API consumers.

[RFC 9457 - Problem Details for HTTP APIs] — IETF , 2024-03-01

Polly Resilience

Resilience Pipeline Configuration

// Infrastructure/Extensions/ResilienceExtensions.cs
public static class ResilienceExtensions
{
    public static IServiceCollection AddResiliencePolicies(this IServiceCollection services)
    {
        services.AddResiliencePipeline("default", builder =>
        {
            builder
                .AddRetry(new RetryStrategyOptions
                {
                    MaxRetryAttempts = 3,
                    Delay = TimeSpan.FromMilliseconds(500),
                    BackoffType = DelayBackoffType.Exponential,
                    UseJitter = true,
                    ShouldHandle = new PredicateBuilder()
                        .Handle<HttpRequestException>()
                        .Handle<TimeoutException>()
                })
                .AddCircuitBreaker(new CircuitBreakerStrategyOptions
                {
                    FailureRatio = 0.5,
                    SamplingDuration = TimeSpan.FromSeconds(30),
                    MinimumThroughput = 10,
                    BreakDuration = TimeSpan.FromSeconds(30),
                    ShouldHandle = new PredicateBuilder()
                        .Handle<HttpRequestException>()
                        .Handle<TimeoutException>()
                })
                .AddTimeout(TimeSpan.FromSeconds(30));
        });

        return services;
    }
}

Using Resilience in HttpClient

The AddStandardResilienceHandler extension from Microsoft.Extensions.Http.Resilience provides a pre-configured resilience pipeline that follows Microsoft’s recommended defaults for HTTP clients.

[Build resilient HTTP apps with Microsoft.Extensions.Http.Resilience] — Microsoft , 2024-09-25
// Program.cs
builder.Services.AddHttpClient<IOcrService, DoclingOcrService>(client =>
{
    client.BaseAddress = new Uri(builder.Configuration["Docling:Endpoint"]!);
    client.Timeout = TimeSpan.FromMinutes(5);
})
.AddStandardResilienceHandler(options =>
{
    options.Retry.MaxRetryAttempts = 3;
    options.Retry.BackoffType = DelayBackoffType.Exponential;
    options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30);
    options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(5);
});

Manual Resilience Execution

// Application/Services/EmbeddingService.cs
public sealed class EmbeddingService : IEmbeddingService
{
    private readonly ResiliencePipeline _pipeline;
    private readonly IEmbeddingClient _client;
    private readonly ILogger<EmbeddingService> _logger;

    public async Task<float[]> GenerateAsync(
        string text,
        CancellationToken ct = default)
    {
        return await _pipeline.ExecuteAsync(async token =>
        {
            try
            {
                return await _client.EmbedAsync(text, token);
            }
            catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable)
            {
                _logger.LogWarning("Embedding service unavailable, retrying...");
                throw;
            }
        }, ct);
    }
}

Domain Exceptions

Custom Exception Types

// Core/Exceptions/DomainException.cs
public abstract class DomainException : Exception
{
    public string Code { get; }

    protected DomainException(string code, string message) : base(message)
    {
        Code = code;
    }
}

public sealed class NotFoundException : DomainException
{
    public NotFoundException(string resource, object id)
        : base($"{resource}.NotFound", $"{resource} with ID '{id}' was not found")
    {
    }
}

public sealed class ValidationException : DomainException
{
    public IDictionary<string, string[]> Errors { get; }

    public ValidationException(IDictionary<string, string[]> errors)
        : base("Validation.Failed", "One or more validation errors occurred")
    {
        Errors = errors;
    }
}

public sealed class ConflictException : DomainException
{
    public ConflictException(string resource, string reason)
        : base($"{resource}.Conflict", reason)
    {
    }
}

Error Response Models

Standard Error Response

// Api/Models/ErrorResponse.cs
public sealed record ErrorResponse(
    string Code,
    string Message,
    IDictionary<string, string[]>? Errors = null)
{
    public ErrorResponse(Error error) : this(error.Code, error.Message)
    {
    }
}

public sealed record ValidationErrorResponse(
    string Code,
    string Message,
    IDictionary<string, string[]> Errors)
    : ErrorResponse(Code, Message, Errors)
{
    public ValidationErrorResponse(IDictionary<string, string[]> errors)
        : this("Validation.Failed", "One or more validation errors occurred", errors)
    {
    }
}

Conclusion

Error handling and resilience are not features you bolt on at the end — they shape the architecture of your entire application. The combination of the Result pattern for domain-level errors and Polly for infrastructure resilience creates clear boundaries: business logic never throws exceptions for expected cases, and transient infrastructure failures are absorbed by retry and circuit breaker policies before they reach the user. The hardest lesson I learned was that resilience configuration is not something you get right on the first try. Every threshold, every timeout, every retry count needs to be tuned against real production traffic patterns. Start with conservative defaults, instrument everything with metrics, and iterate based on what you observe. The goal is not to prevent all failures — it is to ensure that failures are handled predictably, communicated clearly, and recovered from automatically whenever possible.

[Polly Documentation] — Polly Project , 2024-10-05

Next Steps

Further Reading

[Polly resilience and transient-fault-handling library] — GitHub Community , 2024 [Microsoft: Build resilient HTTP apps] — Microsoft , 2024 [RFC 9457: Problem Details for HTTP APIs] — IETF , 2024 [Scott Wlaschin: Railway Oriented Programming] — Fsharpforfunandprofit , 2024