fix(checkout): retry fetchCustomer on passport login event instead of fixed timeout - #1298
Conversation
… 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>
Review: retry de
|
Re-review —
|
leomp12
left a comment
There was a problem hiding this comment.
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 odoRetry, então existe no máximo umdoRetrypor chamada e o teto é de 2 requisições porfetchCustomer. Segunda falha cai emaccount.js:114→logout()+reject.doRetrycancela o próprio timer e fazoffantes de reenviar, nos dois caminhos de entrada. Conferi oremoveListenerdo eventemitter3: monta array novo e reatribui, sem pular listener durante oemit.- 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
loginnuma renovação silenciosa. Rastreei os três chamadores desetSessionempassport-client—load-stored-session.js(constructor),fetch-login.jsefetch-oauth-profile.js, todos disparados por ação do usuário. Quem emite na renovação é osetSession()dovbeta-app.ts, que só chega quando o cloud-commerce#785 subir. - Primeiro acesso via login social: fui no
git loge o retry nasceu emce9e7b345, referenciando a issue #179 ("Error with first access with social login"). Ali ologinjá disparou antes dofetchCustomercomeçar — o 401 é a propagação assíncrona do cadastro, e nenhumloginnovo 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 login → doRetry 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 flagretriedé redundante:clearTimeout+offjá são síncronos e desarmam os dois caminhos antes dosendRequest().once+offdá o mesmo com 3 linhas a menos, e alinha comCheckout.js:216, que já usaonce('login', ...).account.js:99e:114—err.response && err.response.status === 401duplicado 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 destore/modules/account.js. - Se houver mais de um
fetchCustomerem voo (sessão nível 2 comgetCustomer()vazio, ou localStorage bloqueado), cada um arma seu própriodoRetry—isRetryé por chamada, o listener é no emitter global. Umlogindispara todos. Não é loop, cada um termina, mas amplifica. - A descrição do PR ainda descreve o primeiro commit: fala em
setTimeout(doRetry, 5000), semclearTimeoute 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>
|
Empurrei O que mudou em 1. 2. Retry imediato quando a sessão renovou com a request em voo. Capturo o 3. Limpezas que caíram junto. A flag O que eu não mexi: o Verificação.
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 |
leomp12
left a comment
There was a problem hiding this comment.
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.
Resumo
account.jspor listener do eventologindoecomPassportcom fallback de 5spassport/tokencompletar (~311ms de gap); com o evento, o retry roda exatamente quandosetSession()é chamadoContexto
Fix complementar ao ecomplus/cloud-commerce#785, que atrasa o carregamento do
app.jsaté o Firebase renovar o token do usuário. Sem este fix, ofetchCustomernoaccount.jsretornaria 401 e o retry de 1500ms poderia disparar antes de o novo token estar disponível noecomPassport.Mudança
@ecomplus/storefront-app/src/store/modules/account.jsAntes:
Depois:
Test plan
fetchCustomerretorna 401 (token expirado)logindo ecomPassport emite (após renovação do token), não após 1500ms fixos🤖 Generated with Claude Code