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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user