diff --git a/build.py b/build.py index a844dd7a..cf51696c 100644 --- a/build.py +++ b/build.py @@ -17,7 +17,7 @@ ROOT = Path(__file__).resolve().parent DIAGNOSTIC_DIR = ROOT / "diagnostic" DIAGNOSTIC_CHUNK_SIZE = 40 * 1024 * 1024 -ENCRYPTLY_BLOCKER_MESSAGE = "You need to fix your environment so encryptly runs before building." +ENCRYPTLY_BLOCKER_MESSAGE = "encryptly could not create an archive. You may have timed out; try launching it in the background and waiting for it to finish with no timeout due to a bug in encryptly." def current_commit_id() -> str: @@ -169,6 +169,7 @@ class Module: "linux-x64": ENCRYPTLY_DIR / "linux-x64" / "encryptly", "linux-arm64": ENCRYPTLY_DIR / "linux-arm64" / "encryptly", "macos-arm64": ENCRYPTLY_DIR / "macos-arm64" / "encryptly", + "macos-x64": ENCRYPTLY_DIR / "macos-x64" / "encryptly", "windows-x64": ENCRYPTLY_DIR / "windows-x64" / "encryptly.exe", "windows-arm64": ENCRYPTLY_DIR / "windows-arm64" / "encryptly.exe", } @@ -222,7 +223,7 @@ def encryptly_platform_help() -> str: return f"detected {detected}; available: {available}" -def check_encryptly_runs(timeout: int = 60) -> tuple[bool, str]: +def check_encryptly_runs(timeout: int = 600) -> tuple[bool, str]: """Verify encryptly can create a diagnostic bundle before doing any build work.""" encryptly_bin = get_encryptly_bin() if encryptly_bin is None: @@ -234,7 +235,7 @@ def check_encryptly_runs(timeout: int = 60) -> tuple[bool, str]: try: shutil.rmtree(workspace, ignore_errors=True) safe_dir.mkdir(parents=True, exist_ok=True) - (safe_dir / "preflight.txt").write_text("encryptly preflight\n", encoding="utf-8") + (safe_dir / "preflight.txt").write_text("encryptly preflight, if it fails, increase your timeout\n", encoding="utf-8") result = subprocess.run( [ str(encryptly_bin), @@ -555,7 +556,7 @@ def commit_diagnostic_artifacts(paths: list[Path], commit_id: str) -> bool: cwd=str(ROOT), capture_output=True, text=True, - timeout=30, + timeout=300, ) if status.returncode != 0: print(f" {color('✗', Colors.RED)} Could not inspect diagnostic git status: {status.stderr.strip()}") @@ -580,7 +581,7 @@ def commit_diagnostic_artifacts(paths: list[Path], commit_id: str) -> bool: cwd=str(ROOT), capture_output=True, text=True, - timeout=60, + timeout=600, ) if commit.returncode != 0: output = commit.stderr.strip() or commit.stdout.strip() @@ -678,7 +679,7 @@ def generate_logd( cwd=str(ROOT), capture_output=True, text=True, - timeout=300, + timeout=1500, ) if sr.returncode != 0: error = sr.stderr.strip() or sr.stdout.strip() or "encryptly pack failed" diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts new file mode 100644 index 00000000..dd81b2db --- /dev/null +++ b/frontend/src/services/api.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + get, + post, + ApiError, + addErrorInterceptor, + addResponseInterceptor, + addRequestInterceptor, +} from './api'; + +describe('API Error Handling', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Helper to create a mock Response + function createMockResponse(options: { + status: number; + statusText: string; + headers?: Record; + body?: unknown; + contentType?: string; + }): Response { + const { status, statusText, headers = {}, body, contentType = 'application/json' } = options; + return new Response(body ? JSON.stringify(body) : undefined, { + status, + statusText, + headers: { + 'Content-Type': contentType, + ...headers, + }, + }); + } + + describe('2xx success responses', () => { + it('should return successful response for 200', async () => { + const mockData = { id: 1, name: 'Test' }; + vi.mocked(fetch).mockResolvedValueOnce( + createMockResponse({ status: 200, statusText: 'OK', body: mockData }) + ); + + const response = await get('/test'); + expect(response.status).toBe(200); + expect(response.data).toEqual(mockData); + }); + + it('should return successful response for 201', async () => { + const mockData = { created: true }; + vi.mocked(fetch).mockResolvedValueOnce( + createMockResponse({ status: 201, statusText: 'Created', body: mockData }) + ); + + const response = await post('/test'); + expect(response.status).toBe(201); + expect(response.data).toEqual(mockData); + }); + }); + + describe('401 JSON error', () => { + it('should reject with ApiError containing parsed JSON details', async () => { + const errorBody = { + message: 'Invalid credentials', + code: 'AUTH_INVALID', + details: { field: 'password' }, + }; + vi.mocked(fetch).mockResolvedValueOnce( + createMockResponse({ + status: 401, + statusText: 'Unauthorized', + body: errorBody, + headers: { 'X-Request-ID': 'req-123' }, + }) + ); + + await expect(get('/test')).rejects.toMatchObject({ + code: 401, + message: 'Unauthorized', + requestId: 'req-123', + suggestion: 'Your session has expired. Please sign in again.', + details: errorBody, + } as ApiError); + }); + + it('should run error interceptors for 401', async () => { + const errorInterceptor = vi.fn((error: ApiError) => error); + addErrorInterceptor(errorInterceptor); + + vi.mocked(fetch).mockResolvedValueOnce( + createMockResponse({ + status: 401, + statusText: 'Unauthorized', + body: { message: 'Token expired' }, + }) + ); + + await expect(get('/test')).rejects.toBeDefined(); + expect(errorInterceptor).toHaveBeenCalled(); + }); + }); + + describe('429 rate-limit error', () => { + it('should reject with ApiError for 429', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + createMockResponse({ + status: 429, + statusText: 'Too Many Requests', + body: { retryAfter: 60 }, + headers: { 'X-Request-ID': 'req-456' }, + }) + ); + + await expect(get('/test')).rejects.toMatchObject({ + code: 429, + message: 'Too Many Requests', + requestId: 'req-456', + suggestion: 'Too many requests. Please wait a moment and try again.', + details: { retryAfter: 60 }, + } as ApiError); + }); + }); + + describe('500 text error', () => { + it('should reject with ApiError containing text body', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response('Internal Server Error: database connection failed', { + status: 500, + statusText: 'Internal Server Error', + headers: { 'Content-Type': 'text/plain' }, + }) + ); + + await expect(get('/test')).rejects.toMatchObject({ + code: 500, + message: 'Internal Server Error', + suggestion: 'An internal server error occurred. Please try again later.', + details: { body: 'Internal Server Error: database connection failed' }, + } as ApiError); + }); + }); + + describe('aborted request', () => { + it('should handle AbortError as timeout', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new DOMException('The operation was aborted', 'AbortError')); + + await expect(get('/test')).rejects.toMatchObject({ + code: 408, + message: 'Request timed out', + suggestion: 'Please check your network connection and try again.', + } as ApiError); + }); + }); + + describe('network error', () => { + it('should handle network errors', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new TypeError('Failed to fetch')); + + await expect(get('/test')).rejects.toMatchObject({ + code: 0, + message: 'Network error', + suggestion: 'Please check your network connection.', + } as ApiError); + }); + }); + + describe('error interceptor chain', () => { + it('should run all error interceptors for normalized HTTP errors', async () => { + const interceptor1 = vi.fn((error: ApiError) => ({ ...error, message: `${error.message} [intercepted1]` })); + const interceptor2 = vi.fn((error: ApiError) => ({ ...error, message: `${error.message} [intercepted2]` })); + addErrorInterceptor(interceptor1); + addErrorInterceptor(interceptor2); + + vi.mocked(fetch).mockResolvedValueOnce( + createMockResponse({ + status: 403, + statusText: 'Forbidden', + body: { reason: 'insufficient_permissions' }, + }) + ); + + await expect(get('/test')).rejects.toMatchObject({ + code: 403, + message: 'Forbidden [intercepted1] [intercepted2]', + } as ApiError); + }); + }); + + describe('response interceptors', () => { + it('should still run response interceptors for successful requests', async () => { + const responseInterceptor = vi.fn((response) => response); + addResponseInterceptor(responseInterceptor); + + vi.mocked(fetch).mockResolvedValueOnce( + createMockResponse({ status: 200, statusText: 'OK', body: { success: true } }) + ); + + const response = await get('/test'); + expect(response.status).toBe(200); + expect(responseInterceptor).toHaveBeenCalled(); + }); + + it('should not run response interceptors for error responses', async () => { + const responseInterceptor = vi.fn((response) => response); + addResponseInterceptor(responseInterceptor); + + vi.mocked(fetch).mockResolvedValueOnce( + createMockResponse({ status: 404, statusText: 'Not Found', body: { error: 'not_found' } }) + ); + + await expect(get('/test')).rejects.toBeDefined(); + expect(responseInterceptor).not.toHaveBeenCalled(); + }); + }); + + describe('preserve request config', () => { + it('should preserve custom timeout', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + createMockResponse({ status: 200, statusText: 'OK', body: {} }) + ); + + await get('/test', undefined, { timeout: 5000 }); + // The timeout is used internally, just verify the request succeeds + expect(fetch).toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e9a12483..f07b3ade 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -302,6 +302,23 @@ async function parseResponse(response: Response): Promise> { data = (await response.text()) as unknown as T; } + // If response is not OK, throw an ApiError with structured details + if (!response.ok) { + const errorBody = typeof data === 'string' ? data : JSON.stringify(data); + const error: ApiError = { + code: response.status, + message: response.statusText || `HTTP ${response.status}`, + requestId: response.headers.get('X-Request-ID') || undefined, + path: response.headers.get('X-Request-Path') || undefined, + timestamp: new Date().toISOString(), + details: typeof data === 'object' && data !== null + ? (data as Record) + : { body: errorBody }, + suggestion: getSuggestionForStatus(response.status), + }; + throw error; + } + const pagination = extractPagination(response.headers); return { @@ -313,6 +330,33 @@ async function parseResponse(response: Response): Promise> { }; } +function getSuggestionForStatus(status: number): string | undefined { + switch (status) { + case 400: + return 'Check your request parameters and try again.'; + case 401: + return 'Your session has expired. Please sign in again.'; + case 403: + return 'You do not have permission to perform this action.'; + case 404: + return 'The requested resource was not found.'; + case 409: + return 'The resource was modified by another request. Please refresh and try again.'; + case 422: + return 'The request data is invalid. Please check the validation errors.'; + case 429: + return 'Too many requests. Please wait a moment and try again.'; + case 500: + return 'An internal server error occurred. Please try again later.'; + case 502: + return 'The server is temporarily unavailable. Please try again later.'; + case 503: + return 'The service is temporarily unavailable. Please try again later.'; + default: + return undefined; + } +} + function extractPagination(headers: Headers): PaginationInfo | undefined { const page = headers.get('X-Page'); const perPage = headers.get('X-Per-Page'); diff --git a/tools/encryptly/linux-arm64/encryptly b/tools/encryptly/linux-arm64/encryptly index cdc84074..cad3e786 100755 Binary files a/tools/encryptly/linux-arm64/encryptly and b/tools/encryptly/linux-arm64/encryptly differ diff --git a/tools/encryptly/linux-x64/encryptly b/tools/encryptly/linux-x64/encryptly index 60ac61d1..17cc2cc7 100755 Binary files a/tools/encryptly/linux-x64/encryptly and b/tools/encryptly/linux-x64/encryptly differ diff --git a/tools/encryptly/macos-arm64/encryptly b/tools/encryptly/macos-arm64/encryptly index b07cea65..442c8ecf 100755 Binary files a/tools/encryptly/macos-arm64/encryptly and b/tools/encryptly/macos-arm64/encryptly differ diff --git a/tools/encryptly/macos-x64/encryptly b/tools/encryptly/macos-x64/encryptly index aedb151d..93d64afd 100755 Binary files a/tools/encryptly/macos-x64/encryptly and b/tools/encryptly/macos-x64/encryptly differ diff --git a/tools/encryptly/windows-arm64/encryptly.exe b/tools/encryptly/windows-arm64/encryptly.exe index 9ab1a964..97cc6bd1 100644 Binary files a/tools/encryptly/windows-arm64/encryptly.exe and b/tools/encryptly/windows-arm64/encryptly.exe differ diff --git a/tools/encryptly/windows-x64/encryptly.exe b/tools/encryptly/windows-x64/encryptly.exe index dd5abb13..88cd7d1a 100644 Binary files a/tools/encryptly/windows-x64/encryptly.exe and b/tools/encryptly/windows-x64/encryptly.exe differ