fix and update

This commit is contained in:
lendry
2026-06-30 09:39:35 +03:00
parent 250976ca08
commit 4e98f6bfab
8 changed files with 192 additions and 110 deletions

View File

@@ -16,6 +16,9 @@ import {
buildAuthDevicePayload, buildAuthDevicePayload,
IdentifyResponse, IdentifyResponse,
getApiErrorMessage, getApiErrorMessage,
invalidateApiGatewayReady,
isApiGatewayReady,
isGatewayUnavailableError,
isPinRequiredError, isPinRequiredError,
OtpSendResponse, OtpSendResponse,
PasswordlessAuthResponse, PasswordlessAuthResponse,
@@ -25,6 +28,7 @@ import {
resetPinRequiredNotification, resetPinRequiredNotification,
scheduleFedcmSessionSync, scheduleFedcmSessionSync,
setPinRequiredHandler, setPinRequiredHandler,
subscribeApiReady,
} 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';
@@ -35,6 +39,7 @@ interface AuthContextValue {
user: PublicUser | null; user: PublicUser | null;
token: string | null; token: string | null;
isLoading: boolean; isLoading: boolean;
isApiReady: boolean;
isPinLocked: boolean; isPinLocked: boolean;
hasStoredSession: boolean; hasStoredSession: boolean;
login: (login: string, password: string) => Promise<AuthTokens>; login: (login: string, password: string) => Promise<AuthTokens>;
@@ -103,6 +108,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [token, setToken] = React.useState<string | null>(null); const [token, setToken] = React.useState<string | null>(null);
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 [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);
@@ -112,6 +118,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const refreshInFlightRef = React.useRef<Promise<void> | null>(null); const refreshInFlightRef = React.useRef<Promise<void> | null>(null);
const initialBootstrapDoneRef = React.useRef(false); const initialBootstrapDoneRef = React.useRef(false);
React.useEffect(() => {
if (isApiGatewayReady()) {
setIsApiReady(true);
}
return subscribeApiReady(() => {
setIsApiReady(true);
});
}, []);
React.useEffect(() => { React.useEffect(() => {
isPinLockedRef.current = isPinLocked; isPinLockedRef.current = isPinLocked;
}, [isPinLocked]); }, [isPinLocked]);
@@ -160,6 +175,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setHasStoredSession(true); setHasStoredSession(true);
clearPinLock(); clearPinLock();
initialBootstrapDoneRef.current = true; initialBootstrapDoneRef.current = true;
setIsApiReady(true);
setIsLoading(false); setIsLoading(false);
if (auth.pinVerified !== false) { if (auth.pinVerified !== false) {
scheduleFedcmSessionSync(auth.accessToken, 2000); scheduleFedcmSessionSync(auth.accessToken, 2000);
@@ -186,7 +202,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return refreshInFlightRef.current; return refreshInFlightRef.current;
} }
const task = (async () => { const task: Promise<void> = (async () => {
const currentToken = window.localStorage.getItem(AUTH_TOKEN_KEY); const currentToken = window.localStorage.getItem(AUTH_TOKEN_KEY);
const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY); const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY);
setHasStoredSession(Boolean(refreshToken)); setHasStoredSession(Boolean(refreshToken));
@@ -200,10 +216,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const isInitialBootstrap = !initialBootstrapDoneRef.current; const isInitialBootstrap = !initialBootstrapDoneRef.current;
if (isInitialBootstrap) { if (isInitialBootstrap) {
setIsLoading(true); setIsLoading(true);
await ensureApiGatewayReady();
} }
try { try {
for (let bootstrapAttempt = 0; bootstrapAttempt < 6; bootstrapAttempt += 1) {
try {
if (isInitialBootstrap) {
await ensureApiGatewayReady();
}
if (currentToken) { if (currentToken) {
setToken(currentToken); setToken(currentToken);
try { try {
@@ -247,6 +268,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
throw new ApiError('Сессия недействительна', 401); throw new ApiError('Сессия недействительна', 401);
} catch (error) { } catch (error) {
if (isGatewayUnavailableError(error) && isInitialBootstrap && bootstrapAttempt + 1 < 6) {
invalidateApiGatewayReady();
await new Promise((resolve) => setTimeout(resolve, 1200));
continue;
}
if (isPinRequiredError(error)) { if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
return; return;
@@ -262,16 +288,22 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const message = getApiErrorMessage(error, 'Не удалось загрузить профиль'); const message = getApiErrorMessage(error, 'Не удалось загрузить профиль');
if (message) showToast(message); if (message) showToast(message);
logout(); logout();
return;
}
}
} finally { } finally {
if (isInitialBootstrap) { if (isInitialBootstrap) {
initialBootstrapDoneRef.current = true; initialBootstrapDoneRef.current = true;
setIsApiReady(isApiGatewayReady());
setIsLoading(false); setIsLoading(false);
} }
refreshInFlightRef.current = null;
} }
})(); })();
refreshInFlightRef.current = task; refreshInFlightRef.current = task;
void task.finally(() => {
refreshInFlightRef.current = null;
});
return task; return task;
}, [activatePinLock, clearPinLock, logout, showToast]); }, [activatePinLock, clearPinLock, logout, showToast]);
@@ -510,7 +542,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
); );
const isBootstrapping = const isBootstrapping =
isLoading && !isPinLocked && Boolean(hasStoredSession || token || user); !isPinLocked &&
Boolean(hasStoredSession || token || user) &&
(isLoading || !isApiReady);
return ( return (
<AuthContext.Provider <AuthContext.Provider
@@ -518,6 +552,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
user, user,
token, token,
isLoading, isLoading,
isApiReady,
isPinLocked, isPinLocked,
hasStoredSession, hasStoredSession,
login, login,

View File

@@ -27,7 +27,7 @@ function formatTime(value: string) {
export function NotificationBell() { export function NotificationBell() {
const router = useRouter(); const router = useRouter();
const { token, isPinLocked, isLoading } = useAuth(); const { token, isPinLocked, isLoading, isApiReady } = useAuth();
const { showToast } = useToast(); const { showToast } = useToast();
const { unreadCount, setUnreadCount, subscribe } = useRealtime(); const { unreadCount, setUnreadCount, subscribe } = useRealtime();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -36,7 +36,7 @@ export function NotificationBell() {
const [respondingId, setRespondingId] = useState<string | null>(null); const [respondingId, setRespondingId] = useState<string | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!token || isPinLocked || isLoading) return; if (!token || isPinLocked || isLoading || !isApiReady) return;
setLoading(true); setLoading(true);
try { try {
const [list, count] = await Promise.all([fetchNotifications(token), fetchUnreadNotificationCount(token)]); const [list, count] = await Promise.all([fetchNotifications(token), fetchUnreadNotificationCount(token)]);
@@ -48,7 +48,7 @@ export function NotificationBell() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [isLoading, isPinLocked, setUnreadCount, showToast, token]); }, [isApiReady, isLoading, isPinLocked, setUnreadCount, showToast, token]);
useEffect(() => { useEffect(() => {
void load(); void load();

View File

@@ -31,7 +31,7 @@ interface RealtimeContextValue {
const RealtimeContext = React.createContext<RealtimeContextValue | null>(null); const RealtimeContext = React.createContext<RealtimeContextValue | null>(null);
export function RealtimeProvider({ children }: { children: React.ReactNode }) { export function RealtimeProvider({ children }: { children: React.ReactNode }) {
const { user, token, isPinLocked, isLoading } = useAuth(); const { user, token, isPinLocked, isLoading, isApiReady } = useAuth();
const [unreadCount, setUnreadCount] = React.useState(0); const [unreadCount, setUnreadCount] = React.useState(0);
const [connected, setConnected] = React.useState(false); const [connected, setConnected] = React.useState(false);
const listenersRef = React.useRef(new Set<Listener>()); const listenersRef = React.useRef(new Set<Listener>());
@@ -48,7 +48,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
if (!user || !token || isPinLocked || isLoading) { if (!user || !token || isPinLocked || isLoading || !isApiReady) {
socketRef.current?.close(); socketRef.current?.close();
socketRef.current = null; socketRef.current = null;
setConnected(false); setConnected(false);
@@ -121,7 +121,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
socketRef.current = null; socketRef.current = null;
setConnected(false); setConnected(false);
}; };
}, [emit, isLoading, isPinLocked, token, user]); }, [emit, isApiReady, isLoading, isPinLocked, token, user]);
return ( return (
<RealtimeContext.Provider value={{ unreadCount, setUnreadCount, subscribe, connected }}> <RealtimeContext.Provider value={{ unreadCount, setUnreadCount, subscribe, connected }}>

View File

@@ -12,12 +12,12 @@ interface AvatarAccessResponse {
} }
export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | undefined, token: string | null) { export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | undefined, token: string | null) {
const { isPinLocked, isLoading } = useAuth(); const { isPinLocked, isLoading, isApiReady } = useAuth();
const [accessUrl, setAccessUrl] = useState<string | null>(null); const [accessUrl, setAccessUrl] = useState<string | null>(null);
const [resolvingAccessUrl, setResolvingAccessUrl] = useState(false); const [resolvingAccessUrl, setResolvingAccessUrl] = useState(false);
const refreshAccessUrl = useCallback(async () => { const refreshAccessUrl = useCallback(async () => {
if (!userId || !token || !hasAvatar || isPinLocked || isLoading) { if (!userId || !token || !hasAvatar || isPinLocked || isLoading || !isApiReady) {
setAccessUrl(null); setAccessUrl(null);
setResolvingAccessUrl(false); setResolvingAccessUrl(false);
return; return;
@@ -31,7 +31,7 @@ export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | un
} finally { } finally {
setResolvingAccessUrl(false); setResolvingAccessUrl(false);
} }
}, [hasAvatar, isLoading, isPinLocked, token, userId]); }, [hasAvatar, isApiReady, isLoading, isPinLocked, token, userId]);
useEffect(() => { useEffect(() => {
void refreshAccessUrl(); void refreshAccessUrl();
@@ -59,7 +59,7 @@ export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | un
isLoading: isLoadingBlob, isLoading: isLoadingBlob,
hasError hasError
} = useResilientBlobUrl(accessUrl, token, { } = useResilientBlobUrl(accessUrl, token, {
enabled: Boolean(hasAvatar && accessUrl && !isPinLocked && !isLoading), enabled: Boolean(hasAvatar && accessUrl && !isPinLocked && !isLoading && isApiReady),
label: `аватар ${userId ?? ''}`.trim() label: `аватар ${userId ?? ''}`.trim()
}); });

View File

@@ -52,7 +52,7 @@ export function useE2EChat(options: {
userId?: string; userId?: string;
token?: string | null; token?: string | null;
}) { }) {
const { isLoading } = useAuth(); const { isLoading, isApiReady } = useAuth();
const isE2E = options.room?.type === 'E2E'; const isE2E = options.room?.type === 'E2E';
const peerUserId = useMemo( const peerUserId = useMemo(
() => resolveChatPeerUserId(options.room, options.userId), () => resolveChatPeerUserId(options.room, options.userId),
@@ -65,7 +65,7 @@ export function useE2EChat(options: {
const [e2eErrors, setE2eErrors] = useState<Record<string, string>>({}); const [e2eErrors, setE2eErrors] = useState<Record<string, string>>({});
useEffect(() => { useEffect(() => {
if (!options.userId || !options.token || isLoading) return; if (!options.userId || !options.token || isLoading || !isApiReady) return;
void (async () => { void (async () => {
try { try {
const { publicKey } = await ensureE2EKeyPair(options.userId!); const { publicKey } = await ensureE2EKeyPair(options.userId!);
@@ -74,7 +74,7 @@ export function useE2EChat(options: {
// фоновая инициализация E2E // фоновая инициализация E2E
} }
})(); })();
}, [isLoading, options.token, options.userId]); }, [isApiReady, isLoading, options.token, options.userId]);
useEffect(() => { useEffect(() => {
if (!isE2E || !peerUserId || !options.token) { if (!isE2E || !peerUserId || !options.token) {

View File

@@ -13,7 +13,7 @@ import {
} from '@/lib/api'; } from '@/lib/api';
export function useSelectedFamily(enabled = true) { export function useSelectedFamily(enabled = true) {
const { user, token, isPinLocked, isLoading } = useAuth(); const { user, token, isPinLocked, isLoading, isApiReady } = useAuth();
const { selectedGroupId, setSelectedGroupId } = useFamilyOverlay(); const { selectedGroupId, setSelectedGroupId } = useFamilyOverlay();
const [groups, setGroups] = useState<FamilyGroup[]>([]); const [groups, setGroups] = useState<FamilyGroup[]>([]);
const [group, setGroup] = useState<FamilyGroup | null>(null); const [group, setGroup] = useState<FamilyGroup | null>(null);
@@ -22,7 +22,7 @@ export function useSelectedFamily(enabled = true) {
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const accessToken = getAccessToken() ?? token?.trim() ?? null; const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!enabled || !user || !accessToken || isPinLocked || isLoading) { if (!enabled || !user || !accessToken || isPinLocked || isLoading || !isApiReady) {
setGroups([]); setGroups([]);
setGroup(null); setGroup(null);
setPresenceMembers([]); setPresenceMembers([]);
@@ -62,7 +62,7 @@ export function useSelectedFamily(enabled = true) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [enabled, isLoading, isPinLocked, selectedGroupId, setSelectedGroupId, token, user]); }, [enabled, isApiReady, isLoading, isPinLocked, selectedGroupId, setSelectedGroupId, token, user]);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
@@ -71,10 +71,10 @@ export function useSelectedFamily(enabled = true) {
useEffect(() => { useEffect(() => {
const accessToken = getAccessToken() ?? token?.trim() ?? null; const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!enabled || !accessToken || isPinLocked || isLoading || !group) return; if (!enabled || !accessToken || isPinLocked || isLoading || !isApiReady || !group) return;
const timer = window.setInterval(() => void refresh(), 25_000); const timer = window.setInterval(() => void refresh(), 25_000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [enabled, group, isLoading, isPinLocked, refresh, token]); }, [enabled, group, isApiReady, isLoading, isPinLocked, refresh, token]);
const presenceByUserId = useMemo( const presenceByUserId = useMemo(
() => new Map(presenceMembers.map((member) => [member.userId, member])), () => new Map(presenceMembers.map((member) => [member.userId, member])),

View File

@@ -7,19 +7,20 @@ 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, isPinLocked, hasStoredSession } = useAuth(); const { user, isLoading, isApiReady, isPinLocked, hasStoredSession } = useAuth();
useEffect(() => { useEffect(() => {
if (isLoading) return; if (isLoading || !isApiReady) 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, isLoading, isPinLocked, pathname, router, user]); }, [hasStoredSession, isApiReady, isLoading, isPinLocked, pathname, router, user]);
return { return {
user, user,
isLoading, isLoading,
isPinLocked, isPinLocked,
isReady: !isLoading && Boolean(user || isPinLocked || hasStoredSession) isApiReady,
isReady: !isLoading && isApiReady && Boolean(user || isPinLocked || hasStoredSession)
}; };
} }

View File

@@ -677,9 +677,11 @@ 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 = 4; const GATEWAY_PROBE_MAX_ATTEMPTS = 48;
const GATEWAY_PROBE_DELAY_MS = 600; const GATEWAY_PROBE_BASE_DELAY_MS = 450;
const API_MAX_CONCURRENT = 8;
const API_READY_EVENT = 'idp-api-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));
@@ -687,6 +689,8 @@ function sleep(ms: number): Promise<void> {
let gatewayReadyResolved = false; let gatewayReadyResolved = false;
let gatewayReadyPromise: Promise<void> | null = null; let gatewayReadyPromise: Promise<void> | null = null;
let activeApiRequests = 0;
const apiRequestWaiters: Array<() => void> = [];
export function resetApiGatewayWarmup() { export function resetApiGatewayWarmup() {
gatewayReadyResolved = false; gatewayReadyResolved = false;
@@ -702,10 +706,41 @@ export function isApiGatewayReady() {
} }
export function markApiGatewayReady() { export function markApiGatewayReady() {
if (gatewayReadyResolved) return;
gatewayReadyResolved = true; gatewayReadyResolved = true;
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(API_READY_EVENT));
}
} }
/** Liveness: nginx → api-gateway HTTP. Не /health/ready — gRPC 503 не должен блокировать SPA и засорять консоль. */ export function subscribeApiReady(listener: () => void): () => void {
if (typeof window === 'undefined') return () => undefined;
if (gatewayReadyResolved) {
listener();
return () => undefined;
}
window.addEventListener(API_READY_EVENT, listener);
return () => window.removeEventListener(API_READY_EVENT, listener);
}
async function acquireApiSlot(): Promise<void> {
if (activeApiRequests < API_MAX_CONCURRENT) {
activeApiRequests += 1;
return;
}
await new Promise<void>((resolve) => {
apiRequestWaiters.push(resolve);
});
activeApiRequests += 1;
}
function releaseApiSlot(): void {
activeApiRequests = Math.max(0, activeApiRequests - 1);
const next = apiRequestWaiters.shift();
if (next) next();
}
/** Liveness: nginx → api-gateway HTTP. */
async function probeGatewayAlive(): Promise<boolean> { async function probeGatewayAlive(): Promise<boolean> {
try { try {
const response = await fetch(`${getApiUrl()}/health`, { const response = await fetch(`${getApiUrl()}/health`, {
@@ -720,18 +755,22 @@ async function probeGatewayAlive(): Promise<boolean> {
} }
async function waitForGatewayAlive(): Promise<void> { async function waitForGatewayAlive(): Promise<void> {
if (gatewayReadyResolved) return;
for (let attempt = 0; attempt < GATEWAY_PROBE_MAX_ATTEMPTS; attempt += 1) { for (let attempt = 0; attempt < GATEWAY_PROBE_MAX_ATTEMPTS; attempt += 1) {
if (await probeGatewayAlive()) { if (await probeGatewayAlive()) {
gatewayReadyResolved = true; markApiGatewayReady();
return; return;
} }
if (attempt + 1 < GATEWAY_PROBE_MAX_ATTEMPTS) { if (attempt + 1 < GATEWAY_PROBE_MAX_ATTEMPTS) {
await sleep(GATEWAY_PROBE_DELAY_MS * (attempt + 1)); await sleep(Math.min(GATEWAY_PROBE_BASE_DELAY_MS + attempt * 280, 2800));
}
} }
} }
/** Один общий прогрев: до 4 проб /health, без бесконечного цикла. */ throw new ApiError('Сервер API временно недоступен. Попробуйте обновить страницу.', 503, 'GATEWAY_UNAVAILABLE');
}
/** Один общий прогрев: все apiFetch ждут успешный /health, без раннего «fail-open». */
export async function ensureApiGatewayReady(force = false): Promise<void> { export async function ensureApiGatewayReady(force = false): Promise<void> {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
if (gatewayReadyResolved && !force) return; if (gatewayReadyResolved && !force) return;
@@ -749,7 +788,7 @@ export async function ensureApiGatewayReady(force = false): Promise<void> {
} }
function gatewayRetryDelay(attempt: number): number { function gatewayRetryDelay(attempt: number): number {
return 700 + attempt * 400; return 800 + attempt * 700;
} }
/** Повтор при кратковременном 502/503/504 (nginx не достучался до api-gateway после перезапуска). */ /** Повтор при кратковременном 502/503/504 (nginx не достучался до api-gateway после перезапуска). */
@@ -783,6 +822,8 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
await ensureApiGatewayReady(); await ensureApiGatewayReady();
} }
await acquireApiSlot();
const resolvedToken = resolveAccessToken(token); const resolvedToken = resolveAccessToken(token);
const method = (options.method ?? 'GET').toUpperCase(); const method = (options.method ?? 'GET').toUpperCase();
const hasBody = options.body !== undefined && options.body !== null; const hasBody = options.body !== undefined && options.body !== null;
@@ -795,6 +836,7 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
}; };
let response: Response; let response: Response;
try {
try { try {
response = await fetchWithGatewayRetry(`${getApiUrl()}${path}`, { response = await fetchWithGatewayRetry(`${getApiUrl()}${path}`, {
...options, ...options,
@@ -818,6 +860,7 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
if (allowRetry && resolvedToken && error.code === 'TOKEN_EXPIRED') { if (allowRetry && resolvedToken && error.code === 'TOKEN_EXPIRED') {
const refreshed = await trySilentTokenRefresh(); const refreshed = await trySilentTokenRefresh();
if (refreshed?.accessToken) { if (refreshed?.accessToken) {
releaseApiSlot();
return apiFetch<T>(path, options, refreshed.accessToken, false); return apiFetch<T>(path, options, refreshed.accessToken, false);
} }
if (refreshed?.requiresPin) { if (refreshed?.requiresPin) {
@@ -830,6 +873,9 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
markApiGatewayReady(); markApiGatewayReady();
return response.json() as Promise<T>; return response.json() as Promise<T>;
} finally {
releaseApiSlot();
}
} }
export { getDeviceFingerprint, buildAuthDevicePayload, resolveDeviceLabel, formatUserAgentLabel } from '@/lib/device-client'; export { getDeviceFingerprint, buildAuthDevicePayload, resolveDeviceLabel, formatUserAgentLabel } from '@/lib/device-client';