feat: Implement comprehensive OpenAI error handling for Issue #362

Replace silent failures with clear, actionable error messages to eliminate
90-minute debugging sessions when OpenAI API quota is exhausted.

## Backend Enhancements
- Add error sanitization preventing sensitive data exposure (API keys, URLs, tokens)
- Add upfront API key validation before expensive operations (crawl, upload, refresh)
- Implement fail-fast pattern in RAG service (no more empty results for API failures)
- Add specific error handling for quota, rate limit, auth, and API errors
- Add EmbeddingAuthenticationError exception with masked key prefix support

## Frontend Enhancements
- Create enhanced error utilities with OpenAI-specific parsing
- Build TanStack Query compatible API wrapper preserving ETag caching
- Update knowledge service to use enhanced error handling
- Enhance TanStack Query hooks with user-friendly error messages

## Security Features
- Comprehensive regex sanitization (8 patterns) with ReDoS protection
- Input validation and circular reference detection
- Generic fallback messages for sensitive keywords
- Bounded quantifiers to prevent regex DoS attacks

## User Experience
- Clear error messages: "OpenAI API quota exhausted"
- Actionable guidance: "Check your OpenAI billing dashboard and add credits"
- Immediate error visibility (no more silent failures)
- Appropriate error severity styling

## Architecture Compatibility
- Full TanStack Query integration maintained
- ETag caching and optimistic updates preserved
- No performance regression (all existing tests pass)
- Compatible with existing knowledge base architecture

Resolves #362: Users no longer experience mysterious empty RAG results
that require extensive debugging to identify OpenAI quota issues.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
leex279
2025-09-12 19:22:36 +02:00
parent 94aed6b9fa
commit 98b798173e
26 changed files with 1375 additions and 143 deletions

View File

@@ -11,6 +11,7 @@ import { useActiveOperations } from "../progress/hooks";
import { progressKeys } from "../progress/hooks/useProgressQueries";
import type { ActiveOperation, ActiveOperationsResponse } from "../progress/types";
import { knowledgeService } from "../services";
import { getDisplayErrorMessage, type EnhancedError } from "../utils/errorHandler";
import type {
CrawlRequest,
CrawlStartResponse,
@@ -273,7 +274,10 @@ export function useCrawlUrl() {
queryClient.setQueryData(progressKeys.list(), context.previousOperations);
}
const errorMessage = error instanceof Error ? error.message : "Failed to start crawl";
// Use enhanced error handling for better user experience
const errorMessage = (error as EnhancedError)?.isOpenAIError
? getDisplayErrorMessage(error as EnhancedError)
: (error instanceof Error ? error.message : "Failed to start crawl");
showToast(errorMessage, "error");
},
});
@@ -449,8 +453,10 @@ export function useUploadDocument() {
queryClient.setQueryData(progressKeys.list(), context.previousOperations);
}
// Display the actual error message from backend
const message = error instanceof Error ? error.message : "Failed to upload document";
// Use enhanced error handling for better user experience
const message = (error as EnhancedError)?.isOpenAIError
? getDisplayErrorMessage(error as EnhancedError)
: (error instanceof Error ? error.message : "Failed to upload document");
showToast(message, "error");
},
});
@@ -521,7 +527,10 @@ export function useDeleteKnowledgeItem() {
queryClient.setQueryData(queryKey, data);
}
const errorMessage = error instanceof Error ? error.message : "Failed to delete item";
// Use enhanced error handling for better user experience
const errorMessage = (error as EnhancedError)?.isOpenAIError
? getDisplayErrorMessage(error as EnhancedError)
: (error instanceof Error ? error.message : "Failed to delete item");
showToast(errorMessage, "error");
},
onSuccess: (data) => {
@@ -568,7 +577,10 @@ export function useUpdateKnowledgeItem() {
queryClient.setQueryData(knowledgeKeys.detail(variables.sourceId), context.previousItem);
}
const errorMessage = error instanceof Error ? error.message : "Failed to update item";
// Use enhanced error handling for better user experience
const errorMessage = (error as EnhancedError)?.isOpenAIError
? getDisplayErrorMessage(error as EnhancedError)
: (error instanceof Error ? error.message : "Failed to update item");
showToast(errorMessage, "error");
},
onSuccess: (_data, { sourceId }) => {
@@ -604,7 +616,10 @@ export function useRefreshKnowledgeItem() {
return data;
},
onError: (error) => {
const errorMessage = error instanceof Error ? error.message : "Failed to refresh item";
// Use enhanced error handling for better user experience
const errorMessage = (error as EnhancedError)?.isOpenAIError
? getDisplayErrorMessage(error as EnhancedError)
: (error instanceof Error ? error.message : "Failed to refresh item");
showToast(errorMessage, "error");
},
});

View File

@@ -0,0 +1,99 @@
/**
* Enhanced API client for knowledge base operations with OpenAI error handling
* Built on top of the ETag-aware API client with additional error parsing
*/
import { callAPIWithETag } from "../../projects/shared/apiWithEtag";
import { parseKnowledgeBaseError, type EnhancedError } from "../utils/errorHandler";
/**
* API call wrapper with enhanced OpenAI error handling
* Uses ETag caching for efficiency while adding specialized error parsing
*/
export async function callKnowledgeAPI<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
try {
// Use the ETag-aware API client for caching benefits
return await callAPIWithETag<T>(endpoint, options);
} catch (error: any) {
// Apply enhanced error parsing for OpenAI errors
const enhancedError = parseKnowledgeBaseError({
status: error.statusCode || error.status,
error: error.message || error.detail || error,
detail: error.detail
});
// Preserve the original error structure but enhance with our parsing
const finalError = error as EnhancedError;
finalError.isOpenAIError = enhancedError.isOpenAIError;
finalError.errorDetails = enhancedError.errorDetails;
finalError.message = enhancedError.message;
throw finalError;
}
}
/**
* Enhanced upload wrapper that handles FormData and file uploads with better error handling
*/
export async function uploadWithEnhancedErrors(
endpoint: string,
formData: FormData,
timeoutMs: number = 30000
): Promise<any> {
const API_BASE_URL = "/api"; // Use same base as other services
let fullUrl = `${API_BASE_URL}${endpoint}`;
// Handle test environment URLs
if (typeof process !== "undefined" && process.env?.NODE_ENV === "test") {
const testHost = process.env?.VITE_HOST || "localhost";
const testPort = process.env?.ARCHON_SERVER_PORT || "8181";
fullUrl = `http://${testHost}:${testPort}${fullUrl}`;
}
try {
const response = await fetch(fullUrl, {
method: "POST",
body: formData,
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
const text = await response.text();
errorData = { status: response.status, error: text };
}
// Apply enhanced error parsing
const enhancedError = parseKnowledgeBaseError({
status: response.status,
error: errorData.detail || errorData.error || errorData,
detail: errorData.detail
});
throw enhancedError;
}
return response.json();
} catch (error: any) {
// Check if it's a timeout error
if (error instanceof Error && error.name === 'AbortError') {
const timeoutError = parseKnowledgeBaseError(new Error('Request timed out'));
throw timeoutError;
}
// If it's already an enhanced error, re-throw it
if (error && typeof error === 'object' && 'isOpenAIError' in error) {
throw error;
}
// Parse other errors through the error handler for consistency
throw parseKnowledgeBaseError(error);
}
}

View File

@@ -3,7 +3,8 @@
* Handles all knowledge-related API operations using TanStack Query patterns
*/
import { callAPIWithETag, invalidateETagCache } from "../../projects/shared/apiWithEtag";
import { invalidateETagCache } from "../../projects/shared/apiWithEtag";
import { callKnowledgeAPI, uploadWithEnhancedErrors } from "./apiWithEnhancedErrors";
import type {
ChunksResponse,
CodeExamplesResponse,
@@ -40,21 +41,21 @@ export const knowledgeService = {
const queryString = params.toString();
const endpoint = `/api/knowledge-items/summary${queryString ? `?${queryString}` : ""}`;
return callAPIWithETag<KnowledgeItemsResponse>(endpoint);
return callKnowledgeAPI<KnowledgeItemsResponse>(endpoint);
},
/**
* Get a specific knowledge item
*/
async getKnowledgeItem(sourceId: string): Promise<KnowledgeItem> {
return callAPIWithETag<KnowledgeItem>(`/api/knowledge-items/${sourceId}`);
return callKnowledgeAPI<KnowledgeItem>(`/api/knowledge-items/${sourceId}`);
},
/**
* Delete a knowledge item
*/
async deleteKnowledgeItem(sourceId: string): Promise<{ success: boolean; message: string }> {
const response = await callAPIWithETag<{ success: boolean; message: string }>(`/api/knowledge-items/${sourceId}`, {
const response = await callKnowledgeAPI<{ success: boolean; message: string }>(`/api/knowledge-items/${sourceId}`, {
method: "DELETE",
});
@@ -70,7 +71,7 @@ export const knowledgeService = {
* Update a knowledge item
*/
async updateKnowledgeItem(sourceId: string, updates: Partial<KnowledgeItem>): Promise<KnowledgeItem> {
const response = await callAPIWithETag<KnowledgeItem>(`/api/knowledge-items/${sourceId}`, {
const response = await callKnowledgeAPI<KnowledgeItem>(`/api/knowledge-items/${sourceId}`, {
method: "PUT",
body: JSON.stringify(updates),
});
@@ -87,7 +88,7 @@ export const knowledgeService = {
* Start crawling a URL
*/
async crawlUrl(request: CrawlRequest): Promise<CrawlStartResponse> {
const response = await callAPIWithETag<CrawlStartResponse>("/api/knowledge-items/crawl", {
const response = await callKnowledgeAPI<CrawlStartResponse>("/api/knowledge-items/crawl", {
method: "POST",
body: JSON.stringify(request),
});
@@ -103,7 +104,7 @@ export const knowledgeService = {
* Refresh an existing knowledge item
*/
async refreshKnowledgeItem(sourceId: string): Promise<RefreshResponse> {
const response = await callAPIWithETag<RefreshResponse>(`/api/knowledge-items/${sourceId}/refresh`, {
const response = await callKnowledgeAPI<RefreshResponse>(`/api/knowledge-items/${sourceId}/refresh`, {
method: "POST",
});
@@ -132,38 +133,21 @@ export const knowledgeService = {
formData.append("tags", JSON.stringify(metadata.tags));
}
// Use fetch directly for file upload (FormData doesn't work well with our ETag wrapper)
// In test environment, we need absolute URLs
let uploadUrl = "/api/documents/upload";
if (typeof process !== "undefined" && process.env?.NODE_ENV === "test") {
const testHost = process.env?.VITE_HOST || "localhost";
const testPort = process.env?.ARCHON_SERVER_PORT || "8181";
uploadUrl = `http://${testHost}:${testPort}${uploadUrl}`;
}
const response = await fetch(uploadUrl, {
method: "POST",
body: formData,
signal: AbortSignal.timeout(30000), // 30 second timeout for file uploads
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
// Use enhanced upload wrapper with OpenAI error handling
const result = await uploadWithEnhancedErrors("/documents/upload", formData, 30000);
// Invalidate list cache
invalidateETagCache("/api/knowledge-items");
invalidateETagCache("/api/knowledge-items/summary");
return response.json();
return result;
},
/**
* Stop a running crawl
*/
async stopCrawl(progressId: string): Promise<{ success: boolean; message: string }> {
return callAPIWithETag<{ success: boolean; message: string }>(`/api/knowledge-items/stop/${progressId}`, {
return callKnowledgeAPI<{ success: boolean; message: string }>(`/api/knowledge-items/stop/${progressId}`, {
method: "POST",
});
},
@@ -193,7 +177,7 @@ export const knowledgeService = {
const queryString = params.toString();
const endpoint = `/api/knowledge-items/${sourceId}/chunks${queryString ? `?${queryString}` : ""}`;
return callAPIWithETag<ChunksResponse>(endpoint);
return callKnowledgeAPI<ChunksResponse>(endpoint);
},
/**
@@ -217,14 +201,14 @@ export const knowledgeService = {
const queryString = params.toString();
const endpoint = `/api/knowledge-items/${sourceId}/code-examples${queryString ? `?${queryString}` : ""}`;
return callAPIWithETag<CodeExamplesResponse>(endpoint);
return callKnowledgeAPI<CodeExamplesResponse>(endpoint);
},
/**
* Search the knowledge base
*/
async searchKnowledgeBase(options: SearchOptions): Promise<SearchResultsResponse> {
return callAPIWithETag("/api/knowledge-items/search", {
return callKnowledgeAPI("/api/knowledge-items/search", {
method: "POST",
body: JSON.stringify(options),
});
@@ -234,6 +218,6 @@ export const knowledgeService = {
* Get available knowledge sources
*/
async getKnowledgeSources(): Promise<KnowledgeSource[]> {
return callAPIWithETag<KnowledgeSource[]>("/api/knowledge-items/sources");
return callKnowledgeAPI<KnowledgeSource[]>("/api/knowledge-items/sources");
},
};

View File

@@ -0,0 +1,208 @@
/**
* Error handling utilities for knowledge base operations
*
* Provides specialized error handling for OpenAI API errors,
* rate limiting, and quota exhaustion scenarios.
*
* Related to GitHub issue #362 - improves user experience
* by displaying clear error messages when OpenAI API fails.
*/
export interface OpenAIErrorDetails {
error: string;
message: string;
error_type: 'quota_exhausted' | 'rate_limit' | 'api_error' | 'authentication_failed';
tokens_used?: number;
retry_after?: number;
api_key_prefix?: string;
}
export interface EnhancedError extends Error {
statusCode?: number;
errorDetails?: OpenAIErrorDetails;
isOpenAIError?: boolean;
}
/**
* Create a fallback error for cases where input is invalid or unparseable
*/
function createFallbackError(reason: string): EnhancedError {
return Object.assign(new Error('Unknown error occurred'), {
errorDetails: {
error: 'unknown',
message: `${reason}. Please try again or contact support if the problem persists.`,
error_type: 'api_error' as const
}
}) as EnhancedError;
}
/**
* Check if an object can be safely serialized (no circular references)
*/
function isSafeObject(obj: any): boolean {
try {
JSON.stringify(obj);
return true;
} catch {
return false;
}
}
/**
* Parse and enhance API errors from knowledge base operations
*/
export function parseKnowledgeBaseError(error: any): EnhancedError {
// Enhanced input validation
if (!error) {
return createFallbackError('No error information provided');
}
if (typeof error === 'string') {
return Object.assign(new Error(error), {
errorDetails: {
error: 'api_error',
message: error,
error_type: 'api_error' as const
}
}) as EnhancedError;
}
if (typeof error !== 'object' || error === null) {
return createFallbackError('Invalid error format');
}
// Check for empty objects or objects with no useful properties
if (error.constructor === Object && Object.keys(error).length === 0) {
return createFallbackError('Empty error object received');
}
// Check for circular references and object safety
if (!isSafeObject(error)) {
return createFallbackError('Error object contains circular references');
}
// Handle Error instances that might have been serialized/deserialized
if (error instanceof Error || (error.name && error.message && error.stack)) {
// This is likely an Error object, proceed with parsing
} else if (!error.message && !error.error && !error.detail && !error.status) {
// Object doesn't have any recognizable error properties
return createFallbackError('Unrecognized error object structure');
}
const enhancedError: EnhancedError = new Error(error.message || 'Unknown error');
// Check if this is an HTTP response error with JSON details
if (error && typeof error === 'object') {
// Handle fetch Response errors
if (error.status || error.statusCode) {
enhancedError.statusCode = error.status || error.statusCode;
}
// Parse error details from API response
if (error.error || error.detail) {
const errorData = error.error || error.detail;
// Check if it's an OpenAI-specific error
if (typeof errorData === 'object' && errorData.error_type) {
enhancedError.isOpenAIError = true;
enhancedError.errorDetails = errorData as OpenAIErrorDetails;
// Override the message with the detailed error message
enhancedError.message = errorData.message || errorData.error || enhancedError.message;
}
}
}
return enhancedError;
}
/**
* Get user-friendly error message for display in UI
*/
export function getDisplayErrorMessage(error: EnhancedError): string {
if (error.isOpenAIError && error.errorDetails) {
switch (error.errorDetails.error_type) {
case 'quota_exhausted':
return `OpenAI API quota exhausted. Please add credits to your OpenAI account or check your billing settings.`;
case 'rate_limit':
return `OpenAI API rate limit exceeded. Please wait a moment and try again.`;
case 'authentication_failed':
return `Invalid or expired OpenAI API key. Please check your API key in settings.`;
case 'api_error':
return `OpenAI API error: ${error.errorDetails.message}. Please check your API key configuration.`;
default:
return error.errorDetails.message || error.message;
}
}
// Handle HTTP status codes
if (error.statusCode) {
switch (error.statusCode) {
case 401:
return 'Authentication failed. Please check your API key.';
case 429:
return 'API rate limit exceeded. Please wait a moment and try again.';
case 502:
return 'API service unavailable. Please try again in a few minutes.';
case 503:
return 'Service temporarily unavailable. Please try again later.';
default:
return error.message;
}
}
return error.message || 'An unexpected error occurred.';
}
/**
* Get error severity level for UI styling
*/
export function getErrorSeverity(error: EnhancedError): 'error' | 'warning' | 'info' {
if (error.isOpenAIError && error.errorDetails) {
switch (error.errorDetails.error_type) {
case 'quota_exhausted':
return 'error'; // Critical - user action required
case 'authentication_failed':
return 'error'; // Critical - configuration issue
case 'rate_limit':
return 'warning'; // Temporary - retry may work
case 'api_error':
return 'error'; // Likely configuration issue
default:
return 'error';
}
}
if (error.statusCode && error.statusCode >= 500) {
return 'error'; // Server errors
}
return 'warning'; // Default to warning for other errors
}
/**
* Get suggested action for the user based on error type
*/
export function getErrorAction(error: EnhancedError): string | null {
if (error.isOpenAIError && error.errorDetails) {
switch (error.errorDetails.error_type) {
case 'quota_exhausted':
return 'Check your OpenAI billing dashboard and add credits';
case 'authentication_failed':
return 'Verify your OpenAI API key in Settings';
case 'rate_limit':
const retryAfter = error.errorDetails.retry_after || 30;
return `Wait ${retryAfter} seconds and try again`;
case 'api_error':
return 'Verify your OpenAI API key in Settings';
default:
return null;
}
}
return null;
}