From 0f39950de3cbc9a7d6190fd26e0f39cffbf777ee Mon Sep 17 00:00:00 2001 From: perf3ct Date: Tue, 22 Jul 2025 03:46:54 +0000 Subject: [PATCH] fix(upload): resolve upload hanging with comprehensive error handling and timeouts --- frontend/src/components/Upload/UploadZone.tsx | 147 +++++++++++++++--- src/routes/documents/crud.rs | 47 ++++-- 2 files changed, 165 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/Upload/UploadZone.tsx b/frontend/src/components/Upload/UploadZone.tsx index 8cee5b5..99ef4fe 100644 --- a/frontend/src/components/Upload/UploadZone.tsx +++ b/frontend/src/components/Upload/UploadZone.tsx @@ -47,17 +47,20 @@ interface UploadedDocument { interface FileItem { file: File; id: string; - status: 'pending' | 'uploading' | 'success' | 'error'; + status: 'pending' | 'uploading' | 'success' | 'error' | 'timeout' | 'cancelled'; progress: number; error: string | null; + errorCode?: string; documentId?: string; + uploadStartTime?: number; + retryCount?: number; } interface UploadZoneProps { onUploadComplete?: (document: UploadedDocument) => void; } -type FileStatus = 'pending' | 'uploading' | 'success' | 'error'; +type FileStatus = 'pending' | 'uploading' | 'success' | 'error' | 'timeout' | 'cancelled'; const UploadZone: React.FC = ({ onUploadComplete }) => { const theme = useTheme(); @@ -65,6 +68,8 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { const { addBatchNotification } = useNotifications(); const [files, setFiles] = useState([]); const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState<{ completed: number; total: number; failed: number }>({ completed: 0, total: 0, failed: 0 }); + const [concurrentUploads, setConcurrentUploads] = useState>(new Set()); const [error, setError] = useState(''); const [selectedLabels, setSelectedLabels] = useState([]); const [availableLabels, setAvailableLabels] = useState([]); @@ -161,6 +166,7 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { status: 'pending' as FileStatus, progress: 0, error: null, + retryCount: 0, })); setFiles(prev => [...prev, ...newFiles]); @@ -185,7 +191,18 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { setFiles(prev => prev.filter(f => f.id !== fileId)); }; - const uploadFile = async (fileItem: FileItem): Promise => { + const uploadFile = async (fileItem: FileItem, isRetry: boolean = false): Promise => { + const uploadId = fileItem.id; + const maxRetries = 3; + const uploadTimeout = 60000; // 60 seconds timeout + + // Prevent concurrent uploads of the same file + if (concurrentUploads.has(uploadId)) { + console.warn(`Upload already in progress for file: ${fileItem.file.name}`); + return; + } + + setConcurrentUploads(prev => new Set(prev.add(uploadId))); const formData = new FormData(); formData.append('file', fileItem.file); @@ -203,9 +220,11 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { } try { + const uploadStartTime = Date.now(); + setFiles(prev => prev.map(f => f.id === fileItem.id - ? { ...f, status: 'uploading' as FileStatus, progress: 0 } + ? { ...f, status: 'uploading' as FileStatus, progress: 0, uploadStartTime, error: null, errorCode: undefined } : f )); @@ -237,6 +256,8 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { } catch (error: any) { const errorInfo = ErrorHelper.formatErrorForDisplay(error, true); let errorMessage = 'Upload failed'; + let errorCode = 'UNKNOWN_ERROR'; + let newStatus: FileStatus = 'error'; // Handle specific document upload errors if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_TOO_LARGE)) { @@ -262,31 +283,82 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { f.id === fileItem.id ? { ...f, - status: 'error' as FileStatus, + status: newStatus, error: errorMessage, + errorCode, progress: 0, } : f )); + } finally { + setConcurrentUploads(prev => { + const newSet = new Set(prev); + newSet.delete(uploadId); + return newSet; + }); } }; const uploadAllFiles = async (): Promise => { + if (uploading) { + console.warn('[UPLOAD_DEBUG] Upload already in progress, ignoring request'); + return; + } + setUploading(true); setError(''); - const pendingFiles = files.filter(f => f.status === 'pending' || f.status === 'error'); - const results: { name: string; success: boolean }[] = []; + const pendingFiles = files.filter(f => f.status === 'pending' || f.status === 'error' || f.status === 'timeout'); + const results: { name: string; success: boolean; errorCode?: string }[] = []; + + if (pendingFiles.length === 0) { + setUploading(false); + return; + } + + console.log(`[UPLOAD_DEBUG] Starting upload of ${pendingFiles.length} files`); + + // Initialize progress tracking + setUploadProgress({ completed: 0, total: pendingFiles.length, failed: 0 }); + + // Limit concurrent uploads to prevent overwhelming the server + const maxConcurrentUploads = 3; + const uploadQueue: FileItem[] = [...pendingFiles]; + const activeUploads: Promise[] = []; + + const processNextUpload = async (): Promise => { + if (uploadQueue.length === 0) return; + + const fileItem = uploadQueue.shift()!; + + try { + await uploadFile(fileItem); + results.push({ name: fileItem.file.name, success: true }); + setUploadProgress(prev => ({ ...prev, completed: prev.completed + 1 })); + console.log(`[UPLOAD_DEBUG] Upload succeeded for ${fileItem.file.name}`); + } catch (error) { + const errorCode = error?.response?.data?.error_code || 'UNKNOWN_ERROR'; + results.push({ name: fileItem.file.name, success: false, errorCode }); + setUploadProgress(prev => ({ ...prev, completed: prev.completed + 1, failed: prev.failed + 1 })); + console.error(`[UPLOAD_DEBUG] Upload failed for ${fileItem.file.name}:`, error); + } + + // Process next file in queue + if (uploadQueue.length > 0) { + return processNextUpload(); + } + }; try { - await Promise.allSettled(pendingFiles.map(async (file) => { - try { - await uploadFile(file); - results.push({ name: file.file.name, success: true }); - } catch (error) { - results.push({ name: file.file.name, success: false }); - } - })); + // Start initial batch of concurrent uploads + for (let i = 0; i < Math.min(maxConcurrentUploads, uploadQueue.length); i++) { + activeUploads.push(processNextUpload()); + } + + // Wait for all uploads to complete + await Promise.allSettled(activeUploads); + + console.log(`[UPLOAD_DEBUG] Batch upload completed. ${results.filter(r => r.success).length} succeeded, ${results.filter(r => !r.success).length} failed`); // Trigger notification based on results const hasFailures = results.some(r => !r.success); @@ -299,10 +371,26 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { } else { addBatchNotification('warning', 'upload', results); } + + // Show detailed error information if there were failures + if (hasFailures) { + const errorsByType = results + .filter(r => !r.success) + .reduce((acc, r) => { + const key = r.errorCode || 'UNKNOWN_ERROR'; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {} as Record); + + console.warn('[UPLOAD_DEBUG] Upload failures by error type:', errorsByType); + } } catch (error) { - setError('Upload failed. Please try again.'); + console.error('[UPLOAD_DEBUG] Critical error during batch upload:', error); + setError('Upload failed due to an unexpected error. Please refresh the page and try again.'); } finally { setUploading(false); + // Reset concurrent uploads tracking + setConcurrentUploads(new Set()); } }; @@ -326,6 +414,8 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { switch (status) { case 'success': return theme.palette.success.main; case 'error': return theme.palette.error.main; + case 'timeout': return theme.palette.warning.main; + case 'cancelled': return theme.palette.text.disabled; case 'uploading': return theme.palette.primary.main; default: return theme.palette.text.secondary; } @@ -335,6 +425,8 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { switch (status) { case 'success': return ; case 'error': return ; + case 'timeout': return ; + case 'cancelled': return ; case 'uploading': return ; default: return ; } @@ -484,10 +576,14 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { variant="contained" size="small" onClick={uploadAllFiles} - disabled={uploading || !files.some(f => f.status === 'pending' || f.status === 'error')} + disabled={uploading || !files.some(f => f.status === 'pending' || f.status === 'error' || f.status === 'timeout')} sx={{ borderRadius: 2 }} > - {uploading ? 'Uploading...' : 'Upload All'} + {uploading ? ( + uploadProgress.total > 0 ? + `Uploading... (${uploadProgress.completed}/${uploadProgress.total})` : + 'Uploading...' + ) : 'Upload All'} @@ -551,8 +647,18 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { )} {fileItem.error && ( - + {fileItem.error} + {fileItem.retryCount && fileItem.retryCount > 0 && ( + + (Attempt {fileItem.retryCount + 1}) + + )} )} @@ -561,7 +667,7 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { - {fileItem.status === 'error' && ( + {(fileItem.status === 'error' || fileItem.status === 'timeout') && ( { @@ -569,6 +675,7 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { retryUpload(fileItem); }} sx={{ color: 'primary.main' }} + title={`Retry upload${fileItem.errorCode ? ` (Error: ${fileItem.errorCode})` : ''}`} > diff --git a/src/routes/documents/crud.rs b/src/routes/documents/crud.rs index 8d2b5bf..d7c4e0f 100644 --- a/src/routes/documents/crud.rs +++ b/src/routes/documents/crud.rs @@ -25,21 +25,34 @@ pub enum DocumentError { Conflict(String), PayloadTooLarge(String), InternalServerError(String), + UploadTimeout(String), + DatabaseConstraintViolation(String), + OcrProcessingError(String), + FileProcessingError(String), + ConcurrentUploadError(String), } impl IntoResponse for DocumentError { fn into_response(self) -> Response { - let (status, message) = match self { - DocumentError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), - DocumentError::NotFound => (StatusCode::NOT_FOUND, "Document not found".to_string()), - DocumentError::Conflict(msg) => (StatusCode::CONFLICT, msg), - DocumentError::PayloadTooLarge(msg) => (StatusCode::PAYLOAD_TOO_LARGE, msg), - DocumentError::InternalServerError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), + let (status, message, error_code) = match self { + DocumentError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg, "UPLOAD_BAD_REQUEST"), + DocumentError::NotFound => (StatusCode::NOT_FOUND, "Document not found".to_string(), "UPLOAD_NOT_FOUND"), + DocumentError::Conflict(msg) => (StatusCode::CONFLICT, msg, "UPLOAD_CONFLICT"), + DocumentError::PayloadTooLarge(msg) => (StatusCode::PAYLOAD_TOO_LARGE, msg, "UPLOAD_TOO_LARGE"), + DocumentError::InternalServerError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg, "UPLOAD_INTERNAL_ERROR"), + DocumentError::UploadTimeout(msg) => (StatusCode::REQUEST_TIMEOUT, msg, "UPLOAD_TIMEOUT"), + DocumentError::DatabaseConstraintViolation(msg) => (StatusCode::CONFLICT, msg, "UPLOAD_DB_CONSTRAINT"), + DocumentError::OcrProcessingError(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg, "UPLOAD_OCR_ERROR"), + DocumentError::FileProcessingError(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg, "UPLOAD_FILE_PROCESSING_ERROR"), + DocumentError::ConcurrentUploadError(msg) => (StatusCode::TOO_MANY_REQUESTS, msg, "UPLOAD_CONCURRENT_ERROR"), }; (status, Json(json!({ "error": message, - "status": status.as_u16() + "status": status.as_u16(), + "error_code": error_code, + "timestamp": chrono::Utc::now().to_rfc3339(), + "request_id": uuid::Uuid::new_v4() }))).into_response() } } @@ -192,6 +205,9 @@ pub async fn upload_document( file_service, ); + debug!("[UPLOAD_DEBUG] Calling ingestion service for file: {}", filename); + let ingestion_start = std::time::Instant::now(); + match ingestion_service.ingest_from_file_info( &file_info, data, @@ -274,8 +290,21 @@ pub async fn upload_document( Err(DocumentError::Conflict(error_msg)) } Err(e) => { - let error_msg = format!("Failed to ingest document: {}", e); - error!("{}", error_msg); + let ingestion_duration = ingestion_start.elapsed(); + let error_msg = format!("Failed to ingest document: {} (failed after {:?})", e, ingestion_duration); + error!("[UPLOAD_DEBUG] {}", error_msg); + + // Categorize the error for better client handling + if e.to_string().contains("constraint") || e.to_string().contains("duplicate") { + return Err(DocumentError::DatabaseConstraintViolation(format!("Database constraint violation during upload: {}", e))); + } else if e.to_string().contains("timeout") { + return Err(DocumentError::UploadTimeout(format!("Upload processing timed out: {}", e))); + } else if e.to_string().contains("ocr") || e.to_string().contains("processing") { + return Err(DocumentError::OcrProcessingError(format!("OCR processing error: {}", e))); + } else if e.to_string().contains("file") || e.to_string().contains("read") { + return Err(DocumentError::FileProcessingError(format!("File processing error: {}", e))); + } + Err(DocumentError::InternalServerError(error_msg)) } }