Samouczek schematu
Samouczek schematuLekcja 22: Obsługa błędów podczas łączenia z usługami

Lekcja 22: Obsługa błędów podczas łączenia z usługami

Podczas pobierania danych z zewnętrznego API możemy napotkać różne typy błędów.

Na przykład rozważmy następujące zapytanie:

{
  externalData: _sendJSONObjectItemHTTPRequest(
    input: {
      url: "https://newapi.getpop.org/wp-json/wp/v2/posts/8888/"
    }
  )
    
  postTitle: _objectProperty(
    object: $__externalData,
    by: { path: "title.rendered"}
  )
}

Jeśli połączenie z Internetem zostanie przerwane, pole _sendJSONObjectItemHTTPRequest wywoła błąd:

{
  "errors": [
    {
      "message": "cURL error 6: Could not resolve host: newapi.getpop.org (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for https://newapi.getpop.org/wp-json/wp/v2/posts/8888/",
      "locations": [
        {
          "line": 2,
          "column": 17
        }
      ],
      "extensions": {
        "path": [
          "externalData: _sendJSONObjectItemHTTPRequest(input: {url: \"https://newapi.getpop.org/wp-json/wp/v2/posts/8888/\"}) @export(as: \"externalData\")",
          "query { ... }"
        ],
        "type": "QueryRoot",
        "field": "externalData: _sendJSONObjectItemHTTPRequest(input: {url: \"https://newapi.getpop.org/wp-json/wp/v2/posts/8888/\"}) @export(as: \"externalData\")",
        "id": "root",
        "code": "PoP/ComponentModel@e1"
      }
    },
    {
      "message": "Argument 'object' in field '_objectProperty' of type 'QueryRoot' cannot be null",
      "locations": [
        {
          "line": 10,
          "column": 13
        }
      ],
      "extensions": {
        "path": [
          "$__externalData",
          "(object: $__externalData)",
          "postTitle: _objectProperty(object: $__externalData, by: {path: \"title.rendered\"})",
          "query { ... }"
        ],
        "type": "QueryRoot",
        "field": "postTitle: _objectProperty(object: $__externalData, by: {path: \"title.rendered\"})",
        "id": "root",
        "code": "gql@5.4.2.1[b]",
        "specifiedBy": "https://spec.graphql.org/draft/#sec-Required-Arguments"
      }
    }
  ],
  "data": {
    "externalData": null,
    "postTitle": null
  }
}

Jeśli uda nam się nawiązać połączenie, ale żądany zasób nie istnieje, otrzymamy odpowiedź 404:

{
  "errors": [
    {
      "message": "Client error: `GET https://newapi.getpop.org/wp-json/wp/v2/posts/8888/` resulted in a `404 Not Found` response:\n{\"code\":\"rest_post_invalid_id\",\"message\":\"Invalid post ID.\",\"data\":{\"status\":404}}\n",
      "locations": [
        {
          "line": 2,
          "column": 17
        }
      ],
      "extensions": {
        "path": [
          "externalData: _sendJSONObjectItemHTTPRequest(input: {url: \"https://newapi.getpop.org/wp-json/wp/v2/posts/8888/\"}) @export(as: \"externalData\")",
          "query { ... }"
        ],
        "type": "QueryRoot",
        "field": "externalData: _sendJSONObjectItemHTTPRequest(input: {url: \"https://newapi.getpop.org/wp-json/wp/v2/posts/8888/\"}) @export(as: \"externalData\")",
        "id": "root",
        "code": "PoP/ComponentModel@e1"
      }
    },
    {
      "message": "Argument 'object' in field '_objectProperty' of type 'QueryRoot' cannot be null",
      "locations": [
        {
          "line": 10,
          "column": 13
        }
      ],
      "extensions": {
        "path": [
          "$__externalData",
          "(object: $__externalData)",
          "postTitle: _objectProperty(object: $__externalData, by: {path: \"title.rendered\"})",
          "query { ... }"
        ],
        "type": "QueryRoot",
        "field": "postTitle: _objectProperty(object: $__externalData, by: {path: \"title.rendered\"})",
        "id": "root",
        "code": "gql@5.4.2.1[b]",
        "specifiedBy": "https://spec.graphql.org/draft/#sec-Required-Arguments"
      }
    }
  ],
  "data": {
    "externalData": null,
    "postTitle": null
  }
}

W obu przypadkach w odpowiedzi pojawił się dodatkowy błąd:

{
  "message": "Argument 'object' in field '_objectProperty' of type 'QueryRoot' cannot be null" 
}

Błąd ten pojawia się dlatego, że po wystąpieniu pierwszego błędu zmienna dynamiczna $__externalData przyjmie wartość null, wywołując drugi błąd. Nie jest to idealne rozwiązanie — wolelibyśmy wiedzieć, że wystąpił jakiś błąd, a następnie pominąć wykonanie reszty zapytania GraphQL.

W tej lekcji samouczka zbadamy, jak to osiągnąć.

Obsługa błędów podczas łączenia z API REST

To zapytanie GraphQL dzieli logikę na dwie operacje, gdzie:

  • Pierwsza operacja eksportuje zmienną dynamiczną $requestProducedErrors, wskazując, czy wartość pola _sendJSONObjectItemHTTPRequest wynosi null (co oznacza, że wystąpił jakiś błąd)
  • Druga operacja jest pomijana przez @skip, gdy $requestProducedErrors ma wartość true

W ten sposób druga operacja, zawierająca logikę do wykonania, jest pomijana, gdy przy pobieraniu danych w pierwszej operacji wystąpił błąd:

query ConnectToRESTEndpoint($postId: ID!) {
  endpoint: _sprintf(
    string: "https://newapi.getpop.org/wp-json/wp/v2/posts/%s/?_fields=id,type,title,date"
    values: [$postId]
  ) @remove
  
  externalData: _sendJSONObjectItemHTTPRequest(
    input: {
      url: $__endpoint
    }
  ) @export(as: "externalData")
 
  requestProducedErrors: _isNull(value: $__externalData)
    @export(as: "requestProducedErrors")
    @remove
}
 
query ExecuteOperation
  @depends(on: "ConnectToRESTEndpoint")
  @skip(if: $requestProducedErrors)
{
  # Do something...
  postTitle: _objectProperty(
    object: $externalData,
    by: { path: "title.rendered"}
  )
}

Po przekazaniu $postId: 1 zapytanie kończy się powodzeniem, a odpowiedź jest następująca:

{
  "data": {
    "externalData": {
      "id": 1,
      "date": "2019-08-02T07:53:57",
      "type": "post",
      "title": {
        "rendered": "Hello world!"
      }
    },
    "postTitle": "Hello world!"
  }
}

Po przekazaniu $postId: 8888 dotyczącego nieistniejącego zasobu otrzymujemy tę odpowiedź (zwróć uwagę, że w odpowiedzi nie ma postTitle ani drugiej wiadomości o błędzie):

{
  "errors": [
    {
      "message": "Client error: `GET https://newapi.getpop.org/wp-json/wp/v2/posts/8888/?_fields=id,type,title,date` resulted in a `404 Not Found` response:\n{\"code\":\"rest_post_invalid_id\",\"message\":\"Invalid post ID.\",\"data\":{\"status\":404}}\n",
      "locations": [
        {
          "line": 6,
          "column": 17
        }
      ],
      "extensions": {
        "path": [
          "externalData: _sendJSONObjectItemHTTPRequest(input: {url: $__endpoint}) @export(as: \"externalData\")",
          "query ConnectToRESTEndpoint($postId: ID!) { ... }"
        ],
        "type": "QueryRoot",
        "field": "externalData: _sendJSONObjectItemHTTPRequest(input: {url: $__endpoint}) @export(as: \"externalData\")",
        "id": "root",
        "code": "PoP/ComponentModel@e1"
      }
    }
  ],
  "data": {
    "externalData": null
  }
}

Jeśli połączenie z Internetem jest niedostępne, otrzymujemy tę odpowiedź:

{
  "errors": [
    {
      "message": "cURL error 6: Could not resolve host: newapi.getpop.org (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for https://newapi.getpop.org/wp-json/wp/v2/posts/8888/?_fields=id,type,title,date",
      "locations": [
        {
          "line": 17,
          "column": 17
        }
      ],
      "extensions": {
        "path": [
          "externalData: _sendHTTPRequest(input: {url: $__endpoint, method: GET}) { ... }",
          "query ConnectToAPI($postId: ID!) @depends(on: \"ExportDefaultDynamicVariables\") { ... }"
        ],
        "type": "QueryRoot",
        "field": "externalData: _sendHTTPRequest(input: {url: $__endpoint, method: GET}) { ... }",
        "id": "root",
        "code": "PoP/ComponentModel@e1"
      }
    }
  ],
  "data": {
    "externalData": null
  }
}

Wyświetlanie komunikatów o błędach z odpowiedzi API REST

Poprzednie zapytanie używa pola _sendJSONObjectItemHTTPRequest, które oczekuje, że kod statusu wyniesie 200 (lub inny kod powodzenia).

Jednak API REST może zwrócić 404 dla brakującego zasobu i podać opisowy komunikat o błędzie w odpowiedzi JSON.

Możemy przechwycić tę informację zwrotną z serwera, zastępując _sendJSONObjectItemHTTPRequest przez _sendHTTPRequest, i wyświetlić ją we wpisie errors odpowiedzi GraphQL.

Na przykład podczas pobierania danych z nieistniejącego zasobu z WP REST API zwraca ono wpis data.status w odpowiedzi wraz z powiązanymi danymi.

To zapytanie GraphQL przechwytuje te dane i jawnie dodaje wpis błędu z kodem i komunikatem błędu odpowiedzi, używając pola _fail (dostarczanego przez rozszerzenie Wyzwalacza błędów odpowiedzi):

query ExportDefaultDynamicVariables
  @configureWarningsOnExportingDuplicateVariable(enabled: false)
{
  defaultEndpointHasErrors: _echo(value: true)
    @export(as: "endpointHasErrors")
    @remove
}
 
query ConnectToAPI($postId: ID!)
  @depends(on: "ExportDefaultDynamicVariables")
{
  endpoint: _sprintf(
    string: "https://newapi.getpop.org/wp-json/wp/v2/posts/%s/?_fields=id,type,title,date"
    values: [$postId]
  ) @remove
  
  externalData: _sendHTTPRequest(
    input: {
      url: $__endpoint,
      method: GET
    }
  ) {    
    contentType
    statusCode
    body @remove
    bodyJSONObject: _strDecodeJSONObject(string: $__body)
      @export(as: "externalData")
  }
 
  isNullExternalData: _isNull(value: $__externalData)
    @export(as: "isNullExternalData")
    @remove
}
 
query ValidateAPIResponse
  @depends(on: "ConnectToAPI")
  @skip(if: $isNullExternalData)
{
  endpointHasErrors: _propertyIsSetInJSONObject(
    object: $externalData
    by: {
      path: "data.status"
    }
  )
    @export(as: "endpointHasErrors")
    @remove
}
 
query FailIfExternalAPIHasErrors($postId: ID!)
  @depends(on: "ValidateAPIResponse")
  @include(if: $endpointHasErrors)
  @skip(if: $isNullExternalData)
{
  code: _objectProperty(
    object: $externalData,
    by: {
      key: "code"
    }
  ) @remove
  message: _objectProperty(
    object: $externalData,
    by: {
      key: "message"
    }
  ) @remove
  errorMessage: _sprintf(
    string: "[%s] %s",
    values: [$__code, $__message]
  ) @remove
  data: _objectProperty(
    object: $externalData,
    by: {
      key: "data"
    }
  ) @remove
  _fail(
    message: $__errorMessage
    data: {
      postId: $postId,
      endpointData: $__data
    }
  ) @remove
}
 
query ExecuteSomeOperation
  @depends(on: "FailIfExternalAPIHasErrors")
  @skip(if: $endpointHasErrors)
{
  # Do something...
  postTitle: _objectProperty(
    object: $externalData,
    by: { path: "title.rendered"}
  )
}

Rozszerzenie Wyzwalacza błędów odpowiedzi udostępnia dwa sposoby dodawania niestandardowego wpisu w errors:

  • Poprzez pole _fail
  • Poprzez dyrektywę @fail

Podczas gdy pole _fail zawsze dodaje błąd, dyrektywa @fail robi to tylko wtedy, gdy spełniony jest warunek podany w argumencie condition. Domyślna wartość to IS_NULL, co oznacza, że zostanie wywołana, gdy pole, do którego jest zastosowana, ma wartość null:

query GetPost($id: ID!) {
  post(by:{id: $id})
    @fail(
      message: "There is no post with the provided ID"
      data: {
        id: $id
      }
    )
  {
    id
    title
  }
}

Przy wykonaniu zapytania ze zmienną $postId: 1 żądanie kończy się powodzeniem i otrzymujemy:

{
  "data": {
    "externalData": {
      "contentType": "application/json; charset=UTF-8",
      "statusCode": 200,
      "bodyJSONObject": {
        "id": 1,
        "date": "2019-08-02T07:53:57",
        "type": "post",
        "title": {
          "rendered": "Hello world!"
        }
      }
    },
    "postTitle": "Hello world!"
  }
}

Przy wykonaniu zapytania ze zmienną $postId: 8888 zasób jest niedostępny i otrzymujemy:

{
  "errors": [
    {
      "message": "[rest_post_invalid_id] Invalid post ID.",
      "locations": [
        {
          "line": 76,
          "column": 3
        }
      ],
      "extensions": {
        "path": [
          "_fail(message: $__errorMessage, data: {postId: $postId, endpointData: $__data}) @remove",
          "query FailIfExternalAPIHasErrors($postId: ID!) @depends(on: \"ValidateAPIResponse\") @include(if: $endpointHasErrors) @skip(if: $isNullExternalData) { ... }"
        ],
        "type": "QueryRoot",
        "field": "_fail(message: $__errorMessage, data: {postId: $postId, endpointData: $__data}) @remove",
        "id": "root",
        "failureData": {
          "postId": 8888,
          "endpointData": {
            "status": 404
          }
        },
        "code": "PoPSchema/FailFieldAndDirective@e1"
      }
    }
  ],
  "data": {
    "externalData": {
      "contentType": "application/json; charset=UTF-8",
      "statusCode": 404,
      "bodyJSONObject": {
        "code": "rest_post_invalid_id",
        "message": "Invalid post ID.",
        "data": {
          "status": 404
        }
      }
    }
  }
}

Obsługa błędów podczas łączenia z API GraphQL

Podczas zapytania o brakujący zasób w API GraphQL odpowiedź będzie miała kod statusu 200 i wartość null dla tego zasobu (co różni się od REST, który zamiast tego zwraca 404).

Poniższe zapytanie GraphQL sprawdza, czy podczas wykonywania _sendGraphQLHTTPRequest nie wystąpiły żadne błędy, weryfikując, że:

  • Odpowiedź nie jest null (np. połączenie z Internetem nie zostało przerwane)
  • Odpowiedź nie zawiera wpisu errors
  • Odpowiedź zawiera wartość różną od null we wpisie data.post (tzn. zapytany zasób istnieje)
query InitializeDynamicVariables
  @configureWarningsOnExportingDuplicateVariable(enabled: false)
{
  defaultResponseHasErrors: _echo(value: false)
    @export(as: "responseHasErrors")
    @remove
  defaultPostIsMissing: _echo(value: false)
    @export(as: "postIsMissing")
    @remove
}
 
query ConnectToGraphQLAPI($postId: ID!)
  @depends(on: "InitializeDynamicVariables")
{
  externalData: _sendGraphQLHTTPRequest(
    input: {
      endpoint: "https://newapi.getpop.org/api/graphql/",
      query: """
        query GetPostData($postId: ID!) {
          post(by: { id : $postId }) {
            date
            title
          }
        }
      """,
      variables: [
        {
          name: "postId",
          value: $postId
        }
      ]
    }
  ) @export(as: "externalData")
 
  requestProducedErrors: _isNull(value: $__externalData)
    @export(as: "requestProducedErrors")
    @remove
}
 
query ValidateResponse
  @depends(on: "ConnectToGraphQLAPI")
  @skip(if: $requestProducedErrors)
{
  responseHasErrors: _propertyIsSetInJSONObject(
    object: $externalData
    by: {
      key: "errors"
    }
  )
    @export(as: "responseHasErrors")
    @remove
 
  postExists: _propertyIsSetInJSONObject(
    object: $externalData
    by: {
      path: "data.post"
    }
  )
    @remove
    
  postIsMissing: _not(value: $__postExists)
    @export(as: "postIsMissing")
    @remove
}
 
query FailIfResponseHasErrors
  @depends(on: "ValidateResponse")
  @skip(if: $requestProducedErrors)
  @skip(if: $postIsMissing)
  @include(if: $responseHasErrors)
{
  errors: _objectProperty(
    object: $externalData,
    by: {
      key: "errors"
    }
  ) @remove
 
  _fail(
    message: "Executing the GraphQL query produced error(s)"
    data: {
      errors: $__errors
    }
  ) @remove
}
 
query ExecuteOperation
  @depends(on: "FailIfResponseHasErrors")
  @skip(if: $requestProducedErrors)
  @skip(if: $responseHasErrors)
  @skip(if: $postIsMissing)
{
  # Do something...
  postTitle: _objectProperty(
    object: $externalData,
    by: { path: "data.post.title" }
  )
}

Po przekazaniu $postId: 1 zapytanie kończy się powodzeniem, a odpowiedź jest następująca:

{
  "data": {
    "externalData": {
      "data": {
        "post": {
          "date": "2019-08-02T07:53:57+00:00",
          "title": "Hello world!"
        }
      }
    },
    "postTitle": "Hello world!"
  }
}

Po przekazaniu $postId: 8888 dotyczącego nieistniejącego zasobu otrzymujemy tę odpowiedź (zwróć uwagę, że w odpowiedzi nie ma postTitle ani żadnego komunikatu o błędzie):

{
  "data": {
    "externalData": {
      "data": {
        "post": null
      }
    }
  }
}

Jeśli połączenie z Internetem jest niedostępne, otrzymujemy tę odpowiedź:

{
  "errors": [
    {
      "message": "cURL error 6: Could not resolve host: newapi.getpop.org (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for https://newapi.getpop.org/api/graphql/",
      "locations": [
        {
          "line": 15,
          "column": 17
        }
      ],
      "extensions": {
        "path": [
          "externalData: _sendGraphQLHTTPRequest(input: {endpoint: \"https://newapi.getpop.org/api/graphql/\", query: \"\n        query GetPostData($postId: ID!) {\n          post(by: { id : $postId }) {\n            date\n            title\n          }\n        }\n      \", variables: [{name: \"postId\", value: $postId}]}) @export(as: \"externalData\")",
          "query ConnectToGraphQLAPI($postId: ID!) @depends(on: \"InitializeDynamicVariables\") { ... }"
        ],
        "type": "QueryRoot",
        "field": "externalData: _sendGraphQLHTTPRequest(input: {endpoint: \"https://newapi.getpop.org/api/graphql/\", query: \"\n        query GetPostData($postId: ID!) {\n          post(by: { id : $postId }) {\n            date\n            title\n          }\n        }\n      \", variables: [{name: \"postId\", value: $postId}]}) @export(as: \"externalData\")",
        "id": "root",
        "code": "PoP/ComponentModel@e1"
      }
    }
  ],
  "data": {
    "externalData": null
  }
}

Generowanie błędu, gdy żądany zasób nie istnieje

W powyższym zapytaniu GraphQL, jeśli zapytany post nie istnieje, po prostu zwraca null i nie ma żadnego wpisu błędu w errors.

Jeśli chcemy wymusić dodanie błędu w tej sytuacji, możemy dołączyć następującą operację, która używa pola _fail do wywołania błędu:

query FailIfPostNotExists($postId: ID!)
  @skip(if: $requestProducedErrors)
  @include(if: $postIsMissing)
  @depends(on: "ValidateResponse")
{
  errorMessage: _sprintf(
    string: "There is no post with ID '%s'",
    values: [$postId]
  ) @remove
  _fail(
    message: $__errorMessage
    data: {
      id: $postId
    }
  ) @remove
}
 
query ExecuteOperation
  @depends(on: [
    "FailIfResponseHasErrors",
    "FailIfPostNotExists"
  ])
  # ...
{
  # ...
}

Teraz po przekazaniu $postId: 8888 dotyczącego nieistniejącego zasobu otrzymujemy tę odpowiedź:

{
  "errors": [
    {
      "message": "There is no post with ID '8888'",
      "locations": [
        {
          "line": 96,
          "column": 3
        }
      ],
      "extensions": {
        "path": [
          "_fail(message: $__errorMessage, data: {id: $postId}) @remove",
          "query FailIfPostNotExists($postId: ID!) @skip(if: $requestProducedErrors) @include(if: $postIsMissing) @depends(on: \"ValidateResponse\") { ... }"
        ],
        "type": "QueryRoot",
        "field": "_fail(message: $__errorMessage, data: {id: $postId}) @remove",
        "id": "root",
        "failureData": {
          "id": 8888
        },
        "code": "PoPSchema/FailFieldAndDirective@e1"
      }
    }
  ],
  "data": {
    "externalData": {
      "data": {
        "post": null
      }
    }
  }
}