Tutorial del esquema
Tutorial del esquemaLección 22: Manejando errores al conectarse a servicios

Lección 22: Manejando errores al conectarse a servicios

Podemos encontrar diferentes tipos de errores al obtener datos desde una API externa.

Por ejemplo, considera la siguiente consulta:

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

Si la conexión a Internet se ha caído, entonces el campo _sendJSONObjectItemHTTPRequest disparará un error:

{
  "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
  }
}

Si conseguimos conectarnos, pero el recurso solicitado no existe, obtendremos un 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
  }
}

En ambos casos, había un error adicional en la respuesta:

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

Este error ocurre porque, después del primer error, la variable dinámica $__externalData tendrá valor null, disparando el segundo error. Esto no es ideal; preferiríamos ser conscientes de que ocurrió algún error y, entonces, saltarnos la ejecución del resto de la consulta GraphQL.

En esta lección del tutorial exploraremos cómo lograr esto.

Manejando errores al conectarse a una API REST

Esta consulta GraphQL divide la lógica en dos operaciones, donde:

  • La primera operación exporta la variable dinámica $requestProducedErrors, indicando si el valor del campo _sendJSONObjectItemHTTPRequest es null (en cuyo caso, ocurrió algún error)
  • La segunda operación se salta (@skip) cuando $requestProducedErrors es true

De esta forma, la segunda operación, que contiene la lógica a ejecutar, se salta cuando hubo un error obteniendo los datos en la primera operación:

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"}
  )
}

Al pasar $postId: 1, la consulta es exitosa, y la respuesta es:

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

Pasando $postId: 8888 referente a un recurso inexistente, obtenemos esta respuesta (nótese que no hay postTitle en la respuesta, ni un segundo mensaje de error):

{
  "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
  }
}

Si la conexión a Internet está caída, obtenemos esta respuesta:

{
  "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
  }
}

Mostrando los mensajes de error de la respuesta de la API REST

La consulta anterior usa el campo _sendJSONObjectItemHTTPRequest, que espera que el código de estado sea 200 (o cualquier otro código exitoso).

Sin embargo, es posible que la API REST devuelva un 404 para un recurso faltante, y proporcione un mensaje de error descriptivo en la respuesta JSON.

Podemos capturar este feedback del servidor web reemplazando _sendJSONObjectItemHTTPRequest con _sendHTTPRequest, y mostrarlo en la entrada errors de la respuesta GraphQL.

Por ejemplo, al obtener datos de un recurso inexistente de la API REST de WP, devuelve una entrada data.status en la respuesta y datos asociados.

Esta consulta GraphQL captura estos datos, y añade explícitamente una entrada de error con el código de error y mensaje de la respuesta, usando el campo _fail (proporcionado por la extensión Disparador de Errores en la Respuesta):

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"}
  )
}

La extensión Disparador de Errores en la Respuesta proporciona dos formas de añadir una entrada personalizada bajo errors:

  • Mediante el campo _fail
  • Mediante la directiva @fail

Mientras que el campo _fail añade el error siempre, la directiva @fail solo cuando se cumple la condición del argumento condition. Su valor por defecto es IS_NULL, lo que significa que se disparará cuando el campo al que se aplica tenga valor null:

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

Al ejecutar la consulta con la variable $postId: 1 la petición es exitosa, y obtenemos:

{
  "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!"
  }
}

Al ejecutar la consulta con la variable $postId: 8888 el recurso falta, y obtenemos:

{
  "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
        }
      }
    }
  }
}

Manejando errores al conectarse a una API GraphQL

Al consultar un recurso faltante en una API GraphQL, la respuesta tendrá código de estado 200 y valor null para ese recurso (lo que la hace diferente a REST, que en cambio devuelve un 404).

La GraphQL de abajo valida que no ocurrieron errores al ejecutar _sendGraphQLHTTPRequest comprobando que:

  • La respuesta no es null (por ejemplo: la conexión a Internet no se cayó)
  • La respuesta no contiene la entrada errors
  • La respuesta contiene un valor no null bajo la entrada data.post (es decir, el recurso consultado existe)
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" }
  )
}

Al pasar $postId: 1, la consulta es exitosa, y la respuesta es:

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

Pasando $postId: 8888 referente a un recurso inexistente, obtenemos esta respuesta (nótese que no hay postTitle en la respuesta, y tampoco mensaje de error):

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

Si la conexión a Internet está caída, obtenemos esta respuesta:

{
  "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
  }
}

Produciendo un error si el recurso solicitado no existe

En la consulta GraphQL de arriba, si la entrada consultada no existe, simplemente devuelve null y no hay entrada de error bajo errors.

Si queremos forzar añadir un error en esa situación, podemos añadir la siguiente operación, que usa el campo _fail para disparar un error:

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"
  ])
  # ...
{
  # ...
}

Ahora, al pasar $postId: 8888 referente a un recurso inexistente, obtenemos esta respuesta:

{
  "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
      }
    }
  }
}