Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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",
}
Expand Down Expand Up @@ -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:
Expand All @@ -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),
Expand Down Expand Up @@ -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()}")
Expand All @@ -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()
Expand Down Expand Up @@ -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"
Expand Down
230 changes: 230 additions & 0 deletions frontend/src/services/api.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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();
});
});
});
44 changes: 44 additions & 0 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,23 @@ async function parseResponse<T>(response: Response): Promise<ApiResponse<T>> {
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<string, unknown>)
: { body: errorBody },
suggestion: getSuggestionForStatus(response.status),
};
throw error;
}

const pagination = extractPagination(response.headers);

return {
Expand All @@ -313,6 +330,33 @@ async function parseResponse<T>(response: Response): Promise<ApiResponse<T>> {
};
}

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');
Expand Down
Binary file modified tools/encryptly/linux-arm64/encryptly
Binary file not shown.
Binary file modified tools/encryptly/linux-x64/encryptly
Binary file not shown.
Binary file modified tools/encryptly/macos-arm64/encryptly
Binary file not shown.
Binary file modified tools/encryptly/macos-x64/encryptly
Binary file not shown.
Binary file modified tools/encryptly/windows-arm64/encryptly.exe
Binary file not shown.
Binary file modified tools/encryptly/windows-x64/encryptly.exe
Binary file not shown.