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
26 changes: 26 additions & 0 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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**
Expand Down
85 changes: 84 additions & 1 deletion frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ async function request<T>(
}

let lastError: Error | null = null;
let lastApiError: ApiError | null = null;

for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
Expand All @@ -234,6 +235,11 @@ async function request<T>(
const response = await fetch(requestConfig.url, requestConfig);
clearTimeout(timeoutId);

if (!response.ok) {
lastApiError = await normalizeHttpError(response, requestConfig.url);
break;
}

const responseData = await parseResponse<T>(response);

// Apply response interceptors
Expand All @@ -244,6 +250,11 @@ async function request<T>(

return apiResponse;
} catch (error) {
if (isApiError(error)) {
lastApiError = error;
break;
}

lastError = error as Error;

if (attempt < maxRetries && method === 'GET') {
Expand All @@ -256,7 +267,7 @@ async function request<T>(
}
}

const apiError = normalizeError(lastError);
const apiError = lastApiError ?? normalizeError(lastError);
let processedError = apiError;
for (const interceptor of errorInterceptors) {
processedError = interceptor(processedError);
Expand Down Expand Up @@ -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<ApiError> {
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<string, unknown>;
const details = typeof body.details === 'object' && body.details !== null
? body.details as Record<string, unknown>
: 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' };
Expand Down