fix and update

This commit is contained in:
lendry
2026-06-30 11:28:10 +03:00
parent c082b087c5
commit df4bbba133
6 changed files with 139 additions and 69 deletions

View File

@@ -86,10 +86,10 @@ export default function LoginPage() {
const redirectAfterLogin = const redirectAfterLogin =
typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('redirect') : null; typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('redirect') : null;
if (redirectAfterLogin && redirectAfterLogin.startsWith('/')) { if (redirectAfterLogin && redirectAfterLogin.startsWith('/')) {
router.push(redirectAfterLogin); router.replace(redirectAfterLogin);
return; return;
} }
router.push('/'); router.replace('/');
}, [router]); }, [router]);
const { identifyLogin, sendLoginOtp, verifyLoginOtp, loginWithPassword, loginWithLdap, beginTotpLogin, verifyTotpLogin, completePin, applyLoginAuth } = useAuth(); const { identifyLogin, sendLoginOtp, verifyLoginOtp, loginWithPassword, loginWithLdap, beginTotpLogin, verifyTotpLogin, completePin, applyLoginAuth } = useAuth();
@@ -195,11 +195,10 @@ export default function LoginPage() {
function finishQrAuth(auth: AuthTokens) { function finishQrAuth(auth: AuthTokens) {
void (async () => {
const resolved = applyLoginAuth(auth); const resolved = await applyLoginAuth(auth);
finishAuth(resolved.pinVerified, resolved.sessionId);
finishAuth(resolved.pinVerified, resolved.sessionId); })();
} }

View File

@@ -51,7 +51,7 @@ export function AddressQuickSection({
const [editingAddress, setEditingAddress] = useState<UserAddress | null>(null); const [editingAddress, setEditingAddress] = useState<UserAddress | null>(null);
const loadAddresses = useCallback(async () => { const loadAddresses = useCallback(async () => {
if (!user || !token || isPinLocked) return; if (!user || !token || isPinLocked || !isReady) return;
try { try {
const response = await fetchUserAddresses(user.id, token); const response = await fetchUserAddresses(user.id, token);
setAddresses(response.addresses ?? []); setAddresses(response.addresses ?? []);
@@ -59,7 +59,7 @@ export function AddressQuickSection({
const message = getApiErrorMessage(error, 'Не удалось загрузить адреса'); const message = getApiErrorMessage(error, 'Не удалось загрузить адреса');
if (message) showToast(message); if (message) showToast(message);
} }
}, [isPinLocked, showToast, token, user]); }, [isPinLocked, isReady, showToast, token, user]);
useEffect(() => { useEffect(() => {
if (isReady && user && !isPinLocked) void loadAddresses(); if (isReady && user && !isPinLocked) void loadAddresses();

View File

@@ -11,7 +11,6 @@ import {
AUTH_USER_CACHE_KEY, AUTH_USER_CACHE_KEY,
AuthSessionResponse, AuthSessionResponse,
AuthTokens, AuthTokens,
ensureApiGatewayReady,
fetchAuthSession, fetchAuthSession,
buildAuthDevicePayload, buildAuthDevicePayload,
IdentifyResponse, IdentifyResponse,
@@ -29,6 +28,8 @@ import {
scheduleFedcmSessionSync, scheduleFedcmSessionSync,
setPinRequiredHandler, setPinRequiredHandler,
subscribeApiReady, subscribeApiReady,
subscribeApiNotReady,
waitForStableGateway,
} from '@/lib/api'; } from '@/lib/api';
import { useToast } from './toast-provider'; import { useToast } from './toast-provider';
import { PinLockModal } from './pin-lock-modal'; import { PinLockModal } from './pin-lock-modal';
@@ -40,7 +41,9 @@ interface AuthContextValue {
token: string | null; token: string | null;
isLoading: boolean; isLoading: boolean;
isApiReady: boolean; isApiReady: boolean;
/** false первые ~600 ms после bootstrap — блокирует шторм API сразу после логина */ /** true после стабильного /health и успешной проверки сессии */
isSessionReady: boolean;
/** @deprecated используйте isSessionReady */
isContentReady: boolean; isContentReady: boolean;
isPinLocked: boolean; isPinLocked: boolean;
hasStoredSession: boolean; hasStoredSession: boolean;
@@ -53,7 +56,7 @@ interface AuthContextValue {
beginTotpLogin: (recipient: string) => Promise<string>; beginTotpLogin: (recipient: string) => Promise<string>;
verifyTotpLogin: (totpChallengeToken: string, code: string) => Promise<AuthTokens>; verifyTotpLogin: (totpChallengeToken: string, code: string) => Promise<AuthTokens>;
completePin: (sessionId: string, pin: string) => Promise<void>; completePin: (sessionId: string, pin: string) => Promise<void>;
applyLoginAuth: (auth: AuthTokens) => AuthTokens; applyLoginAuth: (auth: AuthTokens) => Promise<AuthTokens>;
register: (data: { displayName: string; login: string; password: string }) => Promise<void>; register: (data: { displayName: string; login: string; password: string }) => Promise<void>;
refreshProfile: () => Promise<void>; refreshProfile: () => Promise<void>;
logout: () => void; logout: () => void;
@@ -111,7 +114,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = React.useState<PublicUser | null>(null); const [user, setUser] = React.useState<PublicUser | null>(null);
const [isLoading, setIsLoading] = React.useState(true); const [isLoading, setIsLoading] = React.useState(true);
const [isApiReady, setIsApiReady] = React.useState(false); const [isApiReady, setIsApiReady] = React.useState(false);
const [isContentReady, setIsContentReady] = React.useState(false); const [isSessionReady, setIsSessionReady] = React.useState(false);
const [isPinLocked, setIsPinLocked] = React.useState(false); const [isPinLocked, setIsPinLocked] = React.useState(false);
const [hasStoredSession, setHasStoredSession] = React.useState(false); const [hasStoredSession, setHasStoredSession] = React.useState(false);
const [lockedSessionId, setLockedSessionId] = React.useState<string | null>(null); const [lockedSessionId, setLockedSessionId] = React.useState<string | null>(null);
@@ -125,9 +128,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (isApiGatewayReady()) { if (isApiGatewayReady()) {
setIsApiReady(true); setIsApiReady(true);
} }
return subscribeApiReady(() => { const onReady = () => setIsApiReady(true);
setIsApiReady(true); const onNotReady = () => {
}); setIsApiReady(false);
setIsSessionReady(false);
};
const unsubReady = subscribeApiReady(onReady);
const unsubNotReady = subscribeApiNotReady(onNotReady);
return () => {
unsubReady();
unsubNotReady();
};
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
@@ -167,8 +178,29 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
}, []); }, []);
const saveSession = React.useCallback( const warmUpAndApplySession = React.useCallback(
(auth: AuthTokens) => { async (accessToken: string) => {
setIsSessionReady(false);
setIsLoading(true);
invalidateApiGatewayReady();
setIsApiReady(false);
try {
await waitForStableGateway(3);
const session = await fetchAuthSession(accessToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
initialBootstrapDoneRef.current = true;
setIsApiReady(true);
setIsSessionReady(true);
} finally {
setIsLoading(false);
}
},
[activatePinLock, clearPinLock]
);
const establishSession = React.useCallback(
async (auth: AuthTokens) => {
window.localStorage.setItem(AUTH_TOKEN_KEY, auth.accessToken); window.localStorage.setItem(AUTH_TOKEN_KEY, auth.accessToken);
window.localStorage.setItem(AUTH_REFRESH_KEY, auth.refreshToken); window.localStorage.setItem(AUTH_REFRESH_KEY, auth.refreshToken);
window.localStorage.setItem(AUTH_SESSION_KEY, auth.sessionId); window.localStorage.setItem(AUTH_SESSION_KEY, auth.sessionId);
@@ -177,15 +209,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
cacheUserProfile(auth.user); cacheUserProfile(auth.user);
setHasStoredSession(true); setHasStoredSession(true);
clearPinLock(); clearPinLock();
initialBootstrapDoneRef.current = true;
setIsApiReady(true); await warmUpAndApplySession(auth.accessToken);
setIsLoading(false);
setIsContentReady(false);
if (auth.pinVerified !== false) { if (auth.pinVerified !== false) {
scheduleFedcmSessionSync(auth.accessToken, 2500); scheduleFedcmSessionSync(auth.accessToken, 2500);
} }
}, },
[clearPinLock] [clearPinLock, warmUpAndApplySession]
); );
const logout = React.useCallback(() => { const logout = React.useCallback(() => {
@@ -197,6 +228,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setToken(null); setToken(null);
setUser(null); setUser(null);
setHasStoredSession(false); setHasStoredSession(false);
setIsSessionReady(false);
setIsApiReady(false);
invalidateApiGatewayReady();
clearPinLock(); clearPinLock();
router.push('/auth/login'); router.push('/auth/login');
}, [clearPinLock, router]); }, [clearPinLock, router]);
@@ -213,6 +247,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (!currentToken && !refreshToken) { if (!currentToken && !refreshToken) {
initialBootstrapDoneRef.current = true; initialBootstrapDoneRef.current = true;
setIsSessionReady(true);
setIsLoading(false); setIsLoading(false);
return; return;
} }
@@ -226,7 +261,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
for (let bootstrapAttempt = 0; bootstrapAttempt < 6; bootstrapAttempt += 1) { for (let bootstrapAttempt = 0; bootstrapAttempt < 6; bootstrapAttempt += 1) {
try { try {
if (isInitialBootstrap) { if (isInitialBootstrap) {
await ensureApiGatewayReady(); await waitForStableGateway(3);
} }
if (currentToken) { if (currentToken) {
@@ -234,6 +269,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try { try {
const session = await fetchAuthSession(currentToken); const session = await fetchAuthSession(currentToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
setIsSessionReady(true);
return; return;
} catch (error) { } catch (error) {
if (isPinRequiredError(error)) { if (isPinRequiredError(error)) {
@@ -266,6 +302,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (refreshed.accessToken) { if (refreshed.accessToken) {
const session = await fetchAuthSession(refreshed.accessToken); const session = await fetchAuthSession(refreshed.accessToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
setIsSessionReady(true);
return; return;
} }
} }
@@ -299,6 +336,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (isInitialBootstrap) { if (isInitialBootstrap) {
initialBootstrapDoneRef.current = true; initialBootstrapDoneRef.current = true;
setIsApiReady(isApiGatewayReady()); setIsApiReady(isApiGatewayReady());
setIsSessionReady(isApiGatewayReady());
setIsLoading(false); setIsLoading(false);
} }
} }
@@ -359,14 +397,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}) })
}); });
if (auth.pinVerified) { if (auth.pinVerified) {
saveSession(auth); await establishSession(auth);
} else { } else {
persistPartialAuth(auth); persistPartialAuth(auth);
setHasStoredSession(true); setHasStoredSession(true);
} }
return auth; return auth;
}, },
[saveSession] [establishSession]
); );
const identifyLogin = React.useCallback(async (loginValue: string) => { const identifyLogin = React.useCallback(async (loginValue: string) => {
@@ -394,14 +432,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}) })
}); });
if (response.auth?.pinVerified) { if (response.auth?.pinVerified) {
saveSession(response.auth); await establishSession(response.auth);
} else if (response.auth) { } else if (response.auth) {
persistPartialAuth(response.auth); persistPartialAuth(response.auth);
setHasStoredSession(true); setHasStoredSession(true);
} }
return response; return response;
}, },
[saveSession] [establishSession]
); );
const loginWithPassword = React.useCallback( const loginWithPassword = React.useCallback(
@@ -415,14 +453,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}) })
}); });
if (auth.pinVerified) { if (auth.pinVerified) {
saveSession(auth); await establishSession(auth);
} else { } else {
persistPartialAuth(auth); persistPartialAuth(auth);
setHasStoredSession(true); setHasStoredSession(true);
} }
return auth; return auth;
}, },
[saveSession] [establishSession]
); );
const loginWithLdap = React.useCallback( const loginWithLdap = React.useCallback(
@@ -436,14 +474,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}) })
}); });
if (auth.pinVerified) { if (auth.pinVerified) {
saveSession(auth); await establishSession(auth);
} else { } else {
persistPartialAuth(auth); persistPartialAuth(auth);
setHasStoredSession(true); setHasStoredSession(true);
} }
return auth; return auth;
}, },
[saveSession] [establishSession]
); );
const beginTotpLogin = React.useCallback(async (recipient: string) => { const beginTotpLogin = React.useCallback(async (recipient: string) => {
@@ -464,20 +502,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
body: JSON.stringify({ totpChallengeToken, code }) body: JSON.stringify({ totpChallengeToken, code })
}); });
if (auth.pinVerified) { if (auth.pinVerified) {
saveSession(auth); await establishSession(auth);
} else { } else {
persistPartialAuth(auth); persistPartialAuth(auth);
setHasStoredSession(true); setHasStoredSession(true);
} }
return auth; return auth;
}, },
[saveSession] [establishSession]
); );
const applyLoginAuth = React.useCallback( const applyLoginAuth = React.useCallback(
(auth: AuthTokens) => { async (auth: AuthTokens) => {
if (auth.pinVerified) { if (auth.pinVerified) {
saveSession(auth); await establishSession(auth);
} else { } else {
persistPartialAuth(auth); persistPartialAuth(auth);
setHasStoredSession(true); setHasStoredSession(true);
@@ -485,7 +523,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
return auth; return auth;
}, },
[activatePinLock, saveSession] [activatePinLock, establishSession]
); );
const completePin = React.useCallback( const completePin = React.useCallback(
@@ -498,14 +536,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
window.localStorage.setItem(AUTH_SESSION_KEY, sessionId); window.localStorage.setItem(AUTH_SESSION_KEY, sessionId);
setToken(response.accessToken); setToken(response.accessToken);
const session = await fetchAuthSession(response.accessToken); await warmUpAndApplySession(response.accessToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
if (pathname.startsWith('/auth/')) { if (pathname.startsWith('/auth/')) {
router.replace('/'); router.replace('/');
} }
}, },
[activatePinLock, clearPinLock, pathname, router] [pathname, router, warmUpAndApplySession]
); );
const handlePinUnlock = React.useCallback( const handlePinUnlock = React.useCallback(
@@ -548,22 +585,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const isBootstrapping = const isBootstrapping =
!isPinLocked && !isPinLocked &&
Boolean(hasStoredSession || token || user) && Boolean(hasStoredSession || token || user) &&
(isLoading || !isApiReady); (isLoading || !isApiReady || !isSessionReady);
React.useEffect(() => {
if (isBootstrapping) {
setIsContentReady(false);
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
if (!cancelled) setIsContentReady(true);
}, 650);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [isBootstrapping]);
return ( return (
<AuthContext.Provider <AuthContext.Provider
@@ -572,7 +594,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
token, token,
isLoading, isLoading,
isApiReady, isApiReady,
isContentReady, isSessionReady,
isContentReady: isSessionReady,
isPinLocked, isPinLocked,
hasStoredSession, hasStoredSession,
login, login,
@@ -590,7 +613,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
logout logout
}} }}
> >
{isBootstrapping || !isContentReady ? <AppBootstrapScreen /> : children} {isBootstrapping ? <AppBootstrapScreen /> : children}
<PinLockModal open={isPinLocked} isSubmitting={pinSubmitting} error={pinError} onSubmit={handlePinUnlock} /> <PinLockModal open={isPinLocked} isSubmitting={pinSubmitting} error={pinError} onSubmit={handlePinUnlock} />
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { apiFetch } from '@/lib/api'; import { apiFetch, ensureApiGatewayReady } from '@/lib/api';
interface PublicSettingsContextValue { interface PublicSettingsContextValue {
projectName: string; projectName: string;
@@ -27,6 +27,7 @@ export function PublicSettingsProvider({ children }: { children: React.ReactNode
const refreshPublicSettings = useCallback(async () => { const refreshPublicSettings = useCallback(async () => {
try { try {
await ensureApiGatewayReady();
const response = await apiFetch<{ settings: Array<{ key: string; value: string }> }>('/settings/public'); const response = await apiFetch<{ settings: Array<{ key: string; value: string }> }>('/settings/public');
const map = Object.fromEntries((response.settings ?? []).map((item) => [item.key, item.value])); const map = Object.fromEntries((response.settings ?? []).map((item) => [item.key, item.value]));
setSettings(map); setSettings(map);

View File

@@ -7,20 +7,21 @@ import { useAuth } from '@/components/id/auth-provider';
export function useRequireAuth() { export function useRequireAuth() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { user, isLoading, isApiReady, isPinLocked, hasStoredSession } = useAuth(); const { user, isLoading, isApiReady, isSessionReady, isPinLocked, hasStoredSession } = useAuth();
useEffect(() => { useEffect(() => {
if (isLoading || !isApiReady) return; if (isLoading || !isApiReady || !isSessionReady) return;
if (user || isPinLocked || hasStoredSession) return; if (user || isPinLocked || hasStoredSession) return;
if (pathname.startsWith('/auth/')) return; if (pathname.startsWith('/auth/')) return;
router.replace('/auth/login'); router.replace('/auth/login');
}, [hasStoredSession, isApiReady, isLoading, isPinLocked, pathname, router, user]); }, [hasStoredSession, isApiReady, isLoading, isPinLocked, isSessionReady, pathname, router, user]);
return { return {
user, user,
isLoading, isLoading,
isPinLocked, isPinLocked,
isApiReady, isApiReady,
isReady: !isLoading && isApiReady && Boolean(user || isPinLocked || hasStoredSession) isSessionReady,
isReady: !isLoading && isApiReady && isSessionReady && Boolean(user || isPinLocked || hasStoredSession)
}; };
} }

View File

@@ -677,13 +677,15 @@ export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | '
} }
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]); const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
const GATEWAY_RETRY_ATTEMPTS = 2; const GATEWAY_RETRY_ATTEMPTS = 4;
const GATEWAY_PROBE_MAX_ATTEMPTS = 48; const GATEWAY_PROBE_MAX_ATTEMPTS = 48;
const GATEWAY_PROBE_BASE_DELAY_MS = 450; const GATEWAY_PROBE_BASE_DELAY_MS = 450;
const API_MAX_CONCURRENT = 8; const GATEWAY_STABLE_PROBES = 3;
const API_BURST_MAX_CONCURRENT = 3; const API_MAX_CONCURRENT = 6;
const API_BURST_WINDOW_MS = 3000; const API_BURST_MAX_CONCURRENT = 2;
const API_BURST_WINDOW_MS = 5000;
const API_READY_EVENT = 'idp-api-ready'; const API_READY_EVENT = 'idp-api-ready';
const API_NOT_READY_EVENT = 'idp-api-not-ready';
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
@@ -703,6 +705,9 @@ export function resetApiGatewayWarmup() {
export function invalidateApiGatewayReady() { export function invalidateApiGatewayReady() {
gatewayReadyResolved = false; gatewayReadyResolved = false;
gatewayReadyAt = 0; gatewayReadyAt = 0;
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(API_NOT_READY_EVENT));
}
} }
export function isApiGatewayReady() { export function isApiGatewayReady() {
@@ -728,6 +733,37 @@ export function subscribeApiReady(listener: () => void): () => void {
return () => window.removeEventListener(API_READY_EVENT, listener); return () => window.removeEventListener(API_READY_EVENT, listener);
} }
export function subscribeApiNotReady(listener: () => void): () => void {
if (typeof window === 'undefined') return () => undefined;
window.addEventListener(API_NOT_READY_EVENT, listener);
return () => window.removeEventListener(API_NOT_READY_EVENT, listener);
}
/** Несколько подряд успешных /health через nginx — защита от «один OK, потом 502». */
export async function waitForStableGateway(stableProbes = GATEWAY_STABLE_PROBES): Promise<void> {
if (typeof window === 'undefined') return;
gatewayReadyResolved = false;
gatewayReadyAt = 0;
let streak = 0;
for (let attempt = 0; attempt < 40 && streak < stableProbes; attempt += 1) {
if (await probeGatewayAlive()) {
streak += 1;
if (streak >= stableProbes) {
markApiGatewayReady();
return;
}
await sleep(400);
} else {
streak = 0;
await sleep(Math.min(500 + attempt * 220, 2200));
}
}
throw new ApiError('Сервер API временно недоступен. Попробуйте обновить страницу.', 503, 'GATEWAY_UNAVAILABLE');
}
async function acquireApiSlot(): Promise<void> { async function acquireApiSlot(): Promise<void> {
const inBurst = gatewayReadyAt > 0 && Date.now() - gatewayReadyAt < API_BURST_WINDOW_MS; const inBurst = gatewayReadyAt > 0 && Date.now() - gatewayReadyAt < API_BURST_WINDOW_MS;
const limit = inBurst ? API_BURST_MAX_CONCURRENT : API_MAX_CONCURRENT; const limit = inBurst ? API_BURST_MAX_CONCURRENT : API_MAX_CONCURRENT;
@@ -807,14 +843,24 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
markApiGatewayReady(); markApiGatewayReady();
} }
if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < GATEWAY_RETRY_ATTEMPTS) { if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
await sleep(gatewayRetryDelay(attempt)); invalidateApiGatewayReady();
try {
await waitForStableGateway(2);
} catch {
await sleep(gatewayRetryDelay(attempt));
}
continue; continue;
} }
return response; return response;
} catch (error) { } catch (error) {
lastNetworkError = error; lastNetworkError = error;
if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) { if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
await sleep(gatewayRetryDelay(attempt)); invalidateApiGatewayReady();
try {
await waitForStableGateway(2);
} catch {
await sleep(gatewayRetryDelay(attempt));
}
continue; continue;
} }
throw mapFetchError(error); throw mapFetchError(error);