Conectar Laravel con Xero sin perder el inquilino
Xero rota el token de refresco en cada renovación, y eso convierte dos workers simultáneos en una organización desconectada para siempre. Ese fallo y los cuatro que vienen después.
La integración entra un martes. Envía facturas, trae pagos, el equipo financiero deja de copiar cifras entre dos pantallas y todo el mundo está contento. Once días después la organización de un cliente aparece desconectada y no se puede reconectar sin que esa persona vuelva a pasar por la pantalla de consentimiento. No se desplegó nada. Nadie tocó el código.
Lo que ocurrió es que dos de sus workers renovaron el mismo token en el mismo segundo.
El token de refresco es de un solo uso
El token de acceso de Xero vive treinta minutos. Con eso se apaña todo el mundo. Lo que pilla a los equipos es que renovarlo le entrega un token de refresco nuevo e invalida de inmediato el que envió. Hay un margen de sesenta segundos sobre el antiguo y después desaparece.
Imagine dos trabajos para el mismo inquilino que arrancan con milisegundos de diferencia, los dos encuentran el token de acceso caducado y los dos llaman al endpoint de renovación. El primero recibe los tokens B y los guarda. El segundo envió el mismo token antiguo y, dentro del margen, también recibe una respuesta válida - otro par, los tokens C - y los escribe encima. Ahora la fila contiene C, la última emisión de Xero fue C, y todo parece correcto. Repítalo bajo carga real con el margen ya consumido y la segunda llamada falla, su manejador de errores no escribe nada, y la fila sigue conteniendo un token de refresco quemado. La organización está desconectada y lo único que la arregla es volver a consentir.
La defensa es que exactamente un proceso pueda renovar un inquilino dado, y que los demás esperen su resultado en lugar de hacer el suyo.
public function accessToken(XeroConnection $connection): string
{
if ($connection->expires_at->isAfter(now()->addMinutes(2))) {
return $connection->access_token;
}
return Cache::lock("xero:refresh:{$connection->tenant_id}", 30)
->block(20, function () use ($connection) {
$connection->refresh(); // releer; alguien puede haber ganado
if ($connection->expires_at->isAfter(now()->addMinutes(2))) {
return $connection->access_token;
}
return $this->exchange($connection);
});
}Tres detalles ahí dentro sostienen el conjunto. El margen de dos minutos evita
entregar un token a un trabajo que va a pasar noventa segundos en cola detrás de
una petición lenta. La relectura dentro del cerrojo abarata al worker perdedor:
despierta, ve un token fresco y lo usa. Y block en lugar de get significa
que el segundo worker espera en vez de devolver false y hacer fallar un trabajo
que no tenía nada malo.
Escriba el par nuevo dentro de una transacción y trate un intercambio fallido como un cambio de estado y no como una excepción. Si Xero dice que el token de refresco no es válido, la conexión está muerta; marcarla como muerta y avisar al cliente es el comportamiento correcto, y reintentarlo cuarenta veces no lo es.
Una conexión no es una empresa
La autorización no es a un usuario ni al negocio de su cliente. Es a un
inquilino, identificado por un tenantId que llega desde el endpoint de
conexiones tras el consentimiento, y una sola persona pulsando en la pantalla de
consentimiento puede concederle tres si administra tres organizaciones.
Esto importa desde el primer día porque decide su esquema. Cada registro que sincroniza lleva el inquilino al que pertenece, cada llamada envía la cabecera de ese inquilino, y cada consulta que busca "la factura de Xero de este pedido" filtra por él. Los equipos que se lo saltan porque el primer cliente tenía una organización acaban escribiendo una migración durante un incidente, que es el peor momento para añadir una columna a una tabla de dos millones de filas.
También importa porque el conjunto no es fijo. Una organización puede retirarse de su aplicación desde la propia interfaz de Xero, por alguien que nunca ha visto la suya. Vuelva a consultar el endpoint de conexiones de forma periódica, no solo al consentir, y concílielo con su tabla.
El límite son cuatro límites
Tiene sesenta llamadas por minuto y por inquilino, cinco mil al día por
inquilino, diez mil por minuto para toda su aplicación y un tope de peticiones
simultáneas. Fallan igual, con un 429 y un Retry-After, pero significan cosas
distintas, y la respuesta le dice cuál ha tocado en la cabecera
X-Rate-Limit-Problem.
Un límite por minuto es un problema de ritmo y la respuesta es frenar la cola de ese inquilino. Un límite diario es un problema de diseño y ningún reintento escalonado lo arregla: está pidiendo cosas que ya tiene. El límite de aplicación es el que le estropea el martes a todos los clientes a la vez porque está corriendo la importación inicial de uno solo, y ese es el argumento para tener una cola por inquilino en vez de una compartida.
Respete Retry-After literalmente. El middleware RateLimited de Laravel sobre
el trabajo, devuelto con el número exacto de segundos que dio la cabecera, es
toda la implementación, y es mejor que cualquier espera exponencial que usted
escribiría, porque ese número no es una suposición.
Los webhooks dicen que algo cambió, no qué
El mensaje de webhook de Xero lleva un tipo de recurso, un inquilino, un identificador y una marca de tiempo. No lleva la factura. Recibe la noticia y luego va a buscarla, lo que convierte un webhook en un aviso para leer, no en una escritura.
Dos cosas del endpoint son lo bastante inusuales como para conocerlas antes de construirlo. Xero lo valida enviando un mensaje de intención de recepción que hay que contestar correctamente antes de que la suscripción se active, y la comprobación de firma es un HMAC sobre el cuerpo en bruto, así que cualquier cosa que reserialice la petición antes de calcular el hash fallará de una forma que parece una clave equivocada. Y la entrega tiene poca paciencia: conteste con un 200 y nada más, ponga el identificador en una cola y haga la lectura después.
Construya igualmente el camino de consulta periódica. Una petición filtrada por
If-Modified-Since le devuelve todo lo que cambió desde su última
sincronización correcta, y le da igual si su endpoint estuvo caído. Esa única
consulta es lo que convierte una caída en un retraso en lugar de en un agujero
en sus datos.
La conciliación es el proyecto
La ingeniería de arriba son dos semanas. Lo que ocupa el resto es decidir qué es verdad.
Su aplicación tiene una factura. Xero tiene una factura. El equipo financiero editó la de Xero el jueves porque es donde trabaja. Alguien emitió un abono contra ella. Entró un pago por un importe que ninguno de los dos registros espera porque el cliente pagó tres facturas en una transferencia. Nada de eso es una pregunta sobre una API.
Las decisiones que hay que escribir antes de que el código sirva de algo: qué sistema posee cada campo, qué ocurre cuando ambos lados cambian el mismo, si una factura anulada en Xero anula el pedido en su aplicación o solo lo marca, cómo se imputa un pago parcial, y qué hace su numeración de facturas cuando Xero es el sistema de referencia para numerar en unas jurisdicciones y no en otras. Nuestro trabajo de ingeniería de integraciones es sobre todo esa conversación, y las llamadas a la API son lo que se deriva de ella.
Haga idempotente cada escritura ya que está. Xero acepta una clave de idempotencia, y la cola que tiene debajo es de entrega al menos una vez, así que un trabajo duplicado se convertirá si no en una factura duplicada en la contabilidad de otra persona.
Cuándo no construir esto
Si el requisito es que el equipo financiero vea los ingresos por producto, una exportación y una hoja de cálculo lo entregan esta semana y una sincronización en dos direcciones lo entrega en dos meses. Si el volumen son cuarenta facturas al mes, el límite diario no es su problema y nada de lo anterior tampoco: un envío nocturno basta y es una décima parte del código.
Construya la sincronización cuando el número de registros convierta la introducción manual en un coste real, cuando los dos lados se editen de verdad, o cuando algo aguas abajo tenga que reaccionar a un pago en cuestión de minutos. En esos casos el trabajo de conciliación se paga solo. Fuera de ellos es mucha ingeniería para eliminar una tarea que costaba veinte minutos a la semana.
Si el libro mayor está en Sage y no en Xero, casi todo esto sigue valiendo y la cuestión del acceso no - ese es otro artículo, porque Sage no es un solo producto.
