fix(upload): resolve upload hanging with comprehensive error handling and timeouts
This commit is contained in:
parent
2e6c1ef238
commit
79d1d6ad19
|
|
@ -47,17 +47,20 @@ interface UploadedDocument {
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
file: File;
|
file: File;
|
||||||
id: string;
|
id: string;
|
||||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
status: 'pending' | 'uploading' | 'success' | 'error' | 'timeout' | 'cancelled';
|
||||||
progress: number;
|
progress: number;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
errorCode?: string;
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
|
uploadStartTime?: number;
|
||||||
|
retryCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UploadZoneProps {
|
interface UploadZoneProps {
|
||||||
onUploadComplete?: (document: UploadedDocument) => void;
|
onUploadComplete?: (document: UploadedDocument) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileStatus = 'pending' | 'uploading' | 'success' | 'error';
|
type FileStatus = 'pending' | 'uploading' | 'success' | 'error' | 'timeout' | 'cancelled';
|
||||||
|
|
||||||
const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
@ -65,6 +68,8 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
const { addBatchNotification } = useNotifications();
|
const { addBatchNotification } = useNotifications();
|
||||||
const [files, setFiles] = useState<FileItem[]>([]);
|
const [files, setFiles] = useState<FileItem[]>([]);
|
||||||
const [uploading, setUploading] = useState<boolean>(false);
|
const [uploading, setUploading] = useState<boolean>(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<{ completed: number; total: number; failed: number }>({ completed: 0, total: 0, failed: 0 });
|
||||||
|
const [concurrentUploads, setConcurrentUploads] = useState<Set<string>>(new Set());
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
const [selectedLabels, setSelectedLabels] = useState<LabelData[]>([]);
|
const [selectedLabels, setSelectedLabels] = useState<LabelData[]>([]);
|
||||||
const [availableLabels, setAvailableLabels] = useState<LabelData[]>([]);
|
const [availableLabels, setAvailableLabels] = useState<LabelData[]>([]);
|
||||||
|
|
@ -161,6 +166,7 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
status: 'pending' as FileStatus,
|
status: 'pending' as FileStatus,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
error: null,
|
error: null,
|
||||||
|
retryCount: 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setFiles(prev => [...prev, ...newFiles]);
|
setFiles(prev => [...prev, ...newFiles]);
|
||||||
|
|
@ -185,7 +191,18 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
setFiles(prev => prev.filter(f => f.id !== fileId));
|
setFiles(prev => prev.filter(f => f.id !== fileId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadFile = async (fileItem: FileItem): Promise<void> => {
|
const uploadFile = async (fileItem: FileItem, isRetry: boolean = false): Promise<void> => {
|
||||||
|
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();
|
const formData = new FormData();
|
||||||
formData.append('file', fileItem.file);
|
formData.append('file', fileItem.file);
|
||||||
|
|
||||||
|
|
@ -203,9 +220,11 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const uploadStartTime = Date.now();
|
||||||
|
|
||||||
setFiles(prev => prev.map(f =>
|
setFiles(prev => prev.map(f =>
|
||||||
f.id === fileItem.id
|
f.id === fileItem.id
|
||||||
? { ...f, status: 'uploading' as FileStatus, progress: 0 }
|
? { ...f, status: 'uploading' as FileStatus, progress: 0, uploadStartTime, error: null, errorCode: undefined }
|
||||||
: f
|
: f
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -237,6 +256,8 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
|
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
|
||||||
let errorMessage = 'Upload failed';
|
let errorMessage = 'Upload failed';
|
||||||
|
let errorCode = 'UNKNOWN_ERROR';
|
||||||
|
let newStatus: FileStatus = 'error';
|
||||||
|
|
||||||
// Handle specific document upload errors
|
// Handle specific document upload errors
|
||||||
if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_TOO_LARGE)) {
|
if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_TOO_LARGE)) {
|
||||||
|
|
@ -262,31 +283,82 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
f.id === fileItem.id
|
f.id === fileItem.id
|
||||||
? {
|
? {
|
||||||
...f,
|
...f,
|
||||||
status: 'error' as FileStatus,
|
status: newStatus,
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
|
errorCode,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
}
|
}
|
||||||
: f
|
: f
|
||||||
));
|
));
|
||||||
|
} finally {
|
||||||
|
setConcurrentUploads(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(uploadId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadAllFiles = async (): Promise<void> => {
|
const uploadAllFiles = async (): Promise<void> => {
|
||||||
|
if (uploading) {
|
||||||
|
console.warn('[UPLOAD_DEBUG] Upload already in progress, ignoring request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
const pendingFiles = files.filter(f => f.status === 'pending' || f.status === 'error');
|
const pendingFiles = files.filter(f => f.status === 'pending' || f.status === 'error' || f.status === 'timeout');
|
||||||
const results: { name: string; success: boolean }[] = [];
|
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<void>[] = [];
|
||||||
|
|
||||||
|
const processNextUpload = async (): Promise<void> => {
|
||||||
|
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 {
|
try {
|
||||||
await Promise.allSettled(pendingFiles.map(async (file) => {
|
// Start initial batch of concurrent uploads
|
||||||
try {
|
for (let i = 0; i < Math.min(maxConcurrentUploads, uploadQueue.length); i++) {
|
||||||
await uploadFile(file);
|
activeUploads.push(processNextUpload());
|
||||||
results.push({ name: file.file.name, success: true });
|
}
|
||||||
} catch (error) {
|
|
||||||
results.push({ name: file.file.name, success: false });
|
// 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
|
// Trigger notification based on results
|
||||||
const hasFailures = results.some(r => !r.success);
|
const hasFailures = results.some(r => !r.success);
|
||||||
|
|
@ -299,10 +371,26 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
} else {
|
} else {
|
||||||
addBatchNotification('warning', 'upload', results);
|
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<string, number>);
|
||||||
|
|
||||||
|
console.warn('[UPLOAD_DEBUG] Upload failures by error type:', errorsByType);
|
||||||
|
}
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
// Reset concurrent uploads tracking
|
||||||
|
setConcurrentUploads(new Set());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -326,6 +414,8 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'success': return theme.palette.success.main;
|
case 'success': return theme.palette.success.main;
|
||||||
case 'error': return theme.palette.error.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;
|
case 'uploading': return theme.palette.primary.main;
|
||||||
default: return theme.palette.text.secondary;
|
default: return theme.palette.text.secondary;
|
||||||
}
|
}
|
||||||
|
|
@ -335,6 +425,8 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'success': return <CheckIcon />;
|
case 'success': return <CheckIcon />;
|
||||||
case 'error': return <ErrorIcon />;
|
case 'error': return <ErrorIcon />;
|
||||||
|
case 'timeout': return <ErrorIcon />;
|
||||||
|
case 'cancelled': return <DeleteIcon />;
|
||||||
case 'uploading': return <UploadIcon />;
|
case 'uploading': return <UploadIcon />;
|
||||||
default: return <FileIcon />;
|
default: return <FileIcon />;
|
||||||
}
|
}
|
||||||
|
|
@ -484,10 +576,14 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={uploadAllFiles}
|
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 }}
|
sx={{ borderRadius: 2 }}
|
||||||
>
|
>
|
||||||
{uploading ? 'Uploading...' : 'Upload All'}
|
{uploading ? (
|
||||||
|
uploadProgress.total > 0 ?
|
||||||
|
`Uploading... (${uploadProgress.completed}/${uploadProgress.total})` :
|
||||||
|
'Uploading...'
|
||||||
|
) : 'Upload All'}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -551,8 +647,18 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{fileItem.error && (
|
{fileItem.error && (
|
||||||
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 0.5 }}>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color={fileItem.status === 'timeout' ? 'warning' : 'error'}
|
||||||
|
sx={{ display: 'block', mt: 0.5 }}
|
||||||
|
title={fileItem.errorCode ? `Error Code: ${fileItem.errorCode}` : undefined}
|
||||||
|
>
|
||||||
{fileItem.error}
|
{fileItem.error}
|
||||||
|
{fileItem.retryCount && fileItem.retryCount > 0 && (
|
||||||
|
<span style={{ marginLeft: '8px', fontSize: '0.8em' }}>
|
||||||
|
(Attempt {fileItem.retryCount + 1})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -561,7 +667,7 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
|
|
||||||
<ListItemSecondaryAction>
|
<ListItemSecondaryAction>
|
||||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
{fileItem.status === 'error' && (
|
{(fileItem.status === 'error' || fileItem.status === 'timeout') && (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -569,6 +675,7 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
retryUpload(fileItem);
|
retryUpload(fileItem);
|
||||||
}}
|
}}
|
||||||
sx={{ color: 'primary.main' }}
|
sx={{ color: 'primary.main' }}
|
||||||
|
title={`Retry upload${fileItem.errorCode ? ` (Error: ${fileItem.errorCode})` : ''}`}
|
||||||
>
|
>
|
||||||
<RefreshIcon fontSize="small" />
|
<RefreshIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
|
||||||
|
|
@ -25,21 +25,34 @@ pub enum DocumentError {
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
PayloadTooLarge(String),
|
PayloadTooLarge(String),
|
||||||
InternalServerError(String),
|
InternalServerError(String),
|
||||||
|
UploadTimeout(String),
|
||||||
|
DatabaseConstraintViolation(String),
|
||||||
|
OcrProcessingError(String),
|
||||||
|
FileProcessingError(String),
|
||||||
|
ConcurrentUploadError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for DocumentError {
|
impl IntoResponse for DocumentError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, message) = match self {
|
let (status, message, error_code) = match self {
|
||||||
DocumentError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
|
DocumentError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg, "UPLOAD_BAD_REQUEST"),
|
||||||
DocumentError::NotFound => (StatusCode::NOT_FOUND, "Document not found".to_string()),
|
DocumentError::NotFound => (StatusCode::NOT_FOUND, "Document not found".to_string(), "UPLOAD_NOT_FOUND"),
|
||||||
DocumentError::Conflict(msg) => (StatusCode::CONFLICT, msg),
|
DocumentError::Conflict(msg) => (StatusCode::CONFLICT, msg, "UPLOAD_CONFLICT"),
|
||||||
DocumentError::PayloadTooLarge(msg) => (StatusCode::PAYLOAD_TOO_LARGE, msg),
|
DocumentError::PayloadTooLarge(msg) => (StatusCode::PAYLOAD_TOO_LARGE, msg, "UPLOAD_TOO_LARGE"),
|
||||||
DocumentError::InternalServerError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
|
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!({
|
(status, Json(json!({
|
||||||
"error": message,
|
"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()
|
}))).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -192,6 +205,9 @@ pub async fn upload_document(
|
||||||
file_service,
|
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(
|
match ingestion_service.ingest_from_file_info(
|
||||||
&file_info,
|
&file_info,
|
||||||
data,
|
data,
|
||||||
|
|
@ -274,8 +290,21 @@ pub async fn upload_document(
|
||||||
Err(DocumentError::Conflict(error_msg))
|
Err(DocumentError::Conflict(error_msg))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let error_msg = format!("Failed to ingest document: {}", e);
|
let ingestion_duration = ingestion_start.elapsed();
|
||||||
error!("{}", error_msg);
|
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))
|
Err(DocumentError::InternalServerError(error_msg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue