wandres.dev
PATRÓN: AUTH · sesiones y permisos

Refresco: un estado, una cola y un solo intento

Renovar una credencial parece un detalle de infraestructura y es el punto donde más aplicaciones expulsan a sus usuarios sin motivo. Esta lección modela el refresco como estado dedicado con dos disparadores, el preventivo por temporizador y el reactivo por rechazo, y demuestra que la exclusión mutua que los interceptores persiguen con banderas y promesas compartidas ya está garantizada por el grafo: si renovar es un estado y solo se entra desde fuera de él, dos refrescos simultáneos son inexpresables. Alrededor de esa garantía se construyen la cola de peticiones que esperan, la política de reintento único y la disciplina de rotación entre pestañas.

⏱ 19 min

El refresco de credenciales es el lugar donde la autenticación pasa de ser un flujo de pantallas a ser un problema de concurrencia. Mientras el usuario trabaja, la credencial caduca en silencio y hay que renovarla sin interrumpirle; mientras se renueva, otras peticiones siguen saliendo, y cada una de ellas puede descubrir por su cuenta que la credencial ya no vale. La implementación habitual encomienda ese asunto a un interceptor que, ante un rechazo, llama al refresco y reintenta, y esa implementación falla exactamente cuando más importa: cuando cinco peticiones fallan a la vez. Lo interesante es que la solución no requiere ningún mecanismo nuevo de sincronización. Requiere reconocer que renovar es un estado y que un sistema no puede estar dos veces en el mismo estado.

🎯 Al terminar esta lección sabrás
  • Distinguir el disparador preventivo por temporizador del reactivo por rechazo del servidor.
  • Obtener exclusión mutua del refresco a partir de la topología del grafo, sin banderas ni cerrojos.
  • Implementar la cola de peticiones que esperan a que la credencial vuelva a ser estable.
  • Acotar el reintento a uno solo y separar el fallo transitorio del rechazo definitivo.

Dos disparadores para un mismo estado

Una renovación puede empezar por dos caminos y conviene no confundirlos, porque tienen origen, fiabilidad y frecuencia distintos. El disparador preventivo es un temporizador que dispara poco antes de la caducidad declarada por el servidor, y su virtud es que la renovación ocurre mientras no hay nada urgente en vuelo. El disparador reactivo es una respuesta rechazada, y su virtud es que no depende del reloj local, que como vimos miente cada vez que el equipo se suspende. Un sistema robusto usa los dos: el preventivo evita la mayoría de los rechazos y el reactivo cubre todos los casos en que el preventivo llegó tarde.

autenticado: {
  after: { ANTES_DE_CADUCAR: 'refrescando' },
  on: {
    RENOVAR: 'refrescando',
    SALIR: 'anonimo',
  },
},
refrescando: {
  // No se declara RENOVAR: volver a entrar aqui es inexpresable.
  invoke: {
    src: 'renovar',
    onDone: { target: 'autenticado', actions: 'guardarSesion' },
    onError: [
      { target: 'expirado', guard: 'esRechazoDefinitivo' },
      { target: 'esperandoReintento' },
    ],
  },
  on: { SALIR: 'anonimo' },
},
esperandoReintento: {
  after: { ESPERA_REFRESCO: 'refrescando' },
  on: { SALIR: 'anonimo' },
},

Ahí está la propiedad central de toda la lección, y cabe en una ausencia: refrescando no declara RENOVAR. Si diez peticiones reciben un rechazo en el mismo milisegundo y las diez envían el evento, la primera provoca la transición y las nueve restantes llegan a un estado que no las escucha, así que el intérprete las descarta sin ruido. No hay bandera que consultar, no hay promesa compartida que memorizar, no hay ventana entre comprobar y actuar donde otro hilo pueda colarse. La exclusión mutua no se implementa: se deduce de que un autómata ocupa un estado a la vez.

Esa diferencia con el interceptor clásico no es de estilo. El interceptor tiene que sostener una variable con la promesa de refresco en curso, comprobarla antes de lanzar la suya, asignarla sin condiciones de carrera y limpiarla en todos los caminos de salida, incluidos los de error; son cuatro oportunidades de equivocarse en un código que nadie mira hasta que un lunes por la mañana media plantilla aparece desconectada. El grafo elimina las cuatro porque no hay variable: el hecho de estar renovando es la posición del sistema, y esa posición es única por definición.

ℹ️
El estado es el cerrojo

Un cerrojo es un objeto que responde a la pregunta de si alguien está dentro de la sección crítica. Un estado responde exactamente a la misma pregunta, con la diferencia de que no puede desincronizarse de la realidad porque es la realidad: el actor está en refrescando o no está, y no existe forma de que la respuesta y el hecho difieran. Cuando una máquina modela bien una sección crítica, la primitiva de sincronización deja de ser código y pasa a ser topología.

La cola de los que esperan

Garantizar un solo refresco resuelve la mitad del problema. La otra mitad es qué hacen las peticiones que llegan mientras la renovación está en curso, y tiene tres respuestas posibles de las cuales dos son malas. Fallar es inaceptable, porque el usuario vería errores durante una operación que estaba destinada a ser invisible. Salir con la credencial vieja es peor, porque acabará en otro rechazo y multiplicará el ruido. La respuesta correcta es esperar, y esperar bien significa suspender la petición hasta que el estado vuelva a ser estable, sin bloquear nada más.

import { waitFor } from 'xstate'

const renovando = (s: any) => s.matches('refrescando') || s.matches('esperandoReintento')

async function credencialEstable() {
  if (renovando(actorAuth.getSnapshot())) {
    await waitFor(actorAuth, (s) => !renovando(s), { timeout: 15_000 })
  }
  return actorAuth.getSnapshot()
}

export async function peticion(url: string, init: RequestInit = {}, reintentada = false) {
  const previo = await credencialEstable()
  if (!previo.matches('autenticado')) throw new Error('sin sesion')

  const res = await fetch(url, { ...init, credentials: 'include' })
  if (res.status !== 401 || reintentada) return res

  actorAuth.send({ type: 'RENOVAR' })
  const posterior = await waitFor(actorAuth, (s) => !renovando(s), { timeout: 15_000 })
  if (!posterior.matches('autenticado')) throw new Error('sesion terminada')
  return peticion(url, init, true)
}

La función se lee como una conversación con la máquina y esa es justamente la idea: quien hace peticiones no sabe nada de credenciales ni de temporizadores, solo pregunta si el sistema está estable y espera si no lo está. El parámetro reintentada es el guardián contra la recursión infinita, y merece atención porque su ausencia produce el fallo más desagradable del área: una petición que el servidor rechaza por permisos, no por caducidad, dispara un refresco que tiene éxito, se reintenta, se rechaza otra vez, y el bucle solo termina cuando el servidor corta por exceso de llamadas.

sequenceDiagram
participant P as peticiones
participant M as maquina de auth
participant S as servidor
P->>S: tres peticiones con credencial caducada
S-->>P: tres rechazos por credencial
P->>M: RENOVAR por triplicado
M->>M: la primera transiciona, las otras se descartan
M->>S: un unico refresco
S-->>M: credencial nueva
M-->>P: estado autenticado de nuevo
P->>S: tres reintentos, uno por peticion
Situación durante el refresco Comportamiento correcto Fallo típico si no se modela
petición nueva espera a que el estado se estabilice sale con credencial caducada
petición ya en vuelo recibe rechazo y reintenta una vez reintenta en bucle
segundo evento de renovación se descarta por ausencia de transición segundo refresco y rotación rota
salida del usuario SALIR abandona refrescando la renovación revive la sesión
refresco rechazado de forma definitiva destino expirado reintentos infinitos contra un rechazo
⚠️
La rotación castiga el refresco duplicado

Cuando el servidor rota la credencial de refresco en cada uso e invalida toda la familia al detectar una reutilización —que es la práctica recomendada, porque es lo que convierte un robo en un incidente detectable—, dos refrescos simultáneos dejan de ser un desperdicio y pasan a ser una expulsión. El segundo llega con una credencial que el primero acaba de consumir, el servidor lo interpreta como reutilización y cierra la familia entera. Ese es el mecanismo exacto por el que una aplicación desconecta a sus usuarios sin que nadie encuentre el error: el sistema hizo lo correcto y el cliente pidió dos veces.

Varias pestañas, un solo dueño

El grafo garantiza un refresco por actor, y cada pestaña tiene el suyo. Con rotación estricta, dos pestañas que despiertan a la vez reproducen el problema anterior a escala de navegador, y ninguna cantidad de disciplina dentro de la máquina lo evita, porque el conflicto ocurre entre máquinas. La solución conserva el mismo principio, subiéndolo un nivel: que solo una pestaña tenga derecho a renovar y que las demás se enteren del resultado.

const canal = new BroadcastChannel('auth')

canal.onmessage = (e) => {
  if (e.data.tipo === 'sesion') actorAuth.send({ type: 'SESION_EXTERNA', sesion: e.data.sesion })
  if (e.data.tipo === 'fin') actorAuth.send({ type: 'SALIR' })
}

// Solo el titular del cerrojo entra en el estado de refresco.
async function renovarConExclusion() {
  return navigator.locks.request('auth-refresco', async () => {
    const sesion = await renovar()
    canal.postMessage({ tipo: 'sesion', sesion })
    return sesion
  })
}

Lo importante del fragmento no es la interfaz de cerrojos del navegador sino la forma de integrarla: el cerrojo vive dentro del actor invocado, no alrededor de la máquina. La máquina sigue teniendo su estado refrescando y sigue siendo la única fuente de verdad de la pestaña; lo que el cerrojo aporta es que el efecto real se ejecute una vez por navegador en lugar de una vez por pestaña. Y el mensaje de vuelta entra como un evento más, SESION_EXTERNA, que las pestañas seguidoras procesan sin haber renovado nada.

💡
Un margen generoso vale más que un reintento listo

La mayoría de los refrescos reactivos que verás en producción no deberían haber existido: son consecuencia de un margen preventivo demasiado ajustado. Renovar cuando falta un minuto para la caducidad deja fuera cualquier equipo con el reloj desviado, cualquier pestaña que estuvo suspendida y cualquier red lenta. Ampliar el margen a varios minutos elimina de golpe una fracción enorme de la concurrencia que luego hay que gestionar con colas y reintentos, y no cuesta nada salvo renovar algo antes.

La concurrencia se resuelve en la topología, no en el código

Hay una lección general escondida en este capítulo y trasciende con mucho la autenticación. Los problemas de concurrencia que resolvemos a diario —dos envíos del mismo formulario, dos cargas del mismo recurso, dos refrescos de la misma credencial— se atacan casi siempre con variables auxiliares que registran si algo está en curso, y esa estrategia arrastra un defecto irreparable: entre consultar la variable y actuar sobre ella existe una ventana, y en un entorno con eventos asíncronos toda ventana acaba visitándose. Ninguna cantidad de cuidado la cierra, porque el problema no es la falta de cuidado sino que la información sobre el proceso está guardada aparte del proceso. Un autómata suprime la ventana de raíz al hacer que la información y el proceso sean lo mismo: no hay un dato que diga que estoy renovando y además una renovación en marcha que podrían discrepar, hay una posición en un grafo que es simultáneamente ambas cosas, y consultarla no es leer un registro sino observar dónde está el sistema. De ahí que la exclusión mutua aparezca sin que nadie la programe, como aparece en un tren la imposibilidad de estar en dos estaciones: no se garantiza, se hereda de la geometría. Esta es la razón profunda de que las máquinas de estado brillen justo donde el asincronismo se vuelve difícil, y también la razón de que su ventaja no se aprecie en el ejemplo de juguete, donde nunca hay dos eventos a la vez. Modelar bien no consiste en escribir el código correcto para cada carrera posible, sino en construir una forma en la que esas carreras no tengan dónde ocurrir.

⚔️ Rompe tu refresco a propósito
  1. Provoca cinco peticiones simultáneas con la credencial ya caducada y cuenta cuántas llamadas de refresco salen de verdad hacia el servidor.
  2. Elimina de tu implementación cualquier bandera de refresco en curso y comprueba que la ausencia de transición hace el mismo trabajo.
  3. Añade el límite de reintento único y verifica con un rechazo por permisos que no se produce ningún bucle.
  4. Fuerza un rechazo definitivo del refresco y confirma que el sistema aterriza en expirado en lugar de reintentar.
  5. Abre tres pestañas, suspéndelas y despiértalas a la vez para observar cuántas renovaciones compiten y si la rotación las castiga.
  6. Introduce el cerrojo compartido y el canal entre pestañas, y prueba que las seguidoras adoptan la sesión sin llamar al servidor.