From 6d59ec3b502a5c21d4daa480b1318b21b28fded1 Mon Sep 17 00:00:00 2001 From: imean131415 <90598658+imean131415@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:53:30 +0800 Subject: [PATCH 1/2] fix: normalize non-ok api responses --- frontend/src/services/api.ts | 85 +++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e9a124831..46fcd305e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -224,6 +224,7 @@ async function request( } let lastError: Error | null = null; + let lastApiError: ApiError | null = null; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { @@ -234,6 +235,11 @@ async function request( const response = await fetch(requestConfig.url, requestConfig); clearTimeout(timeoutId); + if (!response.ok) { + lastApiError = await normalizeHttpError(response, requestConfig.url); + break; + } + const responseData = await parseResponse(response); // Apply response interceptors @@ -244,6 +250,11 @@ async function request( return apiResponse; } catch (error) { + if (isApiError(error)) { + lastApiError = error; + break; + } + lastError = error as Error; if (attempt < maxRetries && method === 'GET') { @@ -256,7 +267,7 @@ async function request( } } - const apiError = normalizeError(lastError); + const apiError = lastApiError ?? normalizeError(lastError); let processedError = apiError; for (const interceptor of errorInterceptors) { processedError = interceptor(processedError); @@ -331,6 +342,78 @@ function extractPagination(headers: Headers): PaginationInfo | undefined { }; } +function isApiError(error: unknown): error is ApiError { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof (error as ApiError).code === 'number' && + 'message' in error && + typeof (error as ApiError).message === 'string' + ); +} + +async function normalizeHttpError(response: Response, requestUrl: string): Promise { + const contentType = response.headers.get('content-type') || ''; + const requestId = response.headers.get('X-Request-ID') || response.headers.get('X-Correlation-ID') || undefined; + const fallbackPath = getPathFromUrl(requestUrl); + + if (contentType.includes('application/json')) { + try { + const payload = await response.json(); + if (payload && typeof payload === 'object') { + const body = payload as Record; + const details = typeof body.details === 'object' && body.details !== null + ? body.details as Record + : body; + + return { + code: response.status, + message: getString(body.message) || getString(body.error) || response.statusText || 'HTTP error', + details, + requestId: getString(body.requestId) || requestId, + timestamp: getString(body.timestamp), + path: getString(body.path) || fallbackPath, + suggestion: getString(body.suggestion), + }; + } + } catch { + return { + code: response.status, + message: 'Invalid JSON error response', + details: { statusText: response.statusText }, + requestId, + path: fallbackPath, + suggestion: 'Please try again later or contact support with the request ID.', + }; + } + } + + const text = await response.text(); + return { + code: response.status, + message: text.trim() || response.statusText || 'HTTP error', + details: text.trim() ? { body: text } : { statusText: response.statusText }, + requestId, + path: fallbackPath, + suggestion: response.status >= 500 + ? 'Please try again later or contact support with the request ID.' + : undefined, + }; +} + +function getString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; +} + +function getPathFromUrl(url: string): string | undefined { + try { + return new URL(url).pathname; + } catch { + return undefined; + } +} + function normalizeError(error: Error | null): ApiError { if (!error) { return { code: 0, message: 'Unknown error' }; From f494f6a9b763aa93cc44a7fb1b86366039a75849 Mon Sep 17 00:00:00 2001 From: imean131415 <90598658+imean131415@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:53:36 +0800 Subject: [PATCH 2/2] docs: document frontend api error semantics --- docs/OPERATIONS.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 58642e7b9..7359bdf56 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -278,6 +278,32 @@ Audit logs are retained for 365 days and include: ## Troubleshooting +### Frontend API Error Semantics + +The legacy frontend API client rejects non-2xx HTTP responses as structured +`ApiError` values instead of passing them through the successful response +interceptor chain. This keeps callers from accidentally treating backend +validation, authentication, rate-limit, or server failures as valid +`ApiResponse` payloads. + +Normalized HTTP errors include the status code, message, request ID when the +backend provides one, path, and either parsed JSON details or the raw text body. +JSON error payloads may provide `message`, `error`, `details`, `requestId`, +`timestamp`, `path`, and `suggestion`. Plain-text responses are preserved in +`details.body`. Timeout and network failures continue to use the existing +client-side normalization path. + +Operational checks: + +```bash +cd frontend +npm run build +``` + +When debugging production incidents, search logs by the propagated +`X-Request-ID` value first. For 401 and 429 responses, confirm the default error +interceptors ran before inspecting caller-specific handling. + ### Common Issues **Service won't start**