diff --git a/apps/frontend/app/auth/oauth/authorize/page.tsx b/apps/frontend/app/auth/oauth/authorize/page.tsx index 325d6e7..292540f 100644 --- a/apps/frontend/app/auth/oauth/authorize/page.tsx +++ b/apps/frontend/app/auth/oauth/authorize/page.tsx @@ -56,7 +56,7 @@ function OAuthAuthorizeContent() { const clientLabel = clientName ?? consentInfo?.client?.name ?? 'Приложение'; useEffect(() => { - if (!clientId) return; + if (!clientId || isLoading) return; let cancelled = false; void fetchOAuthClientPublicInfo(clientId) .then((info) => { @@ -68,7 +68,7 @@ function OAuthAuthorizeContent() { return () => { cancelled = true; }; - }, [clientId]); + }, [clientId, isLoading]); useEffect(() => { if (!searchParams.has('userId')) return; diff --git a/apps/frontend/components/id/auth-provider.tsx b/apps/frontend/components/id/auth-provider.tsx index a606a8d..f735f33 100644 --- a/apps/frontend/components/id/auth-provider.tsx +++ b/apps/frontend/components/id/auth-provider.tsx @@ -16,17 +16,15 @@ import { buildAuthDevicePayload, IdentifyResponse, getApiErrorMessage, - isApiGatewayReady, isPinRequiredError, OtpSendResponse, PasswordlessAuthResponse, PinVerificationResponse, PublicUser, refreshAuthSession, - resetApiGatewayWarmup, resetPinRequiredNotification, + scheduleFedcmSessionSync, setPinRequiredHandler, - syncFedcmSession } from '@/lib/api'; import { useToast } from './toast-provider'; import { PinLockModal } from './pin-lock-modal'; @@ -94,9 +92,7 @@ function applySessionState( setters.activatePinLock(session.sessionId ?? ''); } else { setters.clearPinLock(); - void ensureApiGatewayReady().then(() => { - void syncFedcmSession(); - }); + scheduleFedcmSessionSync(); } } @@ -163,16 +159,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { cacheUserProfile(auth.user); setHasStoredSession(true); clearPinLock(); - initialBootstrapDoneRef.current = false; - resetApiGatewayWarmup(); - setIsLoading(true); - void ensureApiGatewayReady().finally(() => { - initialBootstrapDoneRef.current = true; - setIsLoading(false); - if (auth.pinVerified !== false) { - void syncFedcmSession(auth.accessToken); - } - }); + initialBootstrapDoneRef.current = true; + setIsLoading(false); + if (auth.pinVerified !== false) { + scheduleFedcmSessionSync(auth.accessToken, 2000); + } }, [clearPinLock] ); @@ -182,7 +173,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { window.localStorage.removeItem(AUTH_REFRESH_KEY); window.localStorage.removeItem(AUTH_SESSION_KEY); window.localStorage.removeItem(AUTH_USER_CACHE_KEY); - resetApiGatewayWarmup(); initialBootstrapDoneRef.current = false; setToken(null); setUser(null); @@ -210,7 +200,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const isInitialBootstrap = !initialBootstrapDoneRef.current; if (isInitialBootstrap) { setIsLoading(true); - resetApiGatewayWarmup(); + await ensureApiGatewayReady(); } try { @@ -273,17 +263,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (message) showToast(message); logout(); } finally { - const hasSession = Boolean( - window.localStorage.getItem(AUTH_TOKEN_KEY) || window.localStorage.getItem(AUTH_REFRESH_KEY) - ); - if (hasSession && isInitialBootstrap) { - const bootstrapDeadline = Date.now() + 120_000; - while (!isApiGatewayReady() && Date.now() < bootstrapDeadline) { - await ensureApiGatewayReady(true); - if (isApiGatewayReady()) break; - await new Promise((resolve) => setTimeout(resolve, 800)); - } - } if (isInitialBootstrap) { initialBootstrapDoneRef.current = true; setIsLoading(false); diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 81174c9..538f185 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -544,6 +544,9 @@ export async function refreshAuthSession(): Promise { export async function syncFedcmSession(accessToken?: string | null) { const token = resolveAccessToken(accessToken); if (typeof window === 'undefined' || !token) return; + if (!isApiGatewayReady()) { + await ensureApiGatewayReady(); + } try { await apiFetch<{ synced: boolean }>( '/fedcm/session/sync', @@ -558,6 +561,25 @@ export async function syncFedcmSession(accessToken?: string | null) { } } +let fedcmSyncTimer: ReturnType | null = null; +let fedcmSyncInFlight = false; + +/** Откладывает FedCM sync после OAuth/bootstrap, чтобы не конкурировать с consent/session. */ +export function scheduleFedcmSessionSync(accessToken?: string | null, delayMs = 1500) { + if (typeof window === 'undefined') return; + if (fedcmSyncTimer) { + clearTimeout(fedcmSyncTimer); + } + fedcmSyncTimer = setTimeout(() => { + fedcmSyncTimer = null; + if (fedcmSyncInFlight) return; + fedcmSyncInFlight = true; + void syncFedcmSession(accessToken).finally(() => { + fedcmSyncInFlight = false; + }); + }, delayMs); +} + async function trySilentTokenRefresh(): Promise { try { const refreshed = await refreshAuthSession(); @@ -654,11 +676,12 @@ export function buildSystemSettingPayload(setting: Pick { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -674,7 +697,6 @@ export function resetApiGatewayWarmup() { export function invalidateApiGatewayReady() { gatewayReadyResolved = false; - gatewayReadyPromise = null; } export function isApiGatewayReady() { @@ -685,11 +707,13 @@ export function markApiGatewayReady() { gatewayReadyResolved = true; } -async function probeGatewayReady(): Promise { +/** Liveness: nginx → api-gateway HTTP. Не /health/ready — gRPC 503 не должен блокировать SPA и засорять консоль. */ +async function probeGatewayAlive(): Promise { try { - const response = await fetch(`${getApiUrl()}/health/ready`, { + const response = await fetch(`${getApiUrl()}/health`, { method: 'GET', - cache: 'no-store' + cache: 'no-store', + signal: AbortSignal.timeout(4500) }); return response.ok; } catch { @@ -697,56 +721,69 @@ async function probeGatewayReady(): Promise { } } -/** Ждёт готовности api-gateway и sso-core (readiness по gRPC, не liveness /health). */ +async function waitForGatewayAlive(): Promise { + await sleep(GATEWAY_WARMUP_INITIAL_DELAY_MS); + const deadline = Date.now() + GATEWAY_WARMUP_MAX_TOTAL_MS; + let consecutiveOk = 0; + let attempt = 0; + + while (Date.now() < deadline) { + if (await probeGatewayAlive()) { + consecutiveOk += 1; + if (consecutiveOk >= GATEWAY_READY_CONSECUTIVE_OK) { + gatewayReadyResolved = true; + return; + } + } else { + consecutiveOk = 0; + } + attempt += 1; + const delay = Math.min(GATEWAY_WARMUP_DELAY_MS * 1.28 ** attempt, GATEWAY_WARMUP_MAX_DELAY_MS); + await sleep(delay); + } + + gatewayReadyResolved = true; +} + +/** Один общий прогрев перед API: без сброса promise и без десятков параллельных проб /health/ready. */ export async function ensureApiGatewayReady(force = false): Promise { if (typeof window === 'undefined') return; if (gatewayReadyResolved && !force) return; - if (gatewayReadyPromise && !force) return gatewayReadyPromise; + if (gatewayReadyPromise) return gatewayReadyPromise; + if (force) { gatewayReadyResolved = false; - gatewayReadyPromise = null; } - gatewayReadyPromise = (async () => { - await sleep(600); - let consecutiveOk = 0; - for (let attempt = 0; attempt < GATEWAY_WARMUP_MAX_ATTEMPTS; attempt++) { - if (await probeGatewayReady()) { - consecutiveOk += 1; - if (consecutiveOk >= GATEWAY_READY_CONSECUTIVE_OK) { - gatewayReadyResolved = true; - return; - } - } else { - consecutiveOk = 0; - } - const delay = Math.min(GATEWAY_WARMUP_DELAY_MS * 1.35 ** attempt, GATEWAY_WARMUP_MAX_DELAY_MS); - await sleep(delay); - } + + gatewayReadyPromise = waitForGatewayAlive().finally(() => { gatewayReadyPromise = null; - })(); + }); return gatewayReadyPromise; } +function gatewayRetryDelay(attempt: number): number { + return Math.min(500 * (attempt + 1), 2800); +} + /** Повтор при кратковременном 502/503/504 (nginx не достучался до api-gateway после перезапуска). */ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise { let lastNetworkError: unknown; for (let attempt = 0; attempt < GATEWAY_RETRY_ATTEMPTS; attempt++) { try { const response = await fetch(url, init); + if (response.ok) { + markApiGatewayReady(); + } if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < GATEWAY_RETRY_ATTEMPTS) { - invalidateApiGatewayReady(); - await ensureApiGatewayReady(true); - await sleep(400 * (attempt + 1)); + await sleep(gatewayRetryDelay(attempt)); continue; } return response; } catch (error) { lastNetworkError = error; if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) { - invalidateApiGatewayReady(); - await ensureApiGatewayReady(true); - await sleep(400 * (attempt + 1)); + await sleep(gatewayRetryDelay(attempt)); continue; } throw mapFetchError(error); @@ -804,13 +841,14 @@ export async function apiFetch(path: string, options: RequestInit = {}, token if (allowRetry && isGatewayUnavailableError(error)) { invalidateApiGatewayReady(); - await ensureApiGatewayReady(true); + await sleep(900); return apiFetch(path, options, token, false); } throw error; } + markApiGatewayReady(); return response.json() as Promise; } diff --git a/apps/frontend/lib/resilient-media-fetch.ts b/apps/frontend/lib/resilient-media-fetch.ts index 37a225b..ca21f2c 100644 --- a/apps/frontend/lib/resilient-media-fetch.ts +++ b/apps/frontend/lib/resilient-media-fetch.ts @@ -1,4 +1,4 @@ -import { ensureApiGatewayReady, invalidateApiGatewayReady } from '@/lib/api'; +import { ensureApiGatewayReady } from '@/lib/api'; const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]); const MEDIA_FETCH_MAX_ATTEMPTS = 8; @@ -32,7 +32,6 @@ export async function fetchMediaBlobWithRetry( }); if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < MEDIA_FETCH_MAX_ATTEMPTS) { - invalidateApiGatewayReady(); await sleep(350 * (attempt + 1)); continue; }