fix and update

This commit is contained in:
lendry
2026-07-01 09:15:11 +03:00
parent 7f5bb9838b
commit 2c4b1fcc44
3 changed files with 157 additions and 20 deletions

View File

@@ -12,6 +12,9 @@ import {
approveOAuthAuthorization,
checkOAuthConsent,
fetchOAuthClientPublicInfo,
getApiErrorMessage,
isGatewayUnavailableError,
resetGatewayCircuit,
type OAuthConsentCheckResponse
} from '@/lib/api';
import { deliverOAuthPopupResult, parsePopupOAuthParams, postOneTapResult } from '@/lib/oauth-popup-bridge';
@@ -47,6 +50,10 @@ function OAuthAuthorizeContent() {
const returnUrl = useMemo(() => `/auth/oauth/authorize?${oauthQuery.toString()}`, [oauthQuery]);
useEffect(() => {
resetGatewayCircuit();
}, []);
const clientLabel = clientName ?? consentInfo?.client?.name ?? 'Приложение';
const approve = useCallback(async () => {
@@ -63,7 +70,13 @@ function OAuthAuthorizeContent() {
}
window.location.href = data.redirectUrl;
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка OAuth авторизации');
const message = getApiErrorMessage(err, 'Ошибка OAuth авторизации');
setError(
message ??
(isGatewayUnavailableError(err)
? 'Сервер API временно недоступен. Подождите несколько секунд и нажмите «Разрешить» снова.'
: 'Ошибка OAuth авторизации')
);
autoApproveStartedRef.current = false;
} finally {
setSubmitting(false);
@@ -113,7 +126,13 @@ function OAuthAuthorizeContent() {
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Не удалось проверить согласие');
const message = getApiErrorMessage(err, 'Не удалось проверить согласие');
setError(
message ??
(isGatewayUnavailableError(err)
? 'Сервер API временно недоступен. Подождите несколько секунд — форма обновится автоматически.'
: 'Не удалось проверить согласие')
);
}
} finally {
if (!cancelled) setCheckingConsent(false);

View File

@@ -494,7 +494,7 @@ export async function approveOAuthAuthorization(params: URLSearchParams, token:
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}, token);
}, token, true, { critical: true });
}
export async function checkOAuthConsent(params: URLSearchParams, token: string) {
@@ -503,11 +503,17 @@ export async function checkOAuthConsent(params: URLSearchParams, token: string)
const scope = params.get('scope') ?? 'openid profile';
if (clientId) query.set('client_id', clientId);
query.set('scope', scope);
return apiFetch<OAuthConsentCheckResponse>(`/oauth/consent/check?${query.toString()}`, {}, token);
return apiFetch<OAuthConsentCheckResponse>(`/oauth/consent/check?${query.toString()}`, {}, token, true, { critical: true });
}
export async function fetchOAuthClientPublicInfo(clientId: string) {
return apiFetch<{ clientId: string; name: string }>(`/oauth/clients/${encodeURIComponent(clientId)}/public`);
return apiFetch<{ clientId: string; name: string }>(
`/oauth/clients/${encodeURIComponent(clientId)}/public`,
{},
null,
true,
{ silent: true }
);
}
export async function fetchUserOAuthConsents(userId: string, token: string) {
@@ -552,7 +558,9 @@ export async function syncFedcmSession(accessToken?: string | null) {
method: 'POST',
credentials: 'include'
},
token
token,
true,
{ silent: true }
);
} catch {
// фоновая синхронизация FedCM cookie
@@ -681,6 +689,14 @@ export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | '
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
const GATEWAY_RETRY_ATTEMPTS = 2;
const GATEWAY_CRITICAL_RETRY_ATTEMPTS = 4;
export interface ApiFetchBehavior {
/** Пользовательский сценарий (OAuth, вход) — не блокировать circuit breaker. */
critical?: boolean;
/** Опциональный/фоновый запрос — не открывать circuit breaker при ошибке. */
silent?: boolean;
}
const GATEWAY_STABLE_PROBE_INTERVAL_MS = 800;
const GATEWAY_STABLE_MAX_ROUNDS = 8;
const API_MAX_CONCURRENT = 6;
@@ -710,6 +726,11 @@ export function resetApiGatewayWarmup() {
gatewayCircuitOpenUntil = 0;
}
/** Сбрасывает circuit breaker после кратковременных 502 — для OAuth и других интерактивных экранов. */
export function resetGatewayCircuit(): void {
gatewayCircuitOpenUntil = 0;
}
export function invalidateApiGatewayReady() {
gatewayReadyResolved = false;
gatewayReadyAt = 0;
@@ -719,7 +740,12 @@ export function invalidateApiGatewayReady() {
}
}
function openGatewayCircuit(): void {
function shouldBypassGatewayCircuit(behavior?: ApiFetchBehavior): boolean {
return Boolean(behavior?.critical || behavior?.silent);
}
function openGatewayCircuit(behavior?: ApiFetchBehavior): void {
if (shouldBypassGatewayCircuit(behavior)) return;
gatewayReadyResolved = false;
gatewayReadyAt = 0;
stableGatewayPromise = null;
@@ -729,8 +755,8 @@ function openGatewayCircuit(): void {
}
}
function assertGatewayCircuitClosed(): void {
if (typeof window === 'undefined') return;
function assertGatewayCircuitClosed(behavior?: ApiFetchBehavior): void {
if (typeof window === 'undefined' || shouldBypassGatewayCircuit(behavior)) return;
if (Date.now() < gatewayCircuitOpenUntil) {
throw new ApiError('Сервер API временно недоступен. Запрос отложен, чтобы не перегружать консоль ошибками.', 503, 'GATEWAY_UNAVAILABLE');
}
@@ -863,21 +889,26 @@ function gatewayRetryDelay(attempt: number): number {
}
/** Повтор при кратковременном 502/503/504 без сброса всего UI в bootstrap. */
async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Response> {
async function fetchWithGatewayRetry(
url: string,
init: RequestInit,
behavior?: ApiFetchBehavior
): Promise<Response> {
const maxAttempts = behavior?.critical ? GATEWAY_CRITICAL_RETRY_ATTEMPTS : GATEWAY_RETRY_ATTEMPTS;
let lastNetworkError: unknown;
for (let attempt = 0; attempt < GATEWAY_RETRY_ATTEMPTS; attempt++) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
assertGatewayCircuitClosed();
assertGatewayCircuitClosed(behavior);
const response = await fetch(url, init);
if (response.ok) {
markApiGatewayReady();
}
if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < maxAttempts) {
await sleep(gatewayRetryDelay(attempt));
continue;
}
if (GATEWAY_RETRY_STATUS.has(response.status)) {
openGatewayCircuit();
openGatewayCircuit(behavior);
}
return response;
} catch (error) {
@@ -885,19 +916,25 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
if (isGatewayUnavailableError(error)) {
throw error;
}
if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
if (attempt + 1 < maxAttempts) {
await sleep(gatewayRetryDelay(attempt));
continue;
}
openGatewayCircuit();
openGatewayCircuit(behavior);
throw mapFetchError(error);
}
}
openGatewayCircuit();
openGatewayCircuit(behavior);
throw mapFetchError(lastNetworkError);
}
export async function apiFetch<T>(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise<T> {
export async function apiFetch<T>(
path: string,
options: RequestInit = {},
token?: string | null,
allowRetry = true,
behavior: ApiFetchBehavior = {}
): Promise<T> {
await acquireApiSlot();
const resolvedToken = resolveAccessToken(token);
@@ -924,7 +961,7 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
response = await fetchWithGatewayRetry(`${getApiUrl()}${path}`, {
...options,
headers
});
}, behavior);
} catch (error) {
if (error instanceof ApiError) {
throw error;
@@ -949,7 +986,7 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
const refreshed = await trySilentTokenRefresh();
if (refreshed?.accessToken) {
releaseCurrentApiSlot();
return apiFetch<T>(path, options, refreshed.accessToken, false);
return apiFetch<T>(path, options, refreshed.accessToken, false, behavior);
}
if (refreshed?.requiresPin) {
throw error;