Skip to content

fix(checkout): retry fetchCustomer on passport login event instead of fixed timeout - #1298

Merged
leomp12 merged 4 commits into
masterfrom
fix/checkout-fetch-customer-retry
Jul 31, 2026
Merged

fix(checkout): retry fetchCustomer on passport login event instead of fixed timeout#1298
leomp12 merged 4 commits into
masterfrom
fix/checkout-fetch-customer-retry

Conversation

@vitorrgg

@vitorrgg vitorrgg commented Jul 30, 2026

Copy link
Copy Markdown
Member

Resumo

  • Substitui o retry fixo de 1500ms em account.js por listener do evento login do ecomPassport com fallback de 5s
  • O retry antigo disparava antes de passport/token completar (~311ms de gap); com o evento, o retry roda exatamente quando setSession() é chamado

Contexto

Fix complementar ao ecomplus/cloud-commerce#785, que atrasa o carregamento do app.js até o Firebase renovar o token do usuário. Sem este fix, o fetchCustomer no account.js retornaria 401 e o retry de 1500ms poderia disparar antes de o novo token estar disponível no ecomPassport.

Mudança

@ecomplus/storefront-app/src/store/modules/account.js

Antes:

setTimeout(sendRequest, 1500)

Depois:

ecomPassport.on('login', doRetry)
setTimeout(doRetry, 5000)

Test plan

  • Usuário com token próximo de expirar navega para o checkout
  • fetchCustomer retorna 401 (token expirado)
  • Retry dispara quando o evento login do ecomPassport emite (após renovação do token), não após 1500ms fixos
  • Fallback de 5s funciona caso o evento não emita

🤖 Generated with Claude Code

… fixed timeout

The previous 1500ms fixed retry in fetchCustomer could fire before the
passport token refresh completes (/_api/passport/token takes ~2s in
Firebase-auth stores). This left the customer profile unfetched and the
user stuck on AccountForm.

Now waits for the ecomPassport 'login' event, which fires when vbeta-app.ts
calls setSession() with the refreshed token, then retries immediately.
Falls back to a 5s timeout in case the event does not fire.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vitorrgg

Copy link
Copy Markdown
Member Author

Review: retry de fetchCustomer baseado no evento login

Veredito: a alteração está correta e resolve o problema descrito. Não identifiquei problemas bloqueantes — apenas observações menores (não bloqueantes) listadas abaixo.

Corretude

A lógica ataca a causa raiz corretamente: em vez de apostar que 1500ms são suficientes para o token renovado chegar ao ecomPassport, o retry agora é disparado pelo evento login (emitido pelo setSession() no watcher de isAuthenticated do vbeta-app.ts), com fallback de 5s caso o evento nunca ocorra. Pontos verificados:

  • Guard de execução única: o flag retried garante que doRetry execute só uma vez, mesmo que tanto o evento quanto o timeout disparem — e ambos vão disparar no caminho feliz, já que o setTimeout não é cancelado quando o evento chega primeiro. Sem o guard haveria requisição duplicada; com ele, o segundo disparo é no-op. Correto.
  • Sem leak de listener: ecomPassport.off('login', doRetry) é chamado dentro do próprio doRetry, então o listener é removido tanto no caminho do evento quanto no do timeout. Correto.
  • A Promise sempre é liquidada: o return no branch de retry pula o reject(err) (mesmo comportamento do código anterior), deixando a Promise pendente até a segunda tentativa. Como setTimeout sempre dispara, a segunda tentativa sempre ocorre e resolve ou rejeita. Não há caminho em que a Promise fique pendurada para sempre.
  • Falha na segunda tentativa: com isRetry = true, o catch da retentativa cai no branch de 401 → logout() ou console.error, e rejeita. Sem retry infinito. Correto.
  • return sem valor no lugar de return setTimeout(...): equivalente funcional (o valor de retorno do executor da Promise é descartado), e mais honesto semanticamente.

Edge cases analisados

  1. Evento login nunca emite (ex.: renovação do Firebase falha, ou o setSession já rodou antes de o listener ser registrado — emitters mitt-like não reemitem eventos passados): o fallback de 5s cobre. O custo é latência — ver observação 1 abaixo.
  2. login emitido antes do 401 chegar: como o watcher tem { immediate: true }, é possível que setSession rode antes do registro do listener. Nesse caso o retry só ocorre aos 5s, mas com o token já disponível — funciona, apenas mais lento que os 1500ms antigos.
  3. ecomPassport destruído / componente desmontado: a closure de doRetry mantém referência ao ecomPassport e ao commit por até 5s. Como o ecomPassport é singleton de vida longa no storefront e a action Vuex não está atrelada ao lifecycle de componente, o risco prático é baixo — no pior caso, um commit('setCustomer') tardio em store ainda viva, que é idempotente. Aceitável.
  4. Erro não-401 (rede, 5xx) na primeira tentativa: o branch de retry não filtra por status — qualquer erro com checkAuthorization() verdadeiro entra no caminho de espera. Isso é comportamento pré-existente (o código antigo também não filtrava), mas a mudança de 1,5s → 5s amplifica o efeito: uma falha de rede agora leva ~5s + tempo da segunda requisição para rejeitar, já que nesses casos nenhum login será emitido. Ver observação 2.

Observações menores (não bloqueantes)

  1. Cancelar o timer quando o evento vence a corrida: guardar const timer = setTimeout(doRetry, 5000) e chamar clearTimeout(timer) dentro de doRetry evitaria manter a closure viva à toa e deixaria a intenção mais explícita — hoje isso é papel implícito do flag retried.
  2. Restringir o retry-com-espera a 401: para erros de rede/5xx, esperar o evento login não faz sentido — um retry imediato (ou com backoff curto) seria mais adequado. Algo como if (!isRetry && ecomPassport.checkAuthorization() && err.response?.status === 401) no branch novo, mantendo o comportamento antigo para os demais erros. Como é comportamento herdado, pode ficar para um follow-up.
  3. Fallback de 5s é conservador: se a renovação do token pelo Firebase tipicamente completa em <2s, um fallback de 3s reduziria a latência do pior caso sem comprometer a correção. Vale calibrar com dados reais.

Resumo: a solução elimina a corrida do timeout fixo, não introduz leak nem retry duplicado, e mantém a semântica de rejeição/logout existente. Aprovado com as ressalvas menores acima.

@vitorrgg

Copy link
Copy Markdown
Member Author

Re-review — fetchCustomer retry em account.js

Observações anteriores

  1. clearTimeout na corrida evento × timer — ✅ Resolvido corretamente. retryTimer guarda a referência e doRetry cancela o timer e remove o listener antes de reenviar. A referência a retryTimer dentro de doRetry antes da declaração do const (linha 104 vs. 108) é segura: doRetry só executa assincronamente (timer ou evento), nunca antes da atribuição — sem TDZ na prática.

  2. Retry-com-espera restrito a 401 — ✅ Resolvido. Apenas err.response.status === 401 entra no fluxo evento + fallback; demais erros mantêm o retry simples de 1,5 s. A checagem err.response && também cobre erros de rede sem response.

  3. Fallback 5 s → 3 s — ✅ Aplicado (setTimeout(doRetry, 3000)).

Verificação da API do passport-client

Confirmei que o contrato usado existe: o construtor expõe on/off/once delegando ao EventEmitter3 interno, e setSession emite login somente quando checkLogin() passa. O off(ev, fn) remove por referência de função — como doRetry é uma closure única por tentativa, a remoção funciona e não há vazamento de listener em nenhum dos dois caminhos (ambos passam por doRetry, que faz clearTimeout + off antes do reenvio).

Pontos novos (não bloqueantes)

  • Corrida em que o login chega antes do 401: se o watcher de isAuthenticated chamar setSession enquanto o GET /me.json ainda está em voo, o evento login é emitido antes de o listener ser registrado — o retry então espera os 3 s completos do fallback em vez de disparar imediatamente. Funciona, só adiciona latência nesse cenário.
  • Guard retried redundante: com clearTimeout + off executados dentro de doRetry, o flag é defensivo (dupla proteção). Redundância inofensiva — pode manter.
  • Segundo 401 após retry: com isRetry = true, um novo 401 cai em ecomPassport.logout() + reject. Comportamento terminal correto — evita loop infinito.
  • ecomPassport.once('login', ...) existe e pouparia o off manual, mas como o timeout também invoca doRetry, o padrão on/off atual é equivalente e correto.

Veredito

Aprovado. As três observações foram tratadas corretamente, o contrato de eventos confere com a implementação do passport-client, e não há regressão ou vazamento introduzido pelas mudanças.

@leomp12 leomp12 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A direção está certa e o diagnóstico da causa raiz também: apostar num timeout fixo para cobrir uma renovação de token assíncrona é frágil, e amarrar o retry ao evento é a correção certa. Verifiquei as três coisas que mais me preocupavam e todas estão sólidas — não há loop, não há listener vazando, e a Promise sempre liquida:

  • isRetry é setado antes de registrar o doRetry, então existe no máximo um doRetry por chamada e o teto é de 2 requisições por fetchCustomer. Segunda falha cai em account.js:114logout() + reject.
  • doRetry cancela o próprio timer e faz off antes de reenviar, nos dois caminhos de entrada. Conferi o removeListener do eventemitter3: monta array novo e reatribui, sem pular listener durante o emit.
  • O timer garante que a Promise sempre liquida — o que importa porque triggerLoading é contador (store/index.js:16-24), não booleano; uma Promise pendurada travaria o overlay para sempre.

Também confirmei o contrato: on/off/once existem e delegam ao eventemitter3 (passport-client/src/constructor.js), e setSession emite login em set-session.js:40.

Três pontos estruturais abaixo, nenhum bloqueante.

🟠 Estruturais

1. Hoje o caminho operante é sempre o fallback — o efeito prático é 1,5s → 3s de overlay full-screen

account.js:108 sobe o fallback de 1500ms para 3000ms. Esse tempo é gasto com a Promise pendente, e Checkout.js:134 / Account.js:75 seguram triggerLoading(true) até o .finally — que é o #loading de App.vue:7, position: fixed; z-index: 2000; 100vw × 100vh. Tela cheia travada.

O problema é que, nos dois cenários conhecidos, quem dispara o retry é o timer, não o evento:

  • Expiração de token (o caso deste PR): nada neste repo emite login numa renovação silenciosa. Rastreei os três chamadores de setSession em passport-clientload-stored-session.js (constructor), fetch-login.js e fetch-oauth-profile.js, todos disparados por ação do usuário. Quem emite na renovação é o setSession() do vbeta-app.ts, que só chega quando o cloud-commerce#785 subir.
  • Primeiro acesso via login social: fui no git log e o retry nasceu em ce9e7b345, referenciando a issue #179 ("Error with first access with social login"). Ali o login já disparou antes do fetchCustomer começar — o 401 é a propagação assíncrona do cadastro, e nenhum login novo vem depois. Timer de novo.

Some a fila do client: @ecomplus/client/src/lib/request.js:19 tem delays[API_PASSPORT] = 1070 e requestDelay = delay * queue + concurrentRequests * 2.5 (:145). O retry pode sair mais ~1s depois dos 3s.

Ou seja: até o #785 entrar em produção, este PR entrega só o custo — e mesmo depois, o fluxo do #179 continua no timer. Vale calibrar o 3000 com dado real de latência da renovação em vez de escolher conservador, ou soltar o overlay antes de esperar.

2. O listener é registrado sem checar o estado antes — e login não significa "autorizado"

Dois problemas na mesma linha (account.js:109).

Assina sem checar. O eventemitter3 não reemite eventos passados. Como o 401 é assíncrono, se o setSession rodar enquanto o GET /me.json ainda está em voo, o login passa antes do listener existir e o retry espera os 3s cheios. O repo já resolve esse shape 90 linhas acima, em Checkout.js:213-217:

const tryUpsertCart = () => {
  if (ecomPassport.checkAuthorization()) { upsertCart() }
  else { ecomPassport.once('login', tryUpsertCart) }
}

Checa o estado primeiro, assina só se ainda não estiver pronto.

login é emitido para sessão de qualquer nível. set-session.js:40 emite quando checkLogin() passa, e check-login.js é só Boolean(session.auth && session.auth.id) — sem checar level. Já requestApi exige checkAuthorization(), que pede session.auth.level >= 2 (check-authorization.js).

O caminho concreto: LoginBlock.js:97 chama fetchLogin(email, isAccountConfirm ? docNumber : null), e no primeiro submit isAccountConfirm é falso (LoginBlock.js:76-78 codifica exatamente checkLogin() && !checkAuthorization()). Sessão nível 1 → fetch-login.js chama setSession → emite logindoRetry dispara → request-api.js:37-39 rejeita com new Error('Unauthorized, requires login with OAuth or doc number'), um Error puro sem .response. No catch, isRetry já é true e err.response é undefined, então cai em console.error + reject.

Resultado: a única retentativa é queimada num evento em que a request nem chega a sair, e como os chamadores usam .finally() sem .catch(), sai unhandled rejection. Um if (!ecomPassport.checkAuthorization()) return no topo do doRetry (mantendo timer e listener armados) resolve os dois problemas de uma vez.

3. Latente e pré-existente: o logout() da segunda falha pode apagar uma sessão válida

Não é regressão deste PR — chequei e o caminho do evento é inclusive mais seguro que o master, porque set-session.js emite login depois de self.session = session, então o doRetry disparado pelo evento já usa o token novo. Mas o mecanismo existe e o PR passa bem ao lado dele, então registro:

request-api.js desestrutura session no momento da chamada, e setSession substitui a referência. Se a renovação cair entre o envio do retry e a chegada do 401, o logout() de account.js:115 apaga cookie e localStorage de uma sessão que acabou de ficar válida. E resetAccount não é chamado nesse caminho, então o vuex segue exibindo o cliente com a sessão já apagada.

Guarda barata, se quiser fechar junto: snapshot do access_token antes do sendRequest() e só chamar logout() se ainda for o mesmo.

🟢 Minors

  • account.js:100-103 — a flag retried é redundante: clearTimeout + off já são síncronos e desarmam os dois caminhos antes do sendRequest(). once + off dá o mesmo com 3 linhas a menos, e alinha com Checkout.js:216, que já usa once('login', ...).
  • account.js:99 e :114err.response && err.response.status === 401 duplicado no mesmo catch; uma const no topo deixa o bloco legível como um branch de 3 vias.
  • Escopo dos commits: os dois usam fix(checkout), mas o histórico do arquivo é fix(app/account) / fix(vuex) / fix(app/checkout). O escopo alimenta o CHANGELOG publicado do @ecomplus/storefront-app, então a entrada vai cair sob "checkout" numa mudança de store/modules/account.js.
  • Se houver mais de um fetchCustomer em voo (sessão nível 2 com getCustomer() vazio, ou localStorage bloqueado), cada um arma seu próprio doRetryisRetry é por chamada, o listener é no emitter global. Um login dispara todos. Não é loop, cada um termina, mas amplifica.
  • A descrição do PR ainda descreve o primeiro commit: fala em setTimeout(doRetry, 5000), sem clearTimeout e sem o filtro de 401. O código mergeável é 3000ms e 401-only. O test plan também menciona "fallback de 5s".

Antes do merge

Nada bloqueia. O que eu gostaria de ver resolvido: a guarda de checkAuthorization() dentro do doRetry (item 2) — é uma linha e evita queimar a retentativa à toa; e uma decisão consciente sobre o 3000 (item 1), já que hoje ele é o caminho garantido, não a exceção. O item 3 pode virar follow-up.

Branch está 5 commits atrás do master — rebase antes de mergear. E vale atualizar a descrição do PR para bater com o código.

…login`

`login` is emitted by `setSession` whenever `checkLogin()` passes, which
only requires `session.auth.id` and says nothing about the auth level.
An identified but unauthorized session (level 1) was consuming the single
retry on an event where `requestApi` rejects before the request even
leaves, so the retry was wasted and the promise rejected with a
misleading plain error.

`doRetry` now checks `checkAuthorization()` on the event path and keeps
waiting when the session can't request the API yet. The 3s timer still
runs unconditionally as fallback, so the promise always settles.

Also retry right away when the session was renewed while the request was
in flight — the `login` event is already gone in that case and waiting
for it meant sitting through the full fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@leomp12

leomp12 commented Jul 31, 2026

Copy link
Copy Markdown
Member

Empurrei 812437842 direto neste branch com a correção do item 2 da review — avisa se preferir que eu reverta e você aplique do seu jeito.

O que mudou em @ecomplus/storefront-app/src/store/modules/account.js:

1. doRetry deixou de queimar a retentativa num login que não autoriza. Ele agora recebe isFallback e, no caminho do evento, checa checkAuthorization() antes de gastar a única tentativa — se a sessão ainda não pode chamar a API, ele simplesmente não consome nada e continua esperando. O timer de 3s continua disparando incondicionalmente (doRetry(true)), então a Promise sempre liquida. Isso era o ponto importante: sem o isFallback, uma guarda de checkAuthorization() em doRetry penduraria a Promise para sempre quando a sessão nunca chegasse ao nível 2.

2. Retry imediato quando a sessão renovou com a request em voo. Capturo o access_token no início do sendRequest e comparo no catch. Se mudou, o login já passou (o eventemitter3 não reemite) e assinar o evento significaria esperar o fallback inteiro à toa — então dispara na hora.

3. Limpezas que caíram junto. A flag retried saiu: com clearTimeout + off síncronos no topo do caminho efetivo, ela não salvava nenhum cenário. E err.response && err.response.status === 401 virou uma const isUnauthorized no topo do catch, que era usada duas vezes.

O que eu não mexi: o logout() da segunda falha (item 3 da review) segue como está — é pré-existente e mudar quando o usuário é deslogado merece decisão sua, não minha. O getAccessToken que adicionei já dá a primitiva se você quiser fechar isso depois. E o 3000ms do item 1 continua de pé como questão em aberto.

Verificação. standard@17.1.2 passa limpo no arquivo. Rodei também um harness com um ecomPassport fake exercitando a action de verdade — 12 cenários, todos passando:

  • login nível 1 não consome o retry; um nível 2 depois dispara e resolve
  • sem hang: mesmo depois de um login nível 1 que não vai a lugar nenhum, o fallback liquida
  • token trocado em voo → retry imediato, sem esperar os 3s
  • fallback de 3s dispara, segunda 401 desloga e rejeita
  • teto de 2 requests mantido mesmo com 5 eventos login seguidos
  • erro não-401 não assina o evento e continua retentando em 1500ms
  • listener removido em todos os caminhos

Branch ainda está atrás do master — rebase antes de mergear. E vale atualizar a descrição do PR, que ainda descreve o primeiro commit (5s, sem clearTimeout, sem filtro de 401).

@leomp12 leomp12 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aprovado. Rodei um último passe adversarial no estado final do branch, com execução real da action contra um ecomPassport fake fiel ao contrato do passport-client@1.2.1 e o eventemitter3 instalado — 13 cenários, todos passando, zero unhandled rejections.

Tentei refutar especificamente: hang (incluindo o guard fazendo early return para sempre, getAccessToken lançando com sessão zerada em voo, e throw síncrono no sendRequest), loop/amplificação, TDZ na chamada síncrona de doRetry(), inversão do guard isFallback e regressão contra o master. Nenhuma se sustentou.

O comparativo master × PR fecha bem: renovação por evento resolve em ~1,1s contra ~2,1s, e renovação em voo em ~0,4s contra ~1,9s. O único caminho mais lento é o de 401 sem renovação nenhuma, que vai de ~2,5s para ~4,0s até o logout — é o custo do fallback de 3s que já discutimos.

Fica um follow-up não-bloqueante: ecomPassport.on('login', doRetry) passa para isFallback o que quer que o emitter mande. Hoje set-session.js faz emit('login') sem payload, então chega undefined e o guard funciona. Mas o pacote está em ^1.2.1 com renovate ativo — se uma versão futura passar payload no evento, o guard inverte silenciosamente e o bug volta. Um wrapper nomeado (const onLogin = () => doRetry(), usado no on e no off) blinda isso. Degrada para o comportamento pré-fix, não trava, então não seguro o merge por isso.

@leomp12
leomp12 merged commit 96b9b0b into master Jul 31, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants