mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat(cabinet): recoverable "service unavailable" screen when the backend is unreachable
When the backend was down the cabinet got stuck on a blank loader: the bootstrap token refresh used bare axios with no timeout (hung forever), and every interceptor guard required an HTTP response — so a transport-level failure had zero handling and no error UI ever appeared. Add a full-screen ServiceUnavailableScreen (extends the existing blocking-store pattern) shown whenever the backend is unreachable, that auto-recovers when it returns: - New 'backend_unavailable' blocking type + BlockingOverlay branch; mirrors MaintenanceScreen, CloudWarningIcon, i18n in ru/en/zh/fa. - api/health.ts: pingBackend() probes the root /health/unified (bypasses the /api baseURL + interceptor); URL derived from the origin for remote/sub-path deploys; 502/503/504 count as down. reportPossibleBackendDown() confirms an outage with a liveness probe before flipping, so a one-off blip never blanks a loaded app. - Detected from the response interceptor (no-response) and the bootstrap refresh path; doRefresh gets a timeout so it can no longer hang. - A transport failure during refresh now PRESERVES the session (distinguished from a rejected token via lastFailureWasTransport) instead of logging the user out, so recovery actually resumes. - Recovery lifts the overlay + refetches for an already-loaded session, and only hard-reloads when the initial bootstrap never reached the backend — no lost form state. Manual retry + 5s auto-poll. Dev proxy for /health.
This commit is contained in:
@@ -28,6 +28,7 @@ import {
|
||||
ChannelSubscriptionScreen,
|
||||
BlacklistedScreen,
|
||||
AccountDeletedScreen,
|
||||
ServiceUnavailableScreen,
|
||||
} from './components/blocking';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { PermissionRoute } from '@/components/auth/PermissionRoute';
|
||||
@@ -232,6 +233,10 @@ function BlockingOverlay() {
|
||||
return <AccountDeletedScreen />;
|
||||
}
|
||||
|
||||
if (blockingType === 'backend_unavailable') {
|
||||
return <ServiceUnavailableScreen />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
safeRedirectToLogin,
|
||||
} from '../utils/token';
|
||||
import { useBlockingStore } from '../store/blocking';
|
||||
import { reportPossibleBackendDown, markBackendReached } from './health';
|
||||
import { API } from '../config/constants';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -94,6 +95,12 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
|
||||
const newToken = await tokenRefreshManager.refreshAccessToken();
|
||||
if (newToken) {
|
||||
token = newToken;
|
||||
} else if (tokenRefreshManager.lastFailureWasTransport) {
|
||||
// Backend unreachable (not a rejected token): keep the session intact so
|
||||
// the ServiceUnavailableScreen can auto-recover once the backend returns,
|
||||
// instead of wiping tokens and stranding the user on /login. Let the
|
||||
// request go out with the stale token; it will fail and be handled.
|
||||
return config;
|
||||
} else {
|
||||
tokenStorage.clearTokens();
|
||||
safeRedirectToLogin();
|
||||
@@ -195,10 +202,28 @@ export function isAccountDeletedError(
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(response) => {
|
||||
// First successful response means the app reached the backend at least once
|
||||
// (it bootstrapped). Recovery from a later outage can then just lift the
|
||||
// overlay instead of hard-reloading and losing unsaved UI state.
|
||||
markBackendReached();
|
||||
return response;
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Transport-level failure: no HTTP response at all (backend unreachable, DNS
|
||||
// failure, connection refused, timeout). All the coded guards below need
|
||||
// `error.response`, so this case had zero handling and produced a blank
|
||||
// screen during bootstrap. Confirm the outage with a liveness probe (so a
|
||||
// one-off blip doesn't blank an already-loaded app) and, if confirmed, flip
|
||||
// the full-screen ServiceUnavailableScreen. Fire-and-forget — the original
|
||||
// request still rejects now. Axios cancellations are not outages.
|
||||
if (!error.response && error.code !== 'ERR_CANCELED') {
|
||||
void reportPossibleBackendDown();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (isMaintenanceError(error)) {
|
||||
const detail = (error.response?.data as { detail: MaintenanceError }).detail;
|
||||
useBlockingStore.getState().setMaintenance({
|
||||
@@ -256,6 +281,9 @@ apiClient.interceptors.response.use(
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
}
|
||||
return apiClient(originalRequest);
|
||||
} else if (tokenRefreshManager.lastFailureWasTransport) {
|
||||
// Backend died between the 401 and the refresh: keep the session so the
|
||||
// ServiceUnavailableScreen can recover, rather than logging the user out.
|
||||
} else {
|
||||
tokenStorage.clearTokens();
|
||||
safeRedirectToLogin();
|
||||
|
||||
99
src/api/health.ts
Normal file
99
src/api/health.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import axios from 'axios';
|
||||
import { useBlockingStore } from '../store/blocking';
|
||||
|
||||
/**
|
||||
* Backend liveness probe + "service unavailable" detection.
|
||||
*
|
||||
* The bot backend serves an unauthenticated liveness endpoint at the host ROOT
|
||||
* (`/health/unified` when the Web API is enabled, `/health` otherwise) — NOT
|
||||
* under the apiClient baseURL (`/api`). So we probe it with a BARE axios call at
|
||||
* a root-relative URL, deliberately bypassing both the baseURL and the apiClient
|
||||
* response interceptor (a probe must never itself touch blocking state).
|
||||
*/
|
||||
function resolveHealthUrl(): string {
|
||||
const override = import.meta.env.VITE_HEALTH_URL;
|
||||
if (override) return override;
|
||||
const apiUrl = String(import.meta.env.VITE_API_URL || '/api').trim();
|
||||
// For an absolute API base (remote / sub-path deployments like
|
||||
// `https://api.bot.com/cabinet`) the health route still lives at the host
|
||||
// ROOT, so derive the origin — NOT just strip `/api`. Mirrors the URL parsing
|
||||
// in WebSocketProvider. For a relative base (`/api`, `/cabinet`) health is
|
||||
// same-origin at the site root.
|
||||
if (apiUrl.startsWith('http://') || apiUrl.startsWith('https://')) {
|
||||
try {
|
||||
return `${new URL(apiUrl).origin}/health/unified`;
|
||||
} catch {
|
||||
// malformed URL — fall through to the root-relative path
|
||||
}
|
||||
}
|
||||
return '/health/unified';
|
||||
}
|
||||
|
||||
export const HEALTH_URL = resolveHealthUrl();
|
||||
|
||||
// Statuses a reverse proxy returns when it is up but its API upstream is down.
|
||||
// These must count as NOT reachable, otherwise the recovery poll would reload
|
||||
// the app straight back into a still-broken backend.
|
||||
const GATEWAY_DOWN_STATUSES = new Set([502, 503, 504]);
|
||||
|
||||
/**
|
||||
* Resolves true when the backend is actually serving — i.e. it returns an HTTP
|
||||
* response that is NOT a gateway-down status. A 404 still counts as up (a
|
||||
* Web-API-disabled deployment serves `/health`, not `/health/unified`, so
|
||||
* probing the latter 404s while the server is fine). A 502/503/504 means a front
|
||||
* proxy is up but the API upstream is dead, so it counts as NOT reachable.
|
||||
* Resolves false on a transport-level failure with no response.
|
||||
*/
|
||||
export async function pingBackend(): Promise<boolean> {
|
||||
try {
|
||||
await axios.get(HEALTH_URL, { timeout: 5000 });
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
return !GATEWAY_DOWN_STATUSES.has(err.response.status);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let everReachedBackend = false;
|
||||
|
||||
/** Called on the first successful API response: the app reached the backend at
|
||||
* least once, so a later outage struck an already-loaded session. */
|
||||
export function markBackendReached(): void {
|
||||
everReachedBackend = true;
|
||||
}
|
||||
|
||||
/** True once the app has had at least one successful backend response. Lets the
|
||||
* ServiceUnavailableScreen recover by lifting the overlay (state preserved)
|
||||
* rather than hard-reloading, which is only needed when the INITIAL bootstrap
|
||||
* never reached the backend. */
|
||||
export function hasEverReachedBackend(): boolean {
|
||||
return everReachedBackend;
|
||||
}
|
||||
|
||||
let confirmInFlight = false;
|
||||
|
||||
/**
|
||||
* Called from the places that see a transport-level (no-response) failure — the
|
||||
* apiClient interceptor and the bootstrap token refresh. Rather than blanking
|
||||
* the whole app on a single one-off network blip, it CONFIRMS the outage with a
|
||||
* liveness probe first and only then flips the global `backend_unavailable`
|
||||
* state that renders the full-screen ServiceUnavailableScreen. Fire-and-forget:
|
||||
* callers still reject/handle their own request immediately. Guarded so a burst
|
||||
* of failing requests triggers at most one probe, and skipped once the screen is
|
||||
* already shown.
|
||||
*/
|
||||
export async function reportPossibleBackendDown(): Promise<void> {
|
||||
if (confirmInFlight) return;
|
||||
if (useBlockingStore.getState().blockingType === 'backend_unavailable') return;
|
||||
confirmInFlight = true;
|
||||
try {
|
||||
const reachable = await pingBackend();
|
||||
if (!reachable) {
|
||||
useBlockingStore.getState().setBackendUnavailable();
|
||||
}
|
||||
} finally {
|
||||
confirmInFlight = false;
|
||||
}
|
||||
}
|
||||
156
src/components/blocking/ServiceUnavailableScreen.tsx
Normal file
156
src/components/blocking/ServiceUnavailableScreen.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useBlockingStore } from '../../store/blocking';
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap';
|
||||
import { pingBackend, hasEverReachedBackend } from '../../api/health';
|
||||
import { CloudWarningIcon, RestartIcon } from '@/components/icons';
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
/**
|
||||
* Full-screen state shown when the backend is unreachable (transport-level
|
||||
* failure), replacing the blank loader the app used to get stuck on. Polls the
|
||||
* liveness endpoint and, once the backend answers again, reloads to re-bootstrap
|
||||
* cleanly from whatever state the failed boot left behind. A manual retry button
|
||||
* lets the user force an immediate check.
|
||||
*/
|
||||
export default function ServiceUnavailableScreen() {
|
||||
const { t } = useTranslation();
|
||||
const clearBlocking = useBlockingStore((state) => state.clearBlocking);
|
||||
const queryClient = useQueryClient();
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const isCheckingRef = useRef(false);
|
||||
|
||||
const recover = useCallback(() => {
|
||||
clearBlocking();
|
||||
if (hasEverReachedBackend()) {
|
||||
// The app was already loaded and merely covered by this overlay — its
|
||||
// routes/forms are still mounted with their state. Lift the overlay and
|
||||
// refetch instead of a hard reload that would discard unsaved input.
|
||||
void queryClient.invalidateQueries();
|
||||
} else {
|
||||
// The initial bootstrap never reached the backend (blank loader) — reload
|
||||
// to re-bootstrap cleanly now that it is back.
|
||||
window.location.reload();
|
||||
}
|
||||
}, [clearBlocking, queryClient]);
|
||||
|
||||
// Manual retry: immediate probe with a visible checking state.
|
||||
const handleRetry = useCallback(async () => {
|
||||
if (isCheckingRef.current) return;
|
||||
isCheckingRef.current = true;
|
||||
setIsChecking(true);
|
||||
try {
|
||||
if (await pingBackend()) {
|
||||
recover();
|
||||
}
|
||||
} finally {
|
||||
isCheckingRef.current = false;
|
||||
setIsChecking(false);
|
||||
}
|
||||
}, [recover]);
|
||||
|
||||
// Auto-recovery: probe immediately on mount, then every POLL_INTERVAL_MS.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
if (await pingBackend()) {
|
||||
if (!cancelled) recover();
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const id = setInterval(tick, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [recover]);
|
||||
|
||||
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={screenRef}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="service-unavailable-title"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
|
||||
>
|
||||
<div className="w-full max-w-md text-center">
|
||||
{/* Icon */}
|
||||
<div className="mb-8">
|
||||
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
|
||||
<CloudWarningIcon className="h-12 w-12 text-warning-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 id="service-unavailable-title" className="mb-4 text-2xl font-bold text-white">
|
||||
{t('blocking.serviceUnavailable.title')}
|
||||
</h1>
|
||||
|
||||
{/* Message */}
|
||||
<p className="mb-6 text-lg text-dark-400">{t('blocking.serviceUnavailable.description')}</p>
|
||||
|
||||
{/* Retry button */}
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
disabled={isChecking}
|
||||
className="flex w-full items-center justify-center gap-3 rounded-xl bg-dark-800 px-6 py-4 font-semibold text-white transition-all duration-200 hover:bg-dark-700 disabled:bg-dark-800 disabled:opacity-60"
|
||||
>
|
||||
{isChecking ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{t('blocking.serviceUnavailable.checking')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RestartIcon className="h-5 w-5" />
|
||||
{t('blocking.serviceUnavailable.retry')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Decorative dots */}
|
||||
<div className="mt-8 flex items-center justify-center gap-2">
|
||||
<div
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-warning-500"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<div
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-warning-500"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
<div
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-warning-500"
|
||||
style={{ animationDelay: '600ms' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-dark-500">{t('blocking.serviceUnavailable.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export { default as MaintenanceScreen } from './MaintenanceScreen';
|
||||
export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen';
|
||||
export { default as BlacklistedScreen } from './BlacklistedScreen';
|
||||
export { default as AccountDeletedScreen } from './AccountDeletedScreen';
|
||||
export { default as ServiceUnavailableScreen } from './ServiceUnavailableScreen';
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
PiFlag,
|
||||
PiArrowCounterClockwise,
|
||||
PiArrowClockwise,
|
||||
PiCloudWarning,
|
||||
PiRocket,
|
||||
PiFloppyDisk,
|
||||
PiPaperPlaneTilt,
|
||||
@@ -361,6 +362,10 @@ export const RestartIcon = ({ className }: IconProps) => (
|
||||
<PiArrowClockwise className={cn('h-5 w-5', className)} />
|
||||
);
|
||||
|
||||
export const CloudWarningIcon = ({ className }: IconProps) => (
|
||||
<PiCloudWarning className={cn('h-5 w-5', className)} />
|
||||
);
|
||||
|
||||
export const RocketIcon = ({ className }: IconProps) => (
|
||||
<PiRocket className={cn('h-5 w-5', className)} />
|
||||
);
|
||||
|
||||
@@ -4810,6 +4810,13 @@
|
||||
"openBot": "Open the bot",
|
||||
"retry": "I pressed /start, retry",
|
||||
"hint": "After running /start, return here and tap «Retry»."
|
||||
},
|
||||
"serviceUnavailable": {
|
||||
"title": "Service unavailable",
|
||||
"description": "We can't reach the service right now. It may be undergoing maintenance or there's a temporary connection issue.",
|
||||
"retry": "Try again",
|
||||
"checking": "Checking connection...",
|
||||
"hint": "This page will refresh automatically as soon as the service is back."
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
|
||||
@@ -4470,6 +4470,13 @@
|
||||
"openBot": "باز کردن ربات",
|
||||
"retry": "من /start را زدم، دوباره امتحان کن",
|
||||
"hint": "پس از اجرای /start، به اینجا بازگردید و «دوباره امتحان کنید» را بزنید."
|
||||
},
|
||||
"serviceUnavailable": {
|
||||
"title": "سرویس در دسترس نیست",
|
||||
"description": "در حال حاضر امکان اتصال به سرویس وجود ندارد. ممکن است در حال تعمیر باشد یا مشکل موقتی در اتصال وجود داشته باشد.",
|
||||
"retry": "تلاش مجدد",
|
||||
"checking": "در حال بررسی اتصال...",
|
||||
"hint": "بهمحض در دسترس قرار گرفتن سرویس، این صفحه بهطور خودکار بازخوانی میشود."
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
|
||||
@@ -5365,6 +5365,13 @@
|
||||
"openBot": "Открыть бота",
|
||||
"retry": "Я нажал /start, попробовать снова",
|
||||
"hint": "После /start вернитесь сюда и нажмите «Попробовать снова»."
|
||||
},
|
||||
"serviceUnavailable": {
|
||||
"title": "Сервис недоступен",
|
||||
"description": "Не удаётся подключиться к сервису. Возможно, идут технические работы или временные неполадки со связью.",
|
||||
"retry": "Повторить попытку",
|
||||
"checking": "Проверяем соединение...",
|
||||
"hint": "Страница обновится автоматически, как только сервис снова станет доступен."
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
|
||||
@@ -4351,6 +4351,13 @@
|
||||
"openBot": "打开机器人",
|
||||
"retry": "我已按 /start,重试",
|
||||
"hint": "运行 /start 后,返回此处并点击「重试」。"
|
||||
},
|
||||
"serviceUnavailable": {
|
||||
"title": "服务不可用",
|
||||
"description": "暂时无法连接到服务。可能正在进行维护,或存在临时的网络问题。",
|
||||
"retry": "重试",
|
||||
"checking": "正在检查连接...",
|
||||
"hint": "服务恢复后,本页面将自动刷新。"
|
||||
}
|
||||
},
|
||||
"banSystem": {
|
||||
|
||||
@@ -217,6 +217,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
const user = await authApi.getMe();
|
||||
await get().checkAdminStatus();
|
||||
applySession(newToken, refreshToken, user);
|
||||
} else if (tokenRefreshManager.lastFailureWasTransport) {
|
||||
// Backend unreachable, not a rejected token — keep the session
|
||||
// (don't wipe tokens) so the ServiceUnavailableScreen can resume
|
||||
// once the backend returns. Just stop the bootstrap loader.
|
||||
set({ isLoading: false });
|
||||
} else {
|
||||
clearSession();
|
||||
}
|
||||
@@ -237,6 +242,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
} catch {
|
||||
clearSession();
|
||||
}
|
||||
} else if (tokenRefreshManager.lastFailureWasTransport) {
|
||||
// Backend unreachable, not a rejected token — keep the session
|
||||
// (don't wipe tokens) so the ServiceUnavailableScreen can resume
|
||||
// once the backend returns. Just stop the bootstrap loader.
|
||||
set({ isLoading: false });
|
||||
} else {
|
||||
clearSession();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export type BlockingType =
|
||||
| 'channel_subscription'
|
||||
| 'blacklisted'
|
||||
| 'account_deleted'
|
||||
| 'backend_unavailable'
|
||||
| null;
|
||||
|
||||
interface MaintenanceInfo {
|
||||
@@ -54,6 +55,9 @@ interface BlockingState {
|
||||
setChannelSubscription: (info: ChannelSubscriptionInfo) => void;
|
||||
setBlacklisted: (info: BlacklistedInfo) => void;
|
||||
setAccountDeleted: (info: AccountDeletedInfo) => void;
|
||||
/** Backend is unreachable (transport-level failure). Renders the full-screen
|
||||
* ServiceUnavailableScreen. No info payload — the screen shows static copy. */
|
||||
setBackendUnavailable: () => void;
|
||||
clearBlocking: () => void;
|
||||
}
|
||||
|
||||
@@ -100,6 +104,15 @@ export const useBlockingStore = create<BlockingState>((set) => ({
|
||||
blacklistedInfo: null,
|
||||
}),
|
||||
|
||||
setBackendUnavailable: () =>
|
||||
set({
|
||||
blockingType: 'backend_unavailable',
|
||||
maintenanceInfo: null,
|
||||
channelInfo: null,
|
||||
blacklistedInfo: null,
|
||||
accountDeletedInfo: null,
|
||||
}),
|
||||
|
||||
clearBlocking: () =>
|
||||
set({
|
||||
blockingType: null,
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
deleteCloudStorageItem,
|
||||
} from '@telegram-apps/sdk-react';
|
||||
import { isInTelegramWebApp } from '../hooks/useTelegramSDK';
|
||||
import { API } from '../config/constants';
|
||||
import { reportPossibleBackendDown } from '../api/health';
|
||||
|
||||
const TOKEN_KEYS = {
|
||||
ACCESS: 'access_token',
|
||||
@@ -205,6 +207,17 @@ class TokenRefreshManager {
|
||||
private subscribers: ((token: string | null) => void)[] = [];
|
||||
private refreshEndpoint = '/api/cabinet/auth/refresh';
|
||||
|
||||
/**
|
||||
* True when the most recent refresh failed at the TRANSPORT level (backend
|
||||
* unreachable / timeout) rather than because the refresh token was rejected.
|
||||
* Callers read this right after a null `refreshAccessToken()` to decide whether
|
||||
* to destroy the session (rejected token) or KEEP it so the recoverable
|
||||
* ServiceUnavailableScreen can resume once the backend returns. Synchronously
|
||||
* accurate: it is set inside doRefresh before the shared promise resolves, so
|
||||
* every awaiter (including deduped concurrent callers) reads a consistent value.
|
||||
*/
|
||||
lastFailureWasTransport = false;
|
||||
|
||||
setRefreshEndpoint(endpoint: string): void {
|
||||
this.refreshEndpoint = endpoint;
|
||||
}
|
||||
@@ -214,6 +227,8 @@ class TokenRefreshManager {
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
this.lastFailureWasTransport = false;
|
||||
|
||||
const refreshToken = tokenStorage.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
@@ -238,7 +253,10 @@ class TokenRefreshManager {
|
||||
const response = await axios.post<{ access_token?: string }>(
|
||||
this.refreshEndpoint,
|
||||
{ refresh_token: refreshToken },
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
// Without an explicit timeout this inherits axios's default of 0 (no
|
||||
// timeout): on the persisted-token bootstrap path an unreachable backend
|
||||
// would hang the refresh indefinitely, pinning the app on a blank loader.
|
||||
{ headers: { 'Content-Type': 'application/json' }, timeout: API.TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const newAccessToken = response.data.access_token;
|
||||
@@ -249,7 +267,16 @@ class TokenRefreshManager {
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// This bare-axios call bypasses the apiClient interceptor, so a dead
|
||||
// backend on the refresh-only bootstrap path would otherwise be swallowed
|
||||
// here and silently fall through to a login redirect. Distinguish a
|
||||
// transport-level failure (no response) from an invalid/expired token (a
|
||||
// 4xx response) and surface the service-unavailable screen for the former.
|
||||
if (axios.isAxiosError(err) && !err.response) {
|
||||
this.lastFailureWasTransport = true;
|
||||
void reportPossibleBackendDown();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
2
src/vite-env.d.ts
vendored
2
src/vite-env.d.ts
vendored
@@ -7,6 +7,8 @@ interface ImportMetaEnv {
|
||||
readonly VITE_TELEGRAM_BOT_USERNAME?: string;
|
||||
readonly VITE_APP_NAME?: string;
|
||||
readonly VITE_APP_LOGO?: string;
|
||||
/** Optional override for the backend liveness URL (defaults to `<origin>/health/unified`). */
|
||||
readonly VITE_HEALTH_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Reference in New Issue
Block a user