by

Cacheando GraphQL con Varnish

Warning: artículo en proceso

El auge del lenguaje de consultas GraphQL está comiendo terreno a pasos agigantados a las APIs basadas en REST, pero todo cambio de paradigma trae consigo la ruptura de las viejas reglas a las que estamos acostumbrados.

Con REST teníamos asumidas varias capas de caché al estar basado en HTTP con el que es fácil controlar su flujo, conocer la vida que tendrá una petición y cachearla en los distintos intermediarios de la red, el navegador o el cliente HTTP. Toda esta magia la perdemos utilizando GraphQL.

Niveles de caché en GraphQL

GraphQL no especifica una capa de transporte en concreto, de hecho puedes usarlo con webservices, pero lo más común es utilizar HTTP como un “túnel tonto” sin disponer de la información que nos daban las cabeceras de expiración, ETaga, Max-age, etc. Esto tiene implicaciones a varios niveles, quedando así:

Aplicación

Es común que el backend implemente su propia capa de cacheo sobre Memcache o Redis, pero los servidores de GraphQL no crean cabeceras con los tiempos de vida a nivel de campo, grafo o petición de forma nativa, por lo que sólo queda construir una solución ad-hoc que utilice caberas o campos personalizados con la información de los TTLs y llegar a un acuerdo con las subsiguientes capas para que puedan tenerlas en cuenta.

Cliente

Los clientes HTTP no pueden verse beneficiados del control de expiración estándar por lo que deberían almacenar y controlar los TTLs de los datos para poder reutilizarlos y evitar peticiones innecesarias. Pero la forma óptima de almacenamiento es a nivel de grafo, de esa forma una petición puede añadir campos o datos de un grafo sin invalidarlo completamente. Esto hace que los clientes deban tener una lógica más compleja, que salvo el futuro Relay v2, pocos están abordando ya que tampoco hay un estándar en el lenguaje que defina qué reglas deben cumplir entre cliente y servidor.

Red

La capa de cacheo de red guarda las peticiones de forma completa y “gracias” a GraphQL quedan desfasada tal cual se usan ahora. Para poder recuperar esta funcionalidad podemos utilizar Varnish como cacheo a nivel de petición dentro de nuestra infraestructura, pero para poder utilizar CDNs tradicionales tendríamos que replicar las reglas que menciono en sus diversos lenguajes, aunque con el CDN Fastly el trabajo sería mucho más sencillo ya que está basado en Varnish.

GraphQL sobre HTTP

GraphQL no especifica una capa de transporte en concreto, de hecho puedes usarlo con webservices pero la más común es HTTP que además te permite utilizar tanto GET como POST para las peticiones. Esto hace que debamos diferenciar dos peticiones al mismo endpoint añadiendo al hasing el método HTTP:

sub vcl_hash {
  hash_data(req.method);
}

Además, si se utiliza POST en el endpoint, debemos añadir el cuerpo de la petición al hashing para poder cachearlas y diferenciarlas. Varnish no lo permite por defecto, pero para ello tenemos el módulo Bodyaccess que nos da acceso como texto al número de bytes del body que le indiquemos:

import bodyaccess;

sub vcl_recv {
  # grab some data from request body
  std.cache_req_body(1KB);
}
sub vcl_hash {
  hash_data(req.method);
  bodyaccess.hash_req_body();
}
Endpoint con POST

Si se especifica la cabecera application/json todo el cuerpo de la petición debe estar codificado en JSON. Si se usa la cabecera application/graphql se permite mandar el cuerpo de la petición en el formato nativo de GraphQL. En ambos casos se puede pasar el parámetro query en la URL codificado como JSON, omitiéndolo así del cuerpo.

# application/json
{
  "query":
    "droidByName ($name: name) {
      droid (name: $name) {
        name,
        friends {
          name
        }
      }
    }"
  "operationName": "{...}",
  "variables": {
    "name": "R2-D2"
  }
}
# application/graphql
query
  droidByName ($name: name) {
    droid (name: $name) {
      name,
      friends {
        name
      }
    }
  }
operationName: {...}
variables: {
  "name": "R2-D2"
}
Endpoint con GET

Usando exclusivamente peticiones GET es la forma más sencilla de actuar.

La composición de la petición usa la forma nativa de GraphQL (no codificada como json) dividiendo los parámetros de la URL:

https://example.com/graphql?query={droidByName($name:name){droid(name:$name){name,friends{name}}}}
&operationName={...}&variables={"name":"R2-D2"}
Mutation y subscription

Los mutation son peticiones de escritura, actualización o borrado definidas por el servidor de GraphQL, equivalentes a PUT, POST y DELETE en una API REST.

Los subscription permiten al servidor mandar actualizaciones de datos mediante push a los clientes suscritos.

Si se está utilizando GET en la petición tenemos que poder diferenciarlas del resto de peticiones cacheables:

sub vcl_rev {
  if (req.url ~ "(\?|&)(mutation|subscription)=") {
    return (pass);
  }
}

Con POST tenemos que inspeccionar el cuerpo de la petición para reconocer que es un mutation o subscription, cosa que Varnish no lo permite por defecto, pero para ello tenemos el módulo Bodyaccess que nos da acceso como texto al número de bytes del body que le indiquemos.

import bodyaccess;

sub vcl_recv {
  # grab some bytes to analyze
  std.cache_req_body(1KB);

  # simple regex, harden it to your needs
  if (bodyaccess.req_body() ~ "(\"|)(mutation|subscription)(\"|)") {
    return (pass);
  }
}
Tratamiento de errores

GraphQL es agnóstico a la capa de transporte y no se usan los HTTP status code para saber si ha sido correcta la petición a nivel de datos.

Según la especificación de GraphQL sobre errores las respuestas erróneas deben contener una lista de errors y pueden contener también la devolución de los datos en data si el resultado es sólo parcialmente erróneo.

[
    'data' => [
        'fieldWithException' => null
    ],
    'errors' => [
        [
            'message' => 'Exception message thrown in field resolver',
            'locations' => [
                ['line' => 1, 'column' => 2]
            ],
            'path': [
                'fieldWithException'
            ]
        ]
    ]
]

Esta es una de las partes más fastidiosas ya que todas las peticiones responden con status code: 200, incluso con errores, y hay que inspeccionar prácticamente todo el cuerpo de la petición para saber si algo ha ido mal.

En nuestro caso lo más seguro es que no nos importe que estos errores se cacheen, pero en caso de no ser así, con Varnish no podemos inspeccionar el cuerpo de la respuesta del servidor, así que sólo nos queda cachear todo lo que nos llegue y pasar la responsabilidad al cliente de la API, que sería el encargado de reintentar la petición para complementar o corregir la petición del grafo erróneo.

Aunque hoy en día no se pueda acceder al cuerpo de la respuesta del backend, en el Vmod livvmod-bodyaccess se está barajando añadir esta posibilidad.

Una solución a este problema es que el servidor o un middleware inspeccione las respuestas en busca de estos errores y los indique en una cabecera HTTP personalizada que capturemos:

sub vcl_backend_response {
  if ( beresp.http.X-GraphQL ~ "Not Cacheable" ) {
    set beresp.uncacheable = true;
  }
}

Gracias a la especificación de referencia, hay una función definida para tratar los errores: formatError y por ejemplo se puede sobrescribir, por ejemplo con GraphServer de Apollo o la implementación de express-graphql, y añadir una cabecera HTTP con response.setHeader() de Nodejs.

Invalidaciones

TODO

  • ban y purge se ven afectadas por tener el mismo req.url

 

 

Write a Comment

Comment