Obsługa błędów w .NET — try/catch od podstaw do custom exceptions

try/catch w .NET, hierarchia wyjątków, filter when, logowanie z ILogger i custom exceptions — praktyczny przewodnik z realnego projektu importera.

JAJarosław Armatys 8 min czytania
Wersja wideo tego wpisu — obejrzyj na YouTube

W poprzednich odcinkach serii WP importer zbudowaliśmy moduły: Google Places API, WordPress REST, Entity Framework i bota w Selenium. Wszystko śmiga — dopóki nie wywalisz tego na produkcję i nie puścisz 10 tysięcy wizytówek. Wtedy okazuje się, że po dziesięciu minutach proces się przewrócił, bo w jednym komentarzu pojawiło się emoji, którego driver przeglądarki nie zjadł. I musisz zaczynać od zera.

Tego właśnie w .NET unikasz mechanizmem try/catch. W tym wpisie pokażę ci nie tylko samą składnię, ale też hierarchię wyjątków, filtr when, logowanie przez ILogger i custom exceptions — czyli wszystko, czego potrzebujesz, żeby twoja aplikacja konsolowa nie padała przy pierwszej lepszej awarii po stronie trzeciej.

Po co try/catch

Zasada jest prosta: try/catch zakładasz wszędzie tam, gdzie nie jesteś pewny, czy kod zadziała. Nie chodzi o to, żeby obudowywać nim każdą linijkę — to jest antywzorzec, który tylko zaciemnia kod. Chodzi o to, żeby zabezpieczać granice twojej aplikacji: miejsca, w których rozmawiasz ze światem zewnętrznym.

Klasyczne przykłady z tego projektu:

  • wywołania HTTP do Google Places API — serwer może zwrócić 500, rate limit, timeout;
  • zapis do bazy danych przez EF Core — constraint violation, zerwane połączenie, deadlock;
  • bot w Selenium publikujący na WordPressie — silnik przeglądarki krztusi się na emoji, timeout na wolnym Wi-Fi, zmieniony DOM;
  • serializacja/deserializacja JSON-a — niespodziewany format odpowiedzi.

Reszta logiki aplikacyjnej — mapowanie, walidacje własnych danych, operacje na kolekcjach — to kod, który sam piszesz i sam kontrolujesz. Tam try/catch nie potrzebujesz, bo jeśli tam leci wyjątek, to masz bug i chcesz, żeby aplikacja krzyknęła głośno.

Nie obudowuj try/catch logiki biznesowej, którą sam napisałeś. Obudowuj granice — HTTP, I/O, DB, zewnętrzne procesy. Reszta to ukrywanie własnych bugów.

W moim przypadku to bot okazał się najbardziej niestabilnym elementem. Dodawanie komentarzy przez Selenium czasem rzucało wyjątek, gdy trafił się komentarz z emoji. Nie chciałem tracić dodawania całej wizytówki z powodu jednego z pięćdziesięciu komentarzy — więc opakowałem pętlę w try/catch i jedziemy dalej.

Hierarchia wyjątków

Zanim wrzucisz catch (Exception ex), warto wiedzieć, że wyjątki w .NET tworzą drzewo. Na szczycie jest System.Exception, a pod nim — dziesiątki klas specjalizowanych:

  • SystemException
    • NullReferenceException, ArgumentException, InvalidOperationException
    • IOException → FileNotFoundException, DirectoryNotFoundException
  • HttpRequestException — wszystko, co rzuca HttpClient
  • DbUpdateException, DbUpdateConcurrencyException — Entity Framework
  • TaskCanceledException, OperationCanceledException — cancellation tokens i timeouty
  • JsonException — System.Text.Json

Reguła numer jeden: łap od najbardziej szczegółowego do najbardziej ogólnego. CLR przechodzi bloki catch po kolei i bierze pierwszy pasujący, więc jeśli dasz catch (Exception) jako pierwszy, wszystkie następne są martwym kodem.

try
{
    var places = await _places.FindNearby(lat, lng, radius, type);
    foreach (var place in places)
    {
        await _publisher.PublishAsync(place);
    }
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
    _logger.LogWarning(ex, "Rate limit hit — retrying after 10s");
    await Task.Delay(TimeSpan.FromSeconds(10));
}
catch (DbUpdateException ex)
{
    _logger.LogError(ex, "Database error — skipping batch");
}
catch (Exception ex)
{
    _logger.LogError(ex, "Unexpected error");
    throw;
}

Zwróć uwagę na ostatni catch — łapię Exception, loguję i rzucam dalej przez throw; (nie throw ex; — to resetuje stack trace). To jest siatka bezpieczeństwa: logujemy, co się stało, ale pozwalamy aplikacji zareagować wyżej albo zakończyć się z pełnym kontekstem.

Exception filter — when

Konstrukcja catch (Type ex) when (warunek) to często pomijany gem z C# 6. Filtr when pozwala warunkowo wejść w blok catch — jeśli warunek jest fałszywy, wyjątek leci dalej, tak jakby tego catcha nie było.

Kluczowa różnica w porównaniu do zwykłego if wewnątrz bloku:

// Źle — łapiemy i rzucamy, stack trace się zabrudzi
catch (HttpRequestException ex)
{
    if (ex.StatusCode != HttpStatusCode.TooManyRequests)
        throw;
    // handle rate limit
}

// Dobrze — filtr when, czysty stack trace, zero rzucania
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
    // handle rate limit
}

Filtry są wyjątkowo przydatne przy rozróżnianiu statusów HTTP, kodów błędów SQL albo treści wyjątku:

catch (SqlException ex) when (ex.Number == 2627) // unique constraint
{
    _logger.LogWarning("Duplikat — pomijamy {PlaceId}", place.Id);
}
catch (SqlException ex) when (ex.Number == -2) // timeout
{
    _logger.LogError(ex, "DB timeout — cofam batch");
    throw;
}

Custom exceptions

Gdy twoja domena rośnie, warto wprowadzić własne typy wyjątków. Po co? Dwa powody:

  1. Semantyka — PlaceNotFoundException mówi więcej niż Exception("not found").
  2. Warstwy przepuszczające — wyższe piętra aplikacji mogą łapać tylko te wyjątki, które ich dotyczą, a nie wszystko leci jako generyczny Exception.
public class PlaceNotFoundException : Exception
{
    public PlaceNotFoundException(string placeId)
        : base($"Place '{placeId}' not found in Google API") { }
}

Dobre praktyki przy pisaniu własnych wyjątków:

  • Dziedzicz z Exception (nie z ApplicationException — to zombie z .NET Framework).
  • Daj trzy konstruktory: domyślny, z message, z message i innerException.
  • Trzymaj dodatkowe dane w typowanych property, nie w Data dictionary.
  • Jeśli projekt jest duży — zrób hierarchię bazową (np. WpImporterException), żeby mieć jedno miejsce do łapania „moich” błędów.
public abstract class WpImporterException : Exception
{
    protected WpImporterException(string message) : base(message) { }
    protected WpImporterException(string message, Exception inner)
        : base(message, inner) { }
}

public class PlaceNotFoundException : WpImporterException
{
    public string PlaceId { get; }

    public PlaceNotFoundException(string placeId)
        : base($"Place '{placeId}' not found in Google API")
    {
        PlaceId = placeId;
    }
}

Wyżej w pipeline robisz catch (WpImporterException) i wiesz, że to coś, co sam zgłosiłeś świadomie — w odróżnieniu od losowego HttpRequestException z trzewi biblioteki.

Logowanie zamiast połykania

Najgorsze, co możesz zrobić z wyjątkiem, to złapać go i zignorować. Pusty blok catch to bomba z opóźnionym zapłonem — aplikacja idzie dalej, ale nie wiesz, że coś się wywaliło, dane są niespójne, a ty siedzisz nad logiem trzy dni później i nie widzisz nawet śladu po problemie.

catch (Exception) { } — to jedno z najgorszych rzeczy, jakie możesz zrobić swojemu przyszłemu ja. Stracisz dzień debugowania każdego takiego miejsca.

Minimum, które musisz zrobić w catchu:

  1. Zaloguj przez ILogger — LogError(ex, "komunikat {Kontekst}", dane) — pierwszym argumentem jest wyjątek, żeby logger serializował cały stack trace.
  2. Podejmij decyzję — recover (retry, fallback), skip (loguj i jedź dalej), albo rethrow (throw;).
  3. Nie zmieniaj wyjątku bezcelowo — jeśli chcesz zamienić go na domenowy, użyj throw new PlaceNotFoundException(id, innerException: ex); — to zachowa oryginalny stack trace w InnerException.

Przykład z projektu — dodawanie komentarzy przez bota:

foreach (var comment in comments)
{
    try
    {
        await _bot.AddComment(listingId, comment);
    }
    catch (WebDriverException ex) when (ContainsEmoji(comment.Text))
    {
        _logger.LogWarning(ex, "Komentarz z emoji pominięty ({ListingId})", listingId);
        continue;
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Nieoczekiwany błąd dodawania komentarza ({ListingId})", listingId);
        throw;
    }
}

Pętla przepuszcza „znany zły komentarz” i leci dalej, ale gdy wywali się coś nieoczekiwanego — rethrowuje, żeby nie ukrywać prawdziwych bugów. Tak wyglądają trzy linijki, które w moim projekcie uratowały import kilkuset wizytówek.

Wyjątek nieobsłużony nie jest błędem — błędem jest wyjątek połknięty w ciszy.

Co dalej?

To ostatni odcinek serii WP importer — w czternastu częściach przeszliśmy drogę od pustego Program.cs do działającego bota, który importuje dane z Google Places na WordPressa, zapisuje je do bazy i radzi sobie z emoji w komentarzach.

Kilka tematów, które warto dotknąć dalej, jeśli chcesz zrobić to produkcyjnie:

  • Polly — retry, circuit breaker, timeout jako polityka zamiast ręcznego Task.Delay;
  • MediatR + pipeline behavior — jeden centralny TryCatchBehavior, który loguje i mapuje wyjątki na wyniki;
  • Global exception handler — w ASP.NET Core jest UseExceptionHandler, w konsoli można sobie zrobić własny wrapper wokół main;
  • Structured logging (Serilog) — zamiast sklejania stringów, logujesz właściwości i filtrujesz je w Seq / Elastic.

Jeśli seria ci pomogła, zostaw lajka i napisz w komentarzu, jaki projekt zrobić następny. Kod całego importera jest na moim GitHubie (jarmatys/WPImporter), możesz go zforkować i dalej rozbudowywać.

Tagi:#course-dotnet-wordpress#dotnet#csharp#error-handling

Masz pytanie do tego wpisu?

Napisz do mnie — chętnie porozmawiam o szczegółach technicznych, albo o tym jak zastosować to u Ciebie w projekcie.