Продвинутая обработка ошибок в C#: от базового try/catch до промышленных паттернов
Обработка ошибок — это не просто блокировка исключений, а искусство проектирования надежных систем. В C# (.NET) разработчики часто ограничиваются конструкцией try/catch, но платформа предлагает гораздо более мощные механизмы. В этой статье мы разберем продвинутые техники: от фильтров исключений до паттернов возобновления и передачи контекста ошибки.
1. Фильтры исключений: точность вместо заглушек
Фильтры исключений (Exception Filters), появившиеся в C# 6.0, позволяют указывать условие для входа в блок catch без повторного выбрасывания исключения. Это значительно чище, чем классический подход с if внутри блока.
try
{
// Код, который может выбросить исключение
var result = ProcessData(input);
}
catch (CustomException ex) when (ex.ErrorCode == 1001)
{
// Обрабатываем только ошибки с кодом 1001
Log.Error($"Критическая ошибка: {ex.Message}");
throw; // Пробрасываем дальше, если нужно
}
catch (CustomException ex) when (ex.IsRecoverable)
{
// Восстанавливаемая ошибка — логируем и продолжаем
Log.Warning($"Восстановимая ошибка: {ex.Message}");
RetryOperation();
}
Преимущества:
- Не требуется повторный
throwдля фильтрации. - Улучшается читаемость и производительность (стек вызовов не портится).
- Можно комбинировать несколько блоков
catchс разными условиями.
2. Паттерн "Возобновление" (Retry) и Polly
Не все ошибки фатальны. Для временных сбоев (сетевые проблемы, блокировки БД) используйте паттерн повторных попыток. Встроенные средства C# ограничены, поэтому рекомендуется библиотека Polly.
using Polly;
using Polly.Retry;
// Определяем политику повторных попыток
AsyncRetryPolicy retryPolicy = Policy
.Handle<HttpRequestException>() // Обрабатываем только HTTP ошибки
.WaitAndRetryAsync(
3, // Количество попыток
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), // Экспоненциальная задержка
onRetry: (exception, timeSpan, retryCount, context) =>
{
Log.Warning($"Попытка {retryCount} не удалась. Повтор через {timeSpan.Seconds} сек.");
});
// Применяем политику
await retryPolicy.ExecuteAsync(async () =>
{
var response = await httpClient.GetAsync("https://api.example.com/data");
response.EnsureSuccessStatusCode();
});
Важно: Не используйте бесконечные ретраи. Всегда задавайте максимальное количество попыток и используйте Circuit Breaker для защиты системы от каскадных отказов.
3. Правильная передача исключений: throw vs throw ex
Одна из самых частых ошибок — потеря стека вызовов. Разница между throw и throw ex критична для отладки.
try
{
// ...
}
catch (Exception ex)
{
// НЕПРАВИЛЬНО: теряем оригинальный стек
// throw ex;
// ПРАВИЛЬНО: сохраняем стек
throw;
// ИЛИ: добавляем контекст, не теряя оригинал
// throw new ApplicationException("Ошибка в модуле X", ex);
}
Если нужно добавить контекст, используйте ExceptionDispatchInfo (из пространства System.Runtime.ExceptionServices) для точного сохранения стека при перебросе между потоками.
ExceptionDispatchInfo.Capture(exception).Throw(); // Сохраняет оригинальный стек
4. Пользовательские исключения и контекст ошибки
Стандартные исключения часто не несут бизнес-контекста. Создавайте свои классы исключений с дополнительными свойствами.
public class PaymentProcessingException : Exception
{
public int OrderId { get; }
public decimal Amount { get; }
public PaymentErrorType ErrorType { get; }
public PaymentProcessingException(string message, int orderId, decimal amount, PaymentErrorType errorType, Exception inner = null)
: base(message, inner)
{
OrderId = orderId;
Amount = amount;
ErrorType = errorType;
}
public override string ToString()
{
return $"Заказ #{OrderId}: {Message} (Сумма: {Amount:C}, Тип: {ErrorType})";
}
}
// Использование
throw new PaymentProcessingException("Недостаточно средств", 12345, 1500.00m, PaymentErrorType.InsufficientFunds);
Best practices:
- Всегда наследуйте от
Exception(илиApplicationExceptionв legacy коде). - Добавляйте сериализацию (
[Serializable]) для поддержки AppDomain. - Не делайте исключения слишком общими — каждый тип должен иметь четкий смысл.
5. Глобальная обработка и логирование в ASP.NET Core
В веб-приложениях никогда не полагайтесь только на локальные try/catch. Используйте middleware для централизованной обработки.
// Middleware для глобальной обработки исключений
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (PaymentProcessingException ex)
{
_logger.LogWarning(ex, "Ошибка обработки платежа");
context.Response.StatusCode = 400;
await context.Response.WriteAsJsonAsync(new { error = ex.Message, orderId = ex.OrderId });
}
catch (Exception ex)
{
_logger.LogError(ex, "Необработанная ошибка");
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new { error = "Внутренняя ошибка сервера" });
}
}
}
// Регистрация в Program.cs
app.UseMiddleware<ExceptionMiddleware>();
Дополнительно: Используйте IExceptionHandler (новинка .NET 8) для более чистой архитектуры.
6. Паттерн Result вместо исключений (Functional Error Handling)
В некоторых сценариях (например, валидация ввода) исключения слишком тяжеловесны. Расс