REST API - obsługa i tłumaczenie blędów

1

Witam wszystkich,
tytułem wstępu przedstawię się, jestem Jeffdot i od 3 lat pracuję jako programista .NET. Jest to mój pierwszy post, ale samo forum śledzę już od kilku lat.

Zwracam się do Was z problemem, dotyczącym obsługi błędów w aplikacji oraz późniejszym zwracaniem komunikatów w odpowiednim języku do użytkownika końcowego.

Żeby lepiej zrozumieć zagadnienie poniżej zamieszczam informację o tym, jak w mojej pracy wygląda podział na warstwy aplikacji:

  • **Model ** - encje, których kształt oddaje bazę danych 1 do 1. Niewiele jest tutaj logiki, czyli każdy produkt mimo tego, że jest osobnym bytem, i ma swoją logikę, wrzucany jest do jednej globalnej tabeli. Encje mapowane są przez EF.

  • **Application **- Command/Query handlery - MediatR

  • **Calculator **- tutaj bardziej skomplikowana logika dotycząca wyliczania kwoty konkretnej usługi (to jest core naszej aplikacji, cała istotna logika dotycząca poszczególnych produktów jest tutaj)

  • **API **- REST API kontrolery - nasze wewnętrzne API, tworzy Command/Query i przez MediatR deleguje go do warstwy Application

  • Front

Niestety aplikacja ma już pare lat, nie ma Bounded Contextów, a przy dodawania obsługi nowego produktu, umieszczane wszystko jest w tych globalnych layerach w postaci nowych folderów z nazwą pakietu/produktu.

Początkowo logika była sterowana exceptionami, na szczęście zespół przystał na moją propozycję, by zamiast exceptionów zwracać klasę

public class Result<T>
{
	public bool Success { get; private set; }
	public IEnumerable<ErrorCode> Errors { get; private set; }
	public T Value {get; private set;}
}

ErrorCode to enum, np. ErrorCode.EMAIL_EMPTY

W warstwie API, błędy są tłumaczone na odpowiedni język, przez szukanie w Resources komunikatu o takiej samej nazwie co ErrorCode. Testy jednostkowe sprawdzają, czy nie brakuje odpowiedniego komunikatu ErrorCodu.

Przejdźmy teraz do pytań:

  1. Czy ErrorCode enum, może być współdzielony między warstwami? Błąd może wystąpić zarówno w warstwie aplikacji, warstwie model, jak i w warstwie Calculator. Tworzenie osobnych błędów dla każdej warstwy i mapowanie ich w moim przypadku wydaje się być karkołomne.

  2. Czy w ogóle sam pomysł posługiwania się ErrorCodem i tłumaczeniem go w warstwie API/FRONT, jest ok? Pytam ponieważ, ostatnio jeden z seniorów stwierdził, że tutaj jest niepotrzebnie podwójna robota, ponieważ każdy błąd trzeba dodać do ErrorCode, oraz komunikaty w odpowiednich językach do Resources. Kolegi propozycja była, żeby przesyłać od razu komunikat z Resources, ale stwierdziłem, że to zły pomysł, ponieważ inne warstwy nie powinny zajmować się tłumaczeniem komunikatów na przyjazne dla użytkownika, to powinno dziać się na samym końcu.

  3. W jakich sposób zwracacie z API konkretny HTTP Status Code dla konkretnego błędu. U mnie odbywa się to przez tłumaczenie ErrorCode na odpowiedni resposne, np.
    ErrorCode.NOT_FOUND => HTTP 404 (Not Found);
    ErrorCode.SERVICE_UNAVAILABLE => HTTP 503 (Service Unavailable);
    pozostałe => HTTP 400 (BadRequest).

Zapraszam do dyskusji i jak już wcześniej wspomniałem, od dawna śledzę forum, dlatego bardzo liczę na odpowiedzi od: @somekind, @Shalom, @jarekr000000, @neves

0

Nie do końca kawa mi się rozpuściła w krwioobiegu, ale ciekawe czy GraphQL nie jest lekarstwem na Twoje problemy.

Doszedłeś właściwie do muru, gdy model REST już nie jest w stanie zwrócić nic n/t błędu. Na przykład odpowiednika Either
REST ma być prosty, o ile nie zwraca 200, to jakąś 4xx lub 5xx i tyle - nigdy nie było zamiaru zaawansowanej diagnostyki błędów (od tego u niego sa logi serwera).
pewnie, ze można jakoś nagiąć, ale kolanem naginany REST przestaje być REST-em.

Po drugie, w trzeciej literce ma chodzić o STATE, a problemy się biorą z nagięcia do "action", owa "action"/"metoda" jest odwieczną potrzebą w programowaniu, ale REST nie przewiduje

GraphQL robi "jakby" krok wstecz, nawet nie próbuje dobrać "słusznego" kodu HTTP, zawsze zwraca 200, a za to uzyskuje możliwości, o jakich piszesz.

1
Jeffdot napisał(a):

Początkowo logika była sterowana exceptionami, na szczęście zespół przystał na moją propozycję, by zamiast exceptionów zwracać klasę

Czy używanie exceptionów powoduje u was jakieś problemy? Zacząłbym od wyjaśnienia kwestii, że jeśli nie łapiecie wyjątków gdzieś pośrodku drzewka wywołania i nie robicie ifów w okół złapanych wyjątków to nie macie logiki sterowanej wyjątkami. Nie ma nic złego w łapaniu wyjątków w najwyższej warstwie i ich tłumaczeniu, to nie jest sterowanie logiki wyjątkami. Wyjątki są złe bo są znacznie wolniejsze od normalnego kodu. Ale jak nie ma problemów z wydajnością to jestem nawet w stanie przymknąć oko na robienie walidacji na wyjątkach, pomimo tego że walidacja nie jest sytuacją wyjątkową. Także obecne podejście jakie zalecam to używanie Result w warstwie aplikacji do walidacji, a wyjątków w warstwie domeny żeby wymusić niezmienniki.

Przejdźmy teraz do pytań:

  1. Czy ErrorCode enum, może być współdzielony między warstwami? Błąd może wystąpić zarówno w warstwie aplikacji, warstwie model, jak i w warstwie Calculator. Tworzenie osobnych błędów dla każdej warstwy i mapowanie ich w moim przypadku wydaje się być karkołomne.

tworzenie jednego wielkiego enuma współdzielonego pomiędzy warstwami czy też bounded contextami nie jest dobrym pomysłem. Chcemy mieć jak największą spójność i jak najmniej zależności. Wprowadzanie globalnego bytu nam w tym nie pomaga.

  1. Czy w ogóle sam pomysł posługiwania się ErrorCodem i tłumaczeniem go w warstwie API/FRONT, jest ok? Pytam ponieważ, ostatnio jeden z seniorów stwierdził, że tutaj jest niepotrzebnie podwójna robota, ponieważ każdy błąd trzeba dodać do ErrorCode, oraz komunikaty w odpowiednich językach do Resources. Kolegi propozycja była, żeby przesyłać od razu komunikat z Resources, ale stwierdziłem, że to zły pomysł, ponieważ inne warstwy nie powinny zajmować się tłumaczeniem komunikatów na przyjazne dla użytkownika, to powinno dziać się na samym końcu.

Dobry pomysł, dzięki temu będziecie mieli dostęp do zasobów tylko w jednym miejscu i pozostałe warstwy nie będą świadome tego że w ogóle coś takiego istnieje. Będzie można bardzo łatwo w przyszłości trzymanie tłumaczeń z zasobów podmienić na bazę danych.

  1. W jakich sposób zwracacie z API konkretny HTTP Status Code dla konkretnego błędu. U mnie odbywa się to przez tłumaczenie ErrorCode na odpowiedni resposne, np.
    ErrorCode.NOT_FOUND => HTTP 404 (Not Found);
    ErrorCode.SERVICE_UNAVAILABLE => HTTP 503 (Service Unavailable);
    pozostałe => HTTP 400 (BadRequest).
 public class ErrorController : Controller
 {
     /// <summary>
     /// A special endpoint that acts as ExceptionHandler for uncaught exceptions
     /// </summary>       
     [AllowAnonymous]
     [HttpGet, HttpPost, HttpPut, HttpDelete]
     [ProducesResponseType(StatusCodes.Status400BadRequest)]
     [ProducesResponseType(StatusCodes.Status409Conflict)]
     [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
     [ProducesResponseType(StatusCodes.Status500InternalServerError)]
     public ActionResult<ProblemDetails> HandleError()
     {
         var exceptionHandlerPathFeature = HttpContext.Features.Get<IExceptionHandlerPathFeature>();
         var path = exceptionHandlerPathFeature?.Path;
         var error = exceptionHandlerPathFeature?.Error;

         if (error is DbUpdateConcurrencyException)
         {
             return Conflict();
         }

         if (error is DomainException domainException)
         {
             var expectedProblem = CreateProblemDetails(ProblemDetailsType.ExpectedDomain, domainException.Message); 
             return StatusCode(StatusCodes.Status422UnprocessableEntity, expectedProblem);
         }

         var unexpectedProblem = CreateProblemDetails(ProblemDetailsType.UnexpectedError);
         return StatusCode(StatusCodes.Status500InternalServerError, unexpectedProblem);
     }
	 
	 
	 private protected enum ProblemDetailsType { ExpectedApp = 400, ExpectedDomain = 422, UnexpectedError = 500 }
	 
     private protected ProblemDetails CreateProblemDetails(ProblemDetailsType type, string? detail = null)
    {
         var problemDetails = new ProblemDetails
         {               
             Detail = detail
         };

         switch (type)
         {
             case ProblemDetailsType.ExpectedApp:
                 problemDetails.Title = "An expected app error occurred!";
                 problemDetails.Status = StatusCodes.Status400BadRequest;
                 break;
             case ProblemDetailsType.ExpectedDomain:
                 problemDetails.Title = "An expected domain error occurred!";
                 problemDetails.Status = StatusCodes.Status422UnprocessableEntity;
                 break;
             case ProblemDetailsType.UnexpectedError:
                 problemDetails.Title = "An unexpected error occurred!";
                 problemDetails.Status = StatusCodes.Status500InternalServerError;
                 break;
             default:
                 throw new NotImplementedException();
         }

         problemDetails.Extensions["traceId"] = HttpContext.TraceIdentifier;

         return problemDetails;
     }
}

Nie ma tutaj tłumaczenia, ale jakby było to by było w lini 39. I osobiście do identyfikacji teksu zamiast enuma preferuje pełną angielską nazwę trzymaną w stringu.

2
Jeffdot napisał(a):

Przejdźmy teraz do pytań:

  1. Czy ErrorCode enum, może być współdzielony między warstwami? Błąd może wystąpić zarówno w warstwie aplikacji, warstwie model, jak i w warstwie Calculator. Tworzenie osobnych błędów dla każdej warstwy i mapowanie ich w moim przypadku wydaje się być karkołomne.

Myślę, że to nie jest złe rozwiązanie, o ile to nie jest jakiś wielki system. Jeden enum na pewno ułatwia sprawę z unikaniem duplikatów.
Z drugiej strony ja raczej wolę zwrócić obiekt błędu z każdej warstwy z kodem nadawanym w jakiś sensowny sposób oraz w miarę klarownym komunikatem błędu po angielsku. W najgorszym razie, gdy zapomni się przetłumaczyć, to przynajmniej będzie jakiś komunikat dla bardziej rozgarniętego użytkownika, a nie nic niemówiący kod.

  1. Czy w ogóle sam pomysł posługiwania się ErrorCodem i tłumaczeniem go w warstwie API/FRONT, jest ok? Pytam ponieważ, ostatnio jeden z seniorów stwierdził, że tutaj jest niepotrzebnie podwójna robota, ponieważ każdy błąd trzeba dodać do ErrorCode, oraz komunikaty w odpowiednich językach do Resources. Kolegi propozycja była, żeby przesyłać od razu komunikat z Resources, ale stwierdziłem, że to zły pomysł, ponieważ inne warstwy nie powinny zajmować się tłumaczeniem komunikatów na przyjazne dla użytkownika, to powinno dziać się na samym końcu.

A ilutygodniowy ten senior był? ;)
Zdecydowanie jestem za tym, aby tłumaczeniem zajmował się frontend. (I najlepiej niech to robi oddzielny zespół ludzi, bo to frontend.)

  1. W jakich sposób zwracacie z API konkretny HTTP Status Code dla konkretnego błędu. U mnie odbywa się to przez tłumaczenie ErrorCode na odpowiedni resposne, np.
    ErrorCode.NOT_FOUND => HTTP 404 (Not Found);
    ErrorCode.SERVICE_UNAVAILABLE => HTTP 503 (Service Unavailable);
    pozostałe => HTTP 400 (BadRequest).

No ja właśnie raczej na obiektach operuję, więc mam:
ValidationError => 400
NotFound => 404
ServiceIsBusy => 409
OtherServiceError => 422
NotSupported => 418
itd.

neves napisał(a):

Czy używanie exceptionów powoduje u was jakieś problemy? Zacząłbym od wyjaśnienia kwestii, że jeśli nie łapiecie wyjątków gdzieś pośrodku drzewka wywołania i nie robicie ifów w okół złapanych wyjątków to nie macie logiki sterowanej wyjątkami. Nie ma nic złego w łapaniu wyjątków w najwyższej warstwie i ich tłumaczeniu, to nie jest sterowanie logiki wyjątkami. Wyjątki są złe bo są znacznie wolniejsze od normalnego kodu. Ale jak nie ma problemów z wydajnością to jestem nawet w stanie przymknąć oko na robienie walidacji na wyjątkach, pomimo tego że walidacja nie jest sytuacją wyjątkową. Także obecne podejście jakie zalecam to używanie Result w warstwie aplikacji do walidacji, a wyjątków w warstwie domeny żeby wymusić niezmienniki.

Wylewanie pomyj z wiaderka przez okno zamiast spuszczania do kanalizacji też nie powoduje problemów, ale jest po prostu nieładne i niehigieniczne.
Zysku z rzucania wyjątków w sytuacjach niewyjątkowych nie ma żadnego, za to zaśmiecają logi i monitoring.

1 użytkowników online, w tym zalogowanych: 0, gości: 1