Spaces:
Sleeping
Sleeping
feat: enhance UI state management with graph collapse functionality and improve error handling in API requests
9bb34f8 | import { env } from '@/config/env' | |
| import { useAuthStore } from '@/stores/authStore' | |
| import { useUIStore } from '@/stores/uiStore' | |
| // βββ Typed API errors ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export class ApiError extends Error { | |
| constructor( | |
| public readonly status: number, | |
| message: string, | |
| public readonly requestId?: string, | |
| ) { | |
| super(message) | |
| this.name = 'ApiError' | |
| } | |
| } | |
| // βββ Token refresh βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function refreshToken(): Promise<boolean> { | |
| try { | |
| const res = await fetch(`${env.apiBaseUrl}/api/auth/refresh`, { | |
| method: 'POST', | |
| credentials: 'include', | |
| }) | |
| return res.ok | |
| } catch { | |
| return false | |
| } | |
| } | |
| // βββ Error handling ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** Parse a non-OK response and show the appropriate toast, then throw ApiError. */ | |
| async function handleErrorResponse(res: Response): Promise<never> { | |
| const requestId = res.headers.get('X-Request-ID') ?? undefined | |
| const addToast = useUIStore.getState().addToast | |
| let message: string | |
| try { | |
| const json = await res.json() | |
| message = json.detail ?? json.message ?? res.statusText | |
| } catch { | |
| message = res.statusText || `HTTP ${res.status}` | |
| } | |
| switch (res.status) { | |
| case 429: { | |
| const retryAfter = res.headers.get('Retry-After') | |
| const suffix = retryAfter ? ` β retry in ${retryAfter}s` : '' | |
| addToast({ type: 'warning', message: `Rate limited${suffix}` }) | |
| break | |
| } | |
| case 403: | |
| // RBAC β components handle this via RBACRestrictedBanner; no toast needed | |
| break | |
| case 502: | |
| case 503: | |
| case 504: | |
| addToast({ type: 'error', message: 'Backend unavailable β try again shortly' }) | |
| break | |
| case 500: | |
| addToast({ | |
| type: 'error', | |
| message: requestId | |
| ? `Server error [${requestId}] β contact support if this persists` | |
| : 'Server error β try again', | |
| }) | |
| break | |
| default: | |
| if (res.status >= 400) { | |
| addToast({ type: 'error', message }) | |
| } | |
| } | |
| throw new ApiError(res.status, message, requestId) | |
| } | |
| // βββ Base fetch ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export async function apiFetch( | |
| path: string, | |
| init: RequestInit = {}, | |
| ): Promise<Response> { | |
| const doFetch = () => | |
| fetch(`${env.apiBaseUrl}${path}`, { | |
| ...init, | |
| credentials: 'include', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| ...init.headers, | |
| }, | |
| }) | |
| let res: Response | |
| try { | |
| res = await doFetch() | |
| } catch { | |
| // Network-level failure (DNS, no connection) | |
| useUIStore.getState().addToast({ type: 'error', message: 'No connection to server' }) | |
| throw new ApiError(0, 'Network error') | |
| } | |
| if (res.status === 401) { | |
| const refreshed = await refreshToken() | |
| if (!refreshed) { | |
| useAuthStore.getState().logout() | |
| window.location.href = '/login' | |
| throw new ApiError(401, 'Session expired') | |
| } | |
| try { | |
| res = await doFetch() | |
| } catch { | |
| throw new ApiError(0, 'Network error after token refresh') | |
| } | |
| } | |
| if (!res.ok) { | |
| await handleErrorResponse(res) | |
| } | |
| return res | |
| } | |
| // βββ SSE streaming fetch βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** Returns raw Response for streaming β does NOT parse body. */ | |
| export async function ssePost( | |
| path: string, | |
| body: unknown, | |
| signal: AbortSignal, | |
| ): Promise<Response> { | |
| const doFetch = () => | |
| fetch(`${env.apiBaseUrl}${path}`, { | |
| method: 'POST', | |
| credentials: 'include', | |
| signal, | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Accept': 'text/event-stream', | |
| 'Cache-Control': 'no-cache', | |
| }, | |
| body: JSON.stringify(body), | |
| }) | |
| let res: Response | |
| try { | |
| res = await doFetch() | |
| } catch (err) { | |
| if ((err as Error).name === 'AbortError') throw err | |
| useUIStore.getState().addToast({ type: 'error', message: 'No connection to server' }) | |
| throw new ApiError(0, 'Network error') | |
| } | |
| if (res.status === 401) { | |
| const refreshed = await refreshToken() | |
| if (!refreshed) { | |
| useAuthStore.getState().logout() | |
| window.location.href = '/login' | |
| throw new ApiError(401, 'Session expired') | |
| } | |
| try { | |
| res = await doFetch() | |
| } catch (err) { | |
| if ((err as Error).name === 'AbortError') throw err | |
| throw new ApiError(0, 'Network error after token refresh') | |
| } | |
| } | |
| if (!res.ok) { | |
| const requestId = res.headers.get('X-Request-ID') ?? undefined | |
| const text = await res.text().catch(() => res.statusText) | |
| throw new ApiError(res.status, `${res.status}: ${text}`, requestId) | |
| } | |
| return res | |
| } | |