From 236721d3a1f0e5c6f1cbe019ff02902b3518a5fc Mon Sep 17 00:00:00 2001 From: earn-agent Date: Sun, 28 Jun 2026 22:05:37 +0800 Subject: [PATCH] fix(frontend): harden API response error handling (closes #1) --- frontend/src/services/api.ts | 43 +++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e9a124831..923652281 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -256,8 +256,13 @@ async function request( } } - const apiError = normalizeError(lastError); - let processedError = apiError; + // HARDEN: ensure error is a proper ApiError with code + let processedError: ApiError; + if (lastError && typeof lastError === 'object' && 'code' in lastError) { + processedError = lastError as ApiError; + } else { + processedError = normalizeError(lastError); + } for (const interceptor of errorInterceptors) { processedError = interceptor(processedError); } @@ -290,6 +295,29 @@ function buildUrl(path: string, params?: QueryParams): string { async function parseResponse(response: Response): Promise> { const contentType = response.headers.get('content-type') || ''; + // HARDEN: reject non-2xx responses as proper ApiError + if (!response.ok) { + let errorBody: Record = {}; + try { + if (contentType.includes('application/json')) { + errorBody = await response.json(); + } else { + const text = await response.text(); + errorBody = { message: text }; + } + } catch { + // response body unreadable — use status info only + } + throw { + code: response.status, + message: (errorBody.message as string) || response.statusText || `HTTP ${response.status}`, + details: errorBody.details as Record || errorBody, + requestId: response.headers.get('X-Request-ID') || undefined, + path: response.url, + suggestion: getSuggestionForStatus(response.status), + } as ApiError; + } + let data: T; if (contentType.includes('application/json')) { data = await response.json(); @@ -298,7 +326,6 @@ async function parseResponse(response: Response): Promise> { } else if (contentType.includes('multipart/form-data')) { data = (await response.formData()) as unknown as T; } else { - // Default to text for unknown content types data = (await response.text()) as unknown as T; } @@ -313,6 +340,16 @@ async function parseResponse(response: Response): Promise> { }; } +function getSuggestionForStatus(status: number): string { + if (status === 400) return 'Please check your request parameters.'; + if (status === 401) return 'Please check your authentication credentials.'; + 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 'Rate limit exceeded. Please retry after a moment.'; + if (status >= 500) return 'Server error. Please try again later.'; + return 'Please try again or contact support.'; +} + function extractPagination(headers: Headers): PaginationInfo | undefined { const page = headers.get('X-Page'); const perPage = headers.get('X-Per-Page');