GraphQL – obsługa błędów

W poprzednich postach opisywałem już podstawowe różnice, które wprowadza GraphQL w stosunku do tworzenia API w oparciu o standard REST. Tym razem chciałbym wziąć na tapet kolejną kwestię, która wprowadza w konsternację osoby stawiające pierwsze kroki z GraphQL. Mowa tutaj o obsłudze błędów, a dokładniej odpowiedziach jakie zwraca serwer po napotkaniu „problemów”.

Możesz zawęzić znajomość kodów statusu

Standard HTTP oferuje całą gamę kodów odpowiedzi serwera, z których spora część jest dobrze znana zarówno twórcom aplikacji backendowych jak i frontendowych. Pierwsi z nich powinni zaprojektować aplikację w taki sposób aby zwracała odpowiedni kod na zdarzenie po stronie serwera. Z kolei aplikacje klienckie powinny być w stanie interpretować te odpowiedzi.

Każdy już przywykł do tego typu wykorzystania kodów odpowiedzi, ale GraphQL po raz kolejny rezygnuje z utartych standardów. Tutaj założeniem jest, że serwer odpowiada zawsze kodem 200 OK. Wyjątkiem jest kod 400, który pojawia się w momencie błędnego wpisania zapytania (nierozpoznawanego w zdefiniowanym schemacie GraphQL).

Pole errors zamiast kodów odpowiedzi

Implementacji GraphQL dla node.js posiada globalny error handler i przechwytuje każdy błąd, który się wydarzy w aplikacji po wysłaniu zapytania do serwera. Przechwycone wyjątki są opakowane w obiekt GraphQLError i przekazywane dalej. W momencie gdy taki Error się pojawi to zmieniony jest format odpowiedzi wysyłanej klientowi.

Aby to zobrazować wykorzystamy aplikację, która powstała podczas tworzenia artykułu o wstępie do GraphQL. Na początek zobaczmy jak wygląda sytuacja w momencie poprawnego zapytania gdzie chcemy wyświetlić dane o autorze na podstawie pewnego uuid.

Z kolei zły format uuid spowoduje wywołanie błędu w Mongoose, który zostanie przechwycony przez GraphQL i zwróci odpowiedni komunikat klientowi:

Z powyższych dwóch przykładów wyraźnie można zauważyć, że w sytuacji poprawnego wyniku zapytania klient może spodziewać się odpowiedzi wewnątrz obiektu data. Jeśli jednak pojawia się jakiś problem to w odpowiedzi JSON z serwera pojawia się lista errors z informacjami o błędzie. Warto też zauważyć, że zawsze zwracana jest 200 jako kod odpowiedzi – niezależnie od tego czy wynik jest poprawny czy nie.

Formatowanie błędnych odpowiedzi

W poprzednim przykładzie można było zobaczyć format odpowiedzi po napotkaniu błędu – domyślnie GraphQL zwraca za dużo informacji klientowi. Lepszym rozwiązaniem będzie zalogowanie prawidłowej informacji o błędzie, jednak klient powinien otrzymać informację o problemie bez szczegółów implementacyjnych.

Wyobraźmy sobie, że w aplikacji wywołany zostanie błąd systemowy, który przez nieuwagę nie został przechwycony (ale jak to?!), dla przykładu może to być próba wczytania nieistniejącego pliku. Wynik takiego błędu wyświetli poniższą odpowiedź:

{
  "errors": [
    {
      "message": "ENOENT: no such file or directory, open '/missing/file'",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "author"
      ]
    }
  ],
  "data": {
    "author": null
  }
}

Czy użytkownik musi być informowany o takich szczegółach? Na pewno nie.

Parametr formatError

Jedną z dostępnych opcji podczas inicjalizacji obsługi GraphQL w express.js jest możliwość zdefiniowania formatu wyświetlanych błędów. Z pomocą przychodzi parametr formatError.

Załóżmy, że nie chcemy pokazywać użytkownikom naszych błędów i decydujemy się „przykryć” każdy wyjątek swoim generycznym komunikatem.

app.use('/', graphqlHTTP({
    schema: schemaFactory.create(),
    graphiql: configurationProvider.useGraphiql(),
    formatError: (error) => {
        console.error(error);
        return { message: 'Something went wrong' };
    }
}));

W rezultacie każdy błąd aplikacji zwróci podobną odpowiedź:

{
  "errors": [
    {
      "message": "Something went wrong"
    }
  ],
  "data": {
    "author": null
  }
}

Czy to oczekiwany wynik? Wydaje się, że tak. Ale czy na pewno?

Nie wylewajmy dziecka z kąpielą

Powyższy przykład skutecznie odciął wszelkie komunikaty pochodzące z aplikacji w momencie kiedy poszło coś źle. Na tyle skutecznie, że pozbawiliśmy odbiorców istotnych informacji. Brak szukanego elementu w bazie? Trudno, „Something went wrong”. Źle wpisane zapytanie GraphQL? No cóż, domyśl się… „Something went wrong” 🙂

Dlatego aby uniknąć wylania przysłowiowego dziecka z kąpielą warto zrobić to dobrze. Wiemy, że GraphQL przechwytuje wszystkie wyjątki i opakowuje je we własny error GraphQLError. Posiada on parametr originalError, który zawiera pierwotnie przechwycony błąd. Jeśli wartość tego parametru tu jest pusta to znaczy, że błąd pochodzi z GraphQL – na przykład zostało wpisane niepoprawne zapytanie. Dzięki tej wiedzy możemy lepiej zaprojektować odpowiedź, którą otrzyma klient.

function formatError(error) {
    console.error('GraphQL Error', error);

    if (error.originalError) {
        return { message: 'Something went wrong' }
    }

    return { message: error.message };
}

Dzięki takiemu rozwiązaniu nakładamy generyczną odpowiedź „Something went wrong” dla wszystkich błędów systemowych. Błędy pochodzące z GraphQL zwrócą oryginalną wiadomość aby użytkownik mógł zinterpretować ją i poprawić błędne dane.

Symulacja kodów odpowiedzi

Jeśli zależy nam na utrzymaniu konwencji kodów odpowiedzi ze standardu HTTP i interpretować je w jakiś sposób po stronie klienta można dodać dodatkowe pole w obiekcie zwracanym w funkcji formatError. Dla przykładu można to rozwiązać w taki sposób:

function formatError(error) {
    console.error('GraphQL Error', error);

    if (error.originalError) {
        return { message: 'Something went wrong', statusCode: 500 }
    }

    return { message: error.message, statusCode: 400 };
}

Przez co w odpowiedzi pojawią się odpowiednie parametry.

{
  "errors": [
    {
      "message": "Something went wrong",
      "statusCode": 500
    }
  ],
  "data": {
    "author": null
  }
}

Implementacja własnych wyjątków

Oprócz wyjątków stricte technicznych w naszych aplikacjach istnieją również te związane z logiką, jak np. po przesłaniu niepoprawnych danych. W tym wypadku również dobrze by było przekazać użytkownikowi informacje co poszło źle. Do tego zadania również wykorzystamy funkcję formatError i error handler z GraphQLa.

Zacznijmy od utworzenia „abstrakcyjnego” wyjątku, po którym będą dziedziczyły wszystkie używane przez nas wyjątki. Pozwoli to na łatwe przechwycenie go przed wysłaniem wiadomości do użytkownika, a dodatkowo wymusi konwencję względem wszystkich wyjątków. Myślę, że dobrym pomysłem jest ustawienie od razu kodu odpowiedzi w każdym typie wyjątku co pozwoli zaoszczędzić trochę czasu podczas formatowania odpowiedzi.

class AppError extends Error {
    constructor(message, statusCode) {
        super();

        this.message = message;
        this.code = statusCode;
    }
}

Następnie można już zaimplementować jakiś konkretny wyjątek, np. który będzie zwracany w momencie błędnej walidacji.

class ValidationError extends AppError {
    constructor(message) {
        super(message, 400);
    }
}

Teraz można w prostu sposób użyć nowo utworzonej klasy w odpowiednim miejscu. W poniższym przykładzie sprawdzamy czy książka, którą chcemy dodać do bazy posiada więcej niż 0 stron.

if (Number.isInteger(inputData.pages) && inputData.pages > 0) {
    throw new ValidationError('Book must have more than 0 pages');
}

Następnie pojawiającą się już wcześniej funkcję formatError można rozszerzyć o obsługę wyjątków pochodzących z aplikacji. Dzięki temu, że stworzyliśmy jedną abstrakcyjną klasę, która zawiera od razu wiadomość i status kodu wystarczy tylko sprawdzić czy przechwycony wyjątek jest odpowiedniego typu i można bezpośrednio wyświetlić zawartość.

function formatError(error) {
    const originalError = error.originalError;

    console.error('GraphQL Error', error);

    if (originalError) {
        if (originalError instanceof AppError) {
            return { message: originalError.message, statusCode: originalError.code };
        }


        return { message: 'Something went wrong', statusCode: 500 }
    }

    return { message: error.message, statusCode: 400 };
}

W taki sposób wszystkie warianty zostały pokryte i w momencie gdy chcemy zwrócić oryginalny komunikat to go wyświetlamy, a kiedy chcemy żeby był „przykryty” to wstawiamy generyczny tekst błędu.

Endpoint zawsze zwróci status HTTP równy 200 OK, ale klient musi sprawdzić czy w odpowiedzi znajduje się pole errors, które sugeruje, że coś poszło źle. Dzięki wprowadzonemu jednakowego formatu odpowiedzi klient może w odpowiedni sposób przetworzyć wynik.

Podsumowanie

Implementacje GraphQL nie wspierają standardowych kodów odpowiedzi HTTP jak to ma w wypadku REST API. Zamiast tego zaproponowano aby w odpowiedziach z serwera zawsze trzymać się konwencji. Jeśli we zwracanym wyniku pojawia się pole errors wiedz, że coś się dzieje! Jeśli dobrze zaprojektujesz format błędnych odpowiedzi to zaoszczędzisz sporo czasu i energii – zarówno sobie jak i odbiorcom.

Kody statusu faktycznie mogą stać się niepotrzebne. Jeśli na początku będzie Ci ich brakowało to z pewnością efekt przyzwyczajenia, ale wraz z czasem stanie się to normalne. Przecież używasz GraphQL… Musi być inaczej 😉

Programista skupiony głównie wokół technologii webowych, ale nie przywiązujący się do konkretnych języków i narzędzi. Skoncentrowany na ciągłym rozwoju, zwolennik ruchu Software Crafmanship. Na codzień pracując w DAZN ma okazję rozwijać interesujący projekt do streamingu wydarzeń sportowych. Prywatnie fan sportu, a szczególnie piłki nożnej. Po godzinach próbuje również swoich sił w piwowarstwie domowym.
PODZIEL SIĘ