diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 8167416..4891108 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -14,13 +14,38 @@ export const shouldSkipAuthRedirect = (path: string): boolean => { return path.startsWith("/login") || path.startsWith("/auth"); }; +// Retry config for transient network errors +const MAX_RETRIES = 2; +const RETRY_DELAY_MS = 1000; + +// Track retry count per request +const retryCount = new WeakMap(); + apiClient.interceptors.response.use( (response) => response, - (error) => { + async (error) => { const status = error?.response?.status; if (status === 401 && !shouldSkipAuthRedirect(window.location.pathname)) { window.location.assign(`${BASE_URL}/auth/login`); + return Promise.reject(error); } + + // Retry on transient network errors (ERR_NETWORK_CHANGED, etc.) + const isNetworkError = !error.response && error.message?.includes("Network"); + const isRetryable = isNetworkError || status >= 502; // 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout + + if (isRetryable) { + const config = error.config; + const currentRetry = retryCount.get(config) || 0; + + if (currentRetry < MAX_RETRIES) { + retryCount.set(config, currentRetry + 1); + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS * (currentRetry + 1))); + return apiClient(config); + } + } + return Promise.reject(error); } ); diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index a7c4152..3f6e311 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -58,10 +58,10 @@ export const AppShell = () => { useEffect(() => { void loadSessions(); - // Poll every 10 seconds + // Poll every 30 seconds (reduced from 10s to avoid ERR_NETWORK_CHANGED from Docker network changes) const interval = setInterval(() => { void loadSessions(); - }, 10000); + }, 30000); return () => clearInterval(interval); }, [loadSessions]);