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
24 changes: 24 additions & 0 deletions diagnostic/build-3d7f3362.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"generated_at": "2026-06-27T04:38:17.351187+00:00",
"commit": "3d7f3362",
"diagnostic_logd": null,
"diagnostic_logd_error": null,
"message_blocker": null,
"chunked": false,
"chunk_size_bytes": null,
"password": null,
"decrypt_command": null,
"total_modules": 1,
"passed": 1,
"failed": 0,
"modules": [
{
"name": "frontend",
"status": "PASS",
"elapsed_seconds": 19.004,
"artifact": "/home/yubizyan/bounty-tent-trials/frontend/dist",
"output": "> tent-frontend@0.0.0 build\n> tsc -b && vite build\n\nvite v6.4.3 building for production...\ntransforming...\n\u2713 100 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.62 kB \u2502 gzip: 0.34 kB\ndist/assets/state-BkjSKDbY.js 8.91 kB \u2502 gzip: 3.54 kB \u2502 map: 57.15 kB\ndist/assets/vendor-CREcWLHI.js 48.93 kB \u2502 gzip: 17.25 kB \u2502 map: 481.27 kB\ndist/assets/index-CyxcoTyU.js 231.32 kB \u2502 gzip: 72.16 kB \u2502 map: 1,044.42 kB\n\u2713 built in 5.46s"
}
],
"pr_note": "Encrypted diagnostic logd artifact was not created; include this JSON report showing why. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging."
}
75 changes: 75 additions & 0 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ export interface ApiError {
suggestion?: string;
}

/**
* Type guard to check if a caught error is an ApiError.
*/
function isApiError(error: unknown): error is ApiError {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
typeof (error as Record<string, unknown>).code === 'number' &&
'message' in error
);
}

export interface RequestConfig {
timeout?: number;
retries?: number;
Expand Down Expand Up @@ -244,6 +257,15 @@ async function request<T>(

return apiResponse;
} catch (error) {
// If this is already an ApiError (from parseResponse), pass through error interceptors
if (isApiError(error)) {
let processedError = error as ApiError;
for (const interceptor of errorInterceptors) {
processedError = interceptor(processedError);
}
throw processedError;
}

lastError = error as Error;

if (attempt < maxRetries && method === 'GET') {
Expand Down Expand Up @@ -287,7 +309,60 @@ function buildUrl(path: string, params?: QueryParams): string {
return qs ? `${baseUrl}?${qs}` : baseUrl;
}

/**
* Parse an error response into an ApiError with structured details.
* Preserves request ID, status code, and response body for downstream handling.
*/
async function parseErrorResponse(response: Response): Promise<ApiError> {
const requestId = response.headers.get('X-Request-ID') || undefined;
const path = response.url ? new URL(response.url).pathname : undefined;

let details: Record<string, unknown> | undefined;
let message = response.statusText || 'Request failed';

try {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const body = await response.json();
// Preserve structured error payload from backend
if (typeof body === 'object' && body !== null) {
message = (body as Record<string, unknown>).message as string || body.error as string || message;
details = body as Record<string, unknown>;
}
} else {
const text = await response.text();
if (text) message = text.slice(0, 500);
}
} catch {
// Failed to parse body; use defaults
}

return {
code: response.status,
message,
details,
requestId,
path,
suggestion: getSuggestionForStatus(response.status),
};
}

function getSuggestionForStatus(status: number): string | undefined {
if (status === 401) return 'Your session may have expired. Try logging in again.';
if (status === 403) return 'You do not have permission to perform this action.';
if (status === 404) return 'The requested resource was not found.';
if (status === 429) return 'Too many requests. Please wait and try again.';
if (status >= 500) return 'The server encountered an error. Please try again later.';
return undefined;
}

async function parseResponse<T>(response: Response): Promise<ApiResponse<T>> {
// Non-OK responses are normalized into ApiError and thrown so that
// error interceptors (401, 429, etc.) are consistently exercised.
if (!response.ok) {
throw await parseErrorResponse(response);
}

const contentType = response.headers.get('content-type') || '';

let data: T;
Expand Down