Token Refresh
O RefreshCoordinatorService é um componente core que gerencia o refresh de tokens de autenticação (access token e impersonate token) com deduplicação automática de requisições concorrentes.
Visão Geral
Seção intitulada “Visão Geral”Problema que Resolve
Seção intitulada “Problema que Resolve”Quando múltiplas requests simultâneas detectam que o token expirou, sem coordenação cada uma tentaria fazer refresh independentemente:
// ❌ SEM COORDENAÇÃO (problema)Request A: token expirado → POST /refresh-tokenRequest B: token expirado → POST /refresh-token // duplicado!Request C: token expirado → POST /refresh-token // duplicado!// Resultado: 3 chamadas ao servidor, race conditions, tokens inconsistentes// ✅ COM COORDENAÇÃO (solução)Request A: token expirado → POST /refresh-tokenRequest B: token expirado → aguarda o refresh de ARequest C: token expirado → aguarda o refresh de A// Resultado: 1 chamada, todas recebem o mesmo tokenConceito: Single-Flight Pattern
Seção intitulada “Conceito: Single-Flight Pattern”Implementa o padrão single-flight (também conhecido como request deduplication):
“Múltiplas operações idênticas são consolidadas em uma única execução, com todas as chamadas compartilhando o mesmo resultado.”
Benefícios:
- Previne race conditions
- Reduz carga no servidor (1 request ao invés de N)
- Melhora latência (requests não esperam na fila HTTP)
- Garante consistência (todos recebem o mesmo token)
Por Que Existe?
Seção intitulada “Por Que Existe?”Contexto: Sistema de Impersonate
Seção intitulada “Contexto: Sistema de Impersonate”No Gaio Console, admins podem “impersonate” usuários finais:
- Admin faz login → recebe
accessToken - Admin impersona User X → recebe
impersonateTokenpara User X - Todas as requests usam
impersonateToken(se existir) ouaccessToken
Problema Real: Concurrent Polling
Seção intitulada “Problema Real: Concurrent Polling”Cenário comum no app:
setInterval(() => fetchChats(), 5000); // Request AsetInterval(() => fetchMessages(), 5000); // Request BsetInterval(() => fetchNotifications(), 5000); // Request CO que acontece quando impersonateToken expira?
t=0: Requests A, B, C detectam token expiradot=0: A, B, C tentam fazer refresh simultaneamentet=100: Servidor recebe 3 requests de refresh idênticast=200: 3 novos tokens gerados (race condition!)t=300: Apenas o último token salvo é válido → Requests que salvaram tokens anteriores FALHAM → Usuário é deslogado indevidamenteSolução: RefreshCoordinatorService
Seção intitulada “Solução: RefreshCoordinatorService”t=0: Requests A, B, C detectam token expiradot=0: A chama runSingleFlight() → inicia refresht=1: B chama runSingleFlight() → retorna promise de A (dedup!)t=2: C chama runSingleFlight() → retorna promise de A (dedup!)t=100: Servidor recebe 1 requestt=200: 1 novo token geradot=300: A, B, C recebem o mesmo token Todas as requests continuam com token válidoArquitetura
Seção intitulada “Arquitetura”Componentes do Sistema
Seção intitulada “Componentes do Sistema”┌─────────────────────────────────────────────────────────────┐│ Application Layer │├─────────────────────────────────────────────────────────────┤│ Components fazendo requests autenticadas ││ (Chat, Messages, Notifications, etc.) │└────────────────┬────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────┐│ Axios Private API Instance │├─────────────────────────────────────────────────────────────┤│ Request Interceptor: ││ 1. await refreshCoordinator.waitForActiveRefresh() ││ 2. add Authorization header ││ ││ Response Interceptor: ││ - Handle 401 → retry with refreshed token ││ - Handle 403 → redirect │└────────────────┬────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────┐│ Session Service │├─────────────────────────────────────────────────────────────┤│ getSessionToken(): ││ 1. Check refresh token validity ││ 2. Check access token validity → refresh if needed ││ 3. Check impersonate token validity ││ ├─ if expired → runSingleFlight(refresh) ││ └─ if valid → return it │└────────────────┬────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────┐│ RefreshCoordinatorService │├─────────────────────────────────────────────────────────────┤│ runSingleFlight(): ││ - If refresh active → return existing promise ││ - Else → start new refresh → track promise ││ ││ waitForActiveRefresh(): ││ - If refresh active → wait (with 3s timeout) ││ - Else → return null (continue immediately) │└─────────────────────────────────────────────────────────────┘Guia de Uso
Seção intitulada “Guia de Uso”Uso Básico (Atual no Projeto)
Seção intitulada “Uso Básico (Atual no Projeto)”import { refreshCoordinatorService } from "@/core/auth/services/refresh-coordinator.service";
export const getSessionToken = async (): Promise<string> => { // ... validações de refresh token e access token ...
if (impersonateToken && !isValid(impersonateToken)) { const freshToken = await refreshCoordinatorService.runSingleFlight( async () => { const response = await consoleApi.post<{ impersonate_token: string }>( `/impersonate/${accountId}`, null, { headers: { Authorization: `Bearer ${accessToken}` } }, );
return response.data.impersonate_token; }, );
storeSession({ accessToken, refreshToken, impersonateToken: freshToken }); return freshToken; }
return impersonateToken ?? accessToken;};import { RefreshWaitTimeoutError, refreshCoordinatorService,} from "@/core/auth/services/refresh-coordinator.service";
privateApi.interceptors.request.use(async (config) => { try { await refreshCoordinatorService.waitForActiveRefresh(); } catch (error) { if (error instanceof RefreshWaitTimeoutError) { throw error; } throw error; }
const headers = await getSessionHeaders(); config.headers.set("Authorization", headers.Authorization);
return config;});Uso Avançado (Custom Coordinator)
Seção intitulada “Uso Avançado (Custom Coordinator)”import { createRefreshCoordinator } from "@/core/auth/services/refresh-coordinator.service";
const customCoordinator = createRefreshCoordinator();
await customCoordinator.runSingleFlight(async () => { // sua lógica de refresh});API Completa
Seção intitulada “API Completa”runSingleFlight<TData>(runner: () => Promise<TData>): Promise<TData>
Seção intitulada “runSingleFlight<TData>(runner: () => Promise<TData>): Promise<TData>”Executa lógica de refresh com deduplicação automática.
Comportamento:
- Se nenhum refresh ativo → executa
runner()e retorna o resultado - Se refresh já ativo → retorna a promise do refresh ativo (sem executar
runner())
const [token1, token2, token3] = await Promise.all([ refreshCoordinator.runSingleFlight(() => fetchNewToken()), refreshCoordinator.runSingleFlight(() => fetchNewToken()), refreshCoordinator.runSingleFlight(() => fetchNewToken()),]);
// fetchNewToken() foi chamado apenas 1 vez!console.log(token1 === token2); // truewaitForActiveRefresh<TData>(): Promise<TData | null>
Seção intitulada “waitForActiveRefresh<TData>(): Promise<TData | null>”Aguarda refresh ativo completar, ou retorna imediatamente se nenhum refresh está acontecendo.
Throws: RefreshWaitTimeoutError se refresh não completar em 3000ms.
isRefreshing(): boolean
Seção intitulada “isRefreshing(): boolean”Verifica se refresh está ativo no momento.
reset(): void
Seção intitulada “reset(): void”Reseta o estado interno do coordinator. Apenas em testes!
RefreshWaitTimeoutError
Seção intitulada “RefreshWaitTimeoutError”Erro lançado quando waitForActiveRefresh() excede o timeout.
Patterns & Best Practices
Seção intitulada “Patterns & Best Practices”Use runSingleFlight para Todas as Operações de Refresh
Seção intitulada “Use runSingleFlight para Todas as Operações de Refresh”// ✅ CORRETOawait refreshCoordinator.runSingleFlight(async () => { const token = await api.post("/refresh"); localStorage.setItem("token", token); return token;});
// ❌ ERRADO (sem coordenação)const token = await api.post("/refresh");localStorage.setItem("token", token);Use waitForActiveRefresh em Request Interceptors
Seção intitulada “Use waitForActiveRefresh em Request Interceptors”// ✅ CORRETOaxiosInstance.interceptors.request.use(async (config) => { await refreshCoordinator.waitForActiveRefresh(); const token = getTokenFromStorage(); config.headers.Authorization = `Bearer ${token}`; return config;});Handle RefreshWaitTimeoutError Gracefully
Seção intitulada “Handle RefreshWaitTimeoutError Gracefully”try { await refreshCoordinator.waitForActiveRefresh();} catch (error) { if (error instanceof RefreshWaitTimeoutError) { console.error("Refresh timeout - forcing logout"); forceLogout(); } throw error;}Propagar Erros de Refresh
Seção intitulada “Propagar Erros de Refresh”// ✅ CORRETO - propaga para todos os waitersawait refreshCoordinator.runSingleFlight(async () => { try { const token = await api.post("/refresh"); return token; } catch (error) { trackError("refresh_failed", error); throw error; }});
// ❌ ERRADO - engole erroawait refreshCoordinator.runSingleFlight(async () => { try { return await api.post("/refresh"); } catch (error) { return null; // todos os waiters receberão null }});Testing
Seção intitulada “Testing”Setup de Testes
Seção intitulada “Setup de Testes”import { refreshCoordinatorService } from "@/core/auth/services/refresh-coordinator.service";
describe("Session Refresh", () => { afterEach(() => { refreshCoordinatorService.reset(); });});Padrão: Mock de Refresh com Delay
Seção intitulada “Padrão: Mock de Refresh com Delay”it("should deduplicate concurrent impersonate refresh calls", async () => { let callCount = 0;
const mockRefresh = vi.fn(async () => { callCount++; await new Promise((resolve) => setTimeout(resolve, 100)); return "new-token"; });
const [token1, token2, token3] = await Promise.all([ refreshCoordinatorService.runSingleFlight(mockRefresh), refreshCoordinatorService.runSingleFlight(mockRefresh), refreshCoordinatorService.runSingleFlight(mockRefresh), ]);
expect(callCount).toBe(1); expect(token1).toBe("new-token"); expect(token2).toBe("new-token"); expect(token3).toBe("new-token"); expect(mockRefresh).toHaveBeenCalledTimes(1);});Troubleshooting
Seção intitulada “Troubleshooting”Usuário Deslogado Durante Polling
Seção intitulada “Usuário Deslogado Durante Polling”Sintoma: User is randomly logged out while using the app
Causa: Multiple polling requests tentando refresh simultaneamente sem coordenação.
Solução: Certifique-se de que TODO refresh passa por runSingleFlight().
Requests Travando por 3 Segundos
Seção intitulada “Requests Travando por 3 Segundos”Sintoma: RefreshWaitTimeoutError in console
Causa: Refresh está demorando muito (> 3s) ou travado.
Soluções:
- Aumente timeout: modifique
SESSION_REFRESH_LIMITS.REQUEST_WAIT_TIMEOUT_MS - Otimize endpoint no backend
Token Inconsistente Entre Requests
Seção intitulada “Token Inconsistente Entre Requests”Causa: Race condition no save do token.
Solução: Garanta que refresh retorna e salva token dentro do runSingleFlight():
// ✅ CORRETOawait refreshCoordinator.runSingleFlight(async () => { const token = await api.post("/refresh"); localStorage.setItem("token", token); // dentro do flight return token;});
// ❌ ERRADOconst token = await refreshCoordinator.runSingleFlight(async () => { return await api.post("/refresh");});localStorage.setItem("token", token); // fora do flight - race condition!