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:
c0mrade
2026-06-04 12:44:27 +03:00
parent f7bd36a95e
commit ac8a0fc41a
15 changed files with 384 additions and 3 deletions

View File

@@ -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;
}
}