From a0cfca619dbc37837bbf7c7fca03f1a60b0e502c Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 27 Jun 2026 11:42:21 +0700 Subject: [PATCH] fix: harden API response error handling - parseResponse() now throws ApiError for non-OK HTTP responses so error interceptors (401, 429) are consistently exercised - Added parseErrorResponse() that preserves request IDs, status codes, structured JSON error payloads, and actionable suggestions - Added getSuggestionForStatus() for common HTTP error codes - Added isApiError() type guard for clean error handling - Error interceptor chain now runs on both network errors and HTTP error responses, ensuring uniform behavior Closes #1 --- diagnostic/build-3d7f3362.json | 24 +++++++++++ frontend/src/services/api.ts | 75 ++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 diagnostic/build-3d7f3362.json diff --git a/diagnostic/build-3d7f3362.json b/diagnostic/build-3d7f3362.json new file mode 100644 index 00000000..1b3f0cb4 --- /dev/null +++ b/diagnostic/build-3d7f3362.json @@ -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." +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e9a12483..9f57b1ee 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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).code === 'number' && + 'message' in error + ); +} + export interface RequestConfig { timeout?: number; retries?: number; @@ -244,6 +257,15 @@ async function request( 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') { @@ -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 { + const requestId = response.headers.get('X-Request-ID') || undefined; + const path = response.url ? new URL(response.url).pathname : undefined; + + let details: Record | 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).message as string || body.error as string || message; + details = body as Record; + } + } 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(response: Response): Promise> { + // 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;