diff --git a/src/app/core/constants.ts b/src/app/core/constants.ts
index 6a510e3921..3241a66277 100644
--- a/src/app/core/constants.ts
+++ b/src/app/core/constants.ts
@@ -1,8 +1,9 @@
export const HUNDRED_TB = 109951162777600;
export const HTTP_STATUS_CODES = {
- MAX_SPACE_USED: 420,
+ PAYMENT_REQUIRED: 402,
FORBIDDEN: 403,
NOT_FOUND: 404,
+ MAX_SPACE_USED: 420,
INTERNAL_SERVER_ERROR: 500,
};
export enum ErrorMessages {
diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json
index 0753e29fac..f6c3213cdb 100644
--- a/src/app/i18n/locales/de.json
+++ b/src/app/i18n/locales/de.json
@@ -1291,7 +1291,8 @@
"allProcessesHaveFinished": "Alle Prozesse sind abgeschlossen",
"processing": "Wird bearbeitet {{pending}} von {{finished}}",
"failedToUpload": "Fehler beim Hochladen",
- "failedToDownload": "Fehler beim Herunterladen"
+ "failedToDownload": "Fehler beim Herunterladen",
+ "notAllowed": "Nicht erlaubt"
},
"waiting": "Warten",
"paused": "Pausiert",
@@ -1414,7 +1415,8 @@
"errors": {}
},
"subtitles": {
- "upload-failed": "Upload fehlgeschlagen"
+ "upload-failed": "Upload fehlgeschlagen",
+ "upload-partially-failed": "Einige Dateien konnten nicht hochgeladen werden"
},
"generalErrorMessages": {
"retryUploadFailed": "Beim erneuten Hochladen ist etwas schiefgegangen. Versuche es erneut",
diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json
index b8175b3871..e455ad9a10 100644
--- a/src/app/i18n/locales/en.json
+++ b/src/app/i18n/locales/en.json
@@ -1376,7 +1376,8 @@
"allProcessesHaveFinished": "All processes have finished",
"processing": "Processing {{pending}} of {{finished}}",
"failedToUpload": "Failed to upload",
- "failedToDownload": "Failed to download"
+ "failedToDownload": "Failed to download",
+ "notAllowed": "Not allowed"
},
"waiting": "Waiting",
"paused": "Paused",
@@ -1491,7 +1492,8 @@
"errors": {}
},
"subtitles": {
- "upload-failed": "Upload failed"
+ "upload-failed": "Upload failed",
+ "upload-partially-failed": "Some files could not be uploaded"
},
"generalErrorMessages": {
"retryUploadFailed": "Something went wrong when retrying the upload. Try uploading again",
diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json
index 78a8608dda..3f293c8da8 100644
--- a/src/app/i18n/locales/es.json
+++ b/src/app/i18n/locales/es.json
@@ -1353,7 +1353,8 @@
"allProcessesHaveFinished": "Todos los procesos han finalizado",
"processing": "Procesando {{pending}} de {{finished}}",
"failedToUpload": "Error al subir",
- "failedToDownload": "Error al descargar"
+ "failedToDownload": "Error al descargar",
+ "notAllowed": "No permitido"
},
"waiting": "Esperando",
"paused": "Pausado",
@@ -1469,7 +1470,8 @@
"errors": {}
},
"subtitles": {
- "upload-failed": "Error al subir"
+ "upload-failed": "Error al subir",
+ "upload-partially-failed": "Algunos archivos no se pudieron subir"
},
"generalErrorMessages": {
"retryUploadFailed": "Algo salió mal al reintentar la subida. Intenta subirlo de nuevo",
diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json
index dd2910a24a..1d100d241d 100644
--- a/src/app/i18n/locales/fr.json
+++ b/src/app/i18n/locales/fr.json
@@ -1304,7 +1304,8 @@
"allProcessesHaveFinished": "Tous les processus ont été complétés",
"processing": "Traitement {{pending}} de {{finished}}",
"failedToUpload": "Échec du téléchargement",
- "failedToDownload": "Échec du téléchargement"
+ "failedToDownload": "Échec du téléchargement",
+ "notAllowed": "Non autorisé"
},
"waiting": "En attente",
"paused": "En pause",
@@ -1419,7 +1420,8 @@
"errors": {}
},
"subtitles": {
- "upload-failed": "Échec du téléchargement"
+ "upload-failed": "Échec du téléchargement",
+ "upload-partially-failed": "Certains fichiers n'ont pas pu être téléchargés"
},
"generalErrorMessages": {
"retryUploadFailed": "Quelque chose s'est mal passé en réessayant de télécharger. Essayez de télécharger à nouveau",
diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json
index 9c0c68ecc9..84ab3e0df6 100644
--- a/src/app/i18n/locales/it.json
+++ b/src/app/i18n/locales/it.json
@@ -1411,7 +1411,8 @@
"allProcessesHaveFinished": "Tutti i processi sono stati completati",
"processing": "Elaborazione {{pending}} di {{finished}}",
"failedToUpload": "Caricamento fallito",
- "failedToDownload": "Download fallito"
+ "failedToDownload": "Download fallito",
+ "notAllowed": "Non consentito"
},
"waiting": "In attesa",
"paused": "In pausa",
@@ -1526,7 +1527,8 @@
"errors": {}
},
"subtitles": {
- "upload-failed": "Caricamento fallito"
+ "upload-failed": "Caricamento fallito",
+ "upload-partially-failed": "Alcuni file non sono stati caricati"
},
"generalErrorMessages": {
"retryUploadFailed": "Qualcosa è andato storto nel riprovare a caricare. Prova a caricare di nuovo",
diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json
index 2fadc1d4ec..2766f7e6cb 100644
--- a/src/app/i18n/locales/ru.json
+++ b/src/app/i18n/locales/ru.json
@@ -1319,7 +1319,8 @@
"allProcessesHaveFinished": "Все процессы завершены",
"processing": "Обработка {{pending}} из {{finished}}",
"failedToUpload": "Не удалось загрузить",
- "failedToDownload": "Не удалось загрузить"
+ "failedToDownload": "Не удалось загрузить",
+ "notAllowed": "Не разрешено"
},
"waiting": "Ожидание",
"paused": "Приостановлено",
@@ -1434,7 +1435,8 @@
"errors": {}
},
"subtitles": {
- "upload-failed": "Не удалось загрузить"
+ "upload-failed": "Не удалось загрузить",
+ "upload-partially-failed": "Некоторые файлы не удалось загрузить"
},
"generalErrorMessages": {
"retryUploadFailed": "Что-то пошло не так при повторной попытке загрузки. Попробуйте загрузить снова",
diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json
index 83ee1f5a01..ae7abe4d4f 100644
--- a/src/app/i18n/locales/tw.json
+++ b/src/app/i18n/locales/tw.json
@@ -1306,7 +1306,8 @@
"allProcessesHaveFinished": "所有進程已完成",
"processing": "處理中 {{pending}} 個,共 {{finished}} 個",
"failedToUpload": "上傳失敗",
- "failedToDownload": "下載失敗"
+ "failedToDownload": "下載失敗",
+ "notAllowed": "不允許"
},
"waiting": "等待中",
"paused": "已暫停",
@@ -1421,7 +1422,8 @@
"errors": {}
},
"subtitles": {
- "upload-failed": "上傳失敗"
+ "upload-failed": "上傳失敗",
+ "upload-partially-failed": "部分檔案無法上傳"
},
"generalErrorMessages": {
"retryUploadFailed": "重試上傳時出現問題。請重新上傳",
diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json
index 6a355bb30e..3ebc502b21 100644
--- a/src/app/i18n/locales/zh.json
+++ b/src/app/i18n/locales/zh.json
@@ -1341,7 +1341,8 @@
"allProcessesHaveFinished": "所有处理都已完成",
"processing": "正在处理 {{pending}}/{{finished}}",
"failedToUpload": "上傳失敗",
- "failedToDownload": "下載失敗"
+ "failedToDownload": "下載失敗",
+ "notAllowed": "不允許"
},
"waiting": "等待中",
"paused": "暂停",
@@ -1456,7 +1457,8 @@
"errors": {}
},
"subtitles": {
- "upload-failed": "上传失败"
+ "upload-failed": "上传失败",
+ "upload-partially-failed": "部分文件无法上传"
},
"generalErrorMessages": {
"retryUploadFailed": "重试上传时出了问题。请再次上传",
diff --git a/src/app/network/RetryManager.ts b/src/app/network/RetryManager.ts
index bb3a1e1148..b2bf135082 100644
--- a/src/app/network/RetryManager.ts
+++ b/src/app/network/RetryManager.ts
@@ -3,6 +3,7 @@ export type RetryableTask = {
type: 'upload' | 'download';
params: any;
status?: 'pending' | 'failed' | 'retrying';
+ retryable?: boolean;
};
class RetryManager {
diff --git a/src/app/network/UploadFolderManager.test.ts b/src/app/network/UploadFolderManager.test.ts
index 9ae36c1d3c..6f759d4520 100644
--- a/src/app/network/UploadFolderManager.test.ts
+++ b/src/app/network/UploadFolderManager.test.ts
@@ -10,6 +10,10 @@ import { TaskFolder, UploadFoldersManager, uploadFoldersWithManager } from './Up
import * as networkInformation from './networkInformation';
import { FilesExceedsSizeLimitError } from 'app/drive/services/file.service/upload.errors';
import { uploadItemsParallelThunk } from 'app/store/slices/storage/storage.thunks/uploadItemsThunk';
+import { deleteItemsThunk } from '../store/slices/storage/storage.thunks/deleteItemsThunk';
+import { TaskStatus } from 'app/tasks/types';
+import newStorageService from 'app/drive/services/new-storage.service';
+import referralService from 'services/referral.service';
vi.mock('app/drive/services/new-storage.service', () => ({
default: {
@@ -82,6 +86,29 @@ vi.mock('services/error.service', () => ({
},
}));
+const buildFolder = (overrides: Partial = {}): DriveFolderData => ({
+ id: 0,
+ uuid: 'uuid',
+ name: 'Folder',
+ bucket: 'bucket',
+ parentId: 0,
+ parent_id: 0,
+ parentUuid: 'parentUuid',
+ userId: 0,
+ user_id: 0,
+ icon: null,
+ iconId: null,
+ icon_id: null,
+ isFolder: true,
+ color: null,
+ encrypt_version: null,
+ plain_name: 'Folder',
+ deleted: false,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ ...overrides,
+});
+
describe('checkUploadFolders', () => {
const mockDispatch = vi.fn();
@@ -369,6 +396,81 @@ describe('checkUploadFolders', () => {
expect(logNetworkInfoMock).toHaveBeenCalledWith({ folderName: 'MyFolder' });
});
+ it('should mark the task as Error but keep the already-uploaded content when a child file upload fails', async () => {
+ const mockFolder: DriveFolderData = {
+ id: 0,
+ uuid: 'uuid',
+ name: 'FolderWithFailingFile',
+ bucket: 'bucket',
+ parentId: 0,
+ parent_id: 0,
+ parentUuid: 'parentUuid',
+ userId: 0,
+ user_id: 0,
+ icon: null,
+ iconId: null,
+ icon_id: null,
+ isFolder: true,
+ color: null,
+ encrypt_version: null,
+ plain_name: 'FolderWithFailingFile',
+ deleted: false,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+ const taskId = 'task-id';
+
+ (createFolder as Mock).mockResolvedValueOnce(mockFolder);
+ (checkFolderDuplicated as Mock).mockResolvedValueOnce({
+ duplicatedFoldersResponse: [],
+ foldersWithDuplicates: [],
+ foldersWithoutDuplicates: [mockFolder],
+ });
+
+ // The file upload thunk rejects (e.g. a 402 Payment Required response)
+ const dispatch = vi.fn().mockReturnValue({ unwrap: () => Promise.reject(new AppError('Payment Required')) });
+
+ vi.spyOn(tasksService, 'create').mockReturnValue(taskId);
+ const updateTaskSpy = vi.spyOn(tasksService, 'updateTask').mockReturnValue();
+ vi.spyOn(tasksService, 'getTasks').mockReturnValue([]);
+ // A file failure no longer marks the task as Error mid-flight; it stays in process until the
+ // whole folder finishes, at which point it is marked as Error (partial upload) by the manager
+ vi.spyOn(tasksService, 'findTask').mockReturnValue({ id: taskId, status: TaskStatus.InProcess } as never);
+ vi.spyOn(tasksService, 'addListener').mockReturnValue();
+ vi.spyOn(tasksService, 'removeListener').mockReturnValue();
+ vi.spyOn(errorService, 'castError').mockImplementation((e) => e as never);
+
+ await uploadFoldersWithManager({
+ payload: [
+ {
+ currentFolderId: 'currentFolderId',
+ root: {
+ folderId: mockFolder.uuid,
+ childrenFiles: [new File([new ArrayBuffer(10)], 'failing.txt')],
+ childrenFolders: [],
+ name: mockFolder.name,
+ fullPathEdited: 'path1',
+ },
+ options: { taskId },
+ },
+ ],
+ selectedWorkspace: null,
+ dispatch,
+ maxUploadFileSize: 100,
+ });
+
+ // The task must never be marked as Success
+ expect(updateTaskSpy).not.toHaveBeenCalledWith(
+ expect.objectContaining({ merge: expect.objectContaining({ status: TaskStatus.Success }) }),
+ );
+ // The task must be marked as Error (the subtitle key is resolved by i18n in the real app)
+ expect(updateTaskSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ merge: expect.objectContaining({ status: TaskStatus.Error }) }),
+ );
+ // The already-uploaded content must be preserved: the root folder must NOT be deleted
+ expect(deleteItemsThunk).not.toHaveBeenCalled();
+ });
+
describe('Handle File Uploads', () => {
const taskId = 'task-id';
const mockCreatedFolder: DriveFolderData = {
@@ -433,6 +535,237 @@ describe('checkUploadFolders', () => {
});
});
+ it('should keep uploading the rest of the tree and not delete the root folder when a file fails', async () => {
+ const mockRootFolder: DriveFolderData = {
+ id: 1,
+ uuid: 'root-uuid',
+ name: 'Root',
+ bucket: 'bucket',
+ parentId: 0,
+ parent_id: 0,
+ parentUuid: 'parentUuid',
+ userId: 0,
+ user_id: 0,
+ icon: null,
+ iconId: null,
+ icon_id: null,
+ isFolder: true,
+ color: null,
+ encrypt_version: null,
+ plain_name: 'Root',
+ deleted: false,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+ const mockChildFolder: DriveFolderData = { ...mockRootFolder, id: 2, uuid: 'child-uuid', name: 'Child' };
+ const taskId = 'task-id';
+ const failingFile = new File([new ArrayBuffer(0)], 'empty.txt');
+
+ const createFolderSpy = (createFolder as Mock)
+ .mockResolvedValueOnce(mockRootFolder)
+ .mockResolvedValueOnce(mockChildFolder);
+ (checkFolderDuplicated as Mock).mockResolvedValueOnce({
+ duplicatedFoldersResponse: [] as DriveFolderData[],
+ foldersWithDuplicates: [] as DriveFolderData[],
+ foldersWithoutDuplicates: [mockRootFolder],
+ });
+
+ const failingDispatch = vi
+ .fn()
+ .mockReturnValue({ unwrap: () => Promise.reject(new AppError('Payment required', 402)) });
+
+ const updateTaskSpy = vi.spyOn(tasksService, 'updateTask').mockReturnValue();
+ vi.spyOn(tasksService, 'create').mockReturnValue(taskId);
+ vi.spyOn(tasksService, 'getTasks').mockReturnValue([]);
+ vi.spyOn(tasksService, 'addListener').mockReturnValue();
+ vi.spyOn(tasksService, 'removeListener').mockReturnValue();
+
+ await uploadFoldersWithManager({
+ payload: [
+ {
+ currentFolderId: 'currentFolderId',
+ root: {
+ folderId: mockRootFolder.uuid,
+ childrenFiles: [failingFile],
+ childrenFolders: [
+ {
+ folderId: mockChildFolder.uuid,
+ childrenFiles: [],
+ childrenFolders: [],
+ name: mockChildFolder.name,
+ fullPathEdited: 'path/child',
+ },
+ ],
+ name: mockRootFolder.name,
+ fullPathEdited: 'path',
+ },
+ options: { taskId },
+ },
+ ],
+ selectedWorkspace: null,
+ dispatch: failingDispatch,
+ maxUploadFileSize: 100,
+ });
+
+ // The subfolder was still created -> the queue kept processing after the file failure
+ expect(createFolderSpy).toHaveBeenCalledTimes(2);
+ // The already-uploaded content was preserved: the root folder was NOT deleted
+ expect(newStorageService.deleteFolderByUuid as Mock).not.toHaveBeenCalled();
+ // The folder task ends as Error (partial upload), never as Success
+ const mergedStatuses = updateTaskSpy.mock.calls.map((call) => call[0]?.merge?.status);
+ expect(mergedStatuses).toContain(TaskStatus.Error);
+ expect(mergedStatuses).not.toContain(TaskStatus.Success);
+ });
+
+ it('should mark the task as Success and run the success side-effects when the whole folder uploads', async () => {
+ const mockFolder = buildFolder({ uuid: 'ok-uuid', name: 'OkFolder' });
+ const taskId = 'task-id';
+ const onSuccess = vi.fn();
+
+ (createFolder as Mock).mockResolvedValueOnce(mockFolder);
+ (checkFolderDuplicated as Mock).mockResolvedValueOnce({
+ duplicatedFoldersResponse: [] as DriveFolderData[],
+ foldersWithDuplicates: [] as DriveFolderData[],
+ foldersWithoutDuplicates: [mockFolder],
+ });
+
+ const updateTaskSpy = vi.spyOn(tasksService, 'updateTask').mockReturnValue();
+ vi.spyOn(tasksService, 'create').mockReturnValue(taskId);
+ vi.spyOn(tasksService, 'findTask').mockReturnValue({ id: taskId, status: TaskStatus.InProcess } as never);
+ vi.spyOn(tasksService, 'addListener').mockReturnValue();
+ vi.spyOn(tasksService, 'removeListener').mockReturnValue();
+
+ await uploadFoldersWithManager({
+ payload: [
+ {
+ currentFolderId: 'currentFolderId',
+ root: {
+ folderId: mockFolder.uuid,
+ childrenFiles: [],
+ childrenFolders: [],
+ name: mockFolder.name,
+ fullPathEdited: 'path',
+ },
+ options: { taskId, onSuccess },
+ },
+ ],
+ selectedWorkspace: null,
+ dispatch: mockDispatch,
+ maxUploadFileSize: 100,
+ });
+
+ expect(updateTaskSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ taskId, merge: expect.objectContaining({ status: TaskStatus.Success }) }),
+ );
+ expect(onSuccess).toHaveBeenCalledOnce();
+ expect(referralService.trackFolderUpload as Mock).toHaveBeenCalledOnce();
+ });
+
+ it('should keep the task as Error (not overwrite with Success) when the folder cannot be created', async () => {
+ const taskId = 'task-id';
+
+ // Both creation attempts fail (MAX_UPLOAD_ATTEMPTS = 2)
+ const createFolderSpy = (createFolder as Mock).mockRejectedValue(new Error('creation failed'));
+ (checkFolderDuplicated as Mock).mockResolvedValueOnce({
+ duplicatedFoldersResponse: [] as DriveFolderData[],
+ foldersWithDuplicates: [] as DriveFolderData[],
+ foldersWithoutDuplicates: [] as DriveFolderData[],
+ });
+
+ const updateTaskSpy = vi.spyOn(tasksService, 'updateTask').mockReturnValue();
+ vi.spyOn(tasksService, 'create').mockReturnValue(taskId);
+ vi.spyOn(tasksService, 'getTasks').mockReturnValue([]);
+ // killQueueAndNotifyError already marked the task as Error; run() must not overwrite it
+ vi.spyOn(tasksService, 'findTask').mockReturnValue({ id: taskId, status: TaskStatus.Error } as never);
+ vi.spyOn(tasksService, 'addListener').mockReturnValue();
+ vi.spyOn(tasksService, 'removeListener').mockReturnValue();
+
+ await uploadFoldersWithManager({
+ payload: [
+ {
+ currentFolderId: 'currentFolderId',
+ root: {
+ folderId: 'root',
+ childrenFiles: [],
+ childrenFolders: [],
+ name: 'F',
+ fullPathEdited: 'path',
+ },
+ options: { taskId },
+ },
+ ],
+ selectedWorkspace: null,
+ dispatch: mockDispatch,
+ maxUploadFileSize: 100,
+ });
+
+ // Retried twice, then gave up
+ expect(createFolderSpy).toHaveBeenCalledTimes(2);
+ const mergedStatuses = updateTaskSpy.mock.calls.map((call) => call[0]?.merge?.status);
+ expect(mergedStatuses).not.toContain(TaskStatus.Success);
+ });
+
+ it('should continue with the next folder when a previous folder fails partially', async () => {
+ const folderA = buildFolder({ uuid: 'a-uuid', name: 'A' });
+ const folderB = buildFolder({ uuid: 'b-uuid', name: 'B' });
+
+ const createFolderSpy = (createFolder as Mock).mockResolvedValueOnce(folderA).mockResolvedValueOnce(folderB);
+ (checkFolderDuplicated as Mock).mockResolvedValue({
+ duplicatedFoldersResponse: [] as DriveFolderData[],
+ foldersWithDuplicates: [] as DriveFolderData[],
+ foldersWithoutDuplicates: [] as DriveFolderData[],
+ });
+
+ // Only folder A has a (failing) file; folder B has none and must still be uploaded
+ const failingDispatch = vi
+ .fn()
+ .mockReturnValue({ unwrap: () => Promise.reject(new AppError('Payment required', 402)) });
+
+ const updateTaskSpy = vi.spyOn(tasksService, 'updateTask').mockReturnValue();
+ vi.spyOn(tasksService, 'create').mockReturnValue('task-id');
+ vi.spyOn(tasksService, 'getTasks').mockReturnValue([]);
+ vi.spyOn(tasksService, 'findTask').mockReturnValue({ id: 'task-b', status: TaskStatus.InProcess } as never);
+ vi.spyOn(tasksService, 'addListener').mockReturnValue();
+ vi.spyOn(tasksService, 'removeListener').mockReturnValue();
+
+ await uploadFoldersWithManager({
+ payload: [
+ {
+ currentFolderId: 'currentFolderId',
+ root: {
+ folderId: folderA.uuid,
+ childrenFiles: [new File([new ArrayBuffer(10)], 'a.txt')],
+ childrenFolders: [],
+ name: folderA.name,
+ fullPathEdited: 'a',
+ },
+ options: { taskId: 'task-a' },
+ },
+ {
+ currentFolderId: 'currentFolderId',
+ root: {
+ folderId: folderB.uuid,
+ childrenFiles: [],
+ childrenFolders: [],
+ name: folderB.name,
+ fullPathEdited: 'b',
+ },
+ options: { taskId: 'task-b' },
+ },
+ ],
+ selectedWorkspace: null,
+ dispatch: failingDispatch,
+ maxUploadFileSize: 100,
+ });
+
+ // Both folders were processed -> the loop did not stop on folder A's partial failure
+ expect(createFolderSpy).toHaveBeenCalledTimes(2);
+ // Folder B finished successfully
+ expect(updateTaskSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ taskId: 'task-b', merge: expect.objectContaining({ status: TaskStatus.Success }) }),
+ );
+ });
+
it('should abort the upload if abortController is called', async () => {
const mockParentFolder: DriveFolderData = {
id: 1,
diff --git a/src/app/network/UploadFolderManager.ts b/src/app/network/UploadFolderManager.ts
index 15c7f69d0d..12d30aad2e 100644
--- a/src/app/network/UploadFolderManager.ts
+++ b/src/app/network/UploadFolderManager.ts
@@ -51,6 +51,9 @@ interface TaskInfo {
itemsUploaded: number;
totalItems: number;
};
+ // Set when at least one file failed to upload. We keep the already-uploaded content
+ // (no rollback) and mark the task as errored once the whole folder finishes.
+ hasFailedFiles?: boolean;
}
const generateTaskIdForFolders = async (foldersPayload: UploadFolderPayload[]): Promise => {
@@ -216,7 +219,7 @@ export class UploadFoldersManager {
);
} catch {
if (uploadAttempts >= UploadFoldersManager.MAX_UPLOAD_ATTEMPTS) {
- this.stopUploadTask(taskId, abortController);
+ await this.stopUploadTask(taskId, abortController);
this.killQueueAndNotifyError(taskId);
return;
}
@@ -283,9 +286,10 @@ export class UploadFoldersManager {
)
.unwrap()
.catch(() => {
- this.stopUploadTask(taskId, abortController);
- this.killQueueAndNotifyError(taskId);
- return;
+ // A file failed to upload. Do NOT roll back: keep everything already uploaded and let
+ // the queue keep uploading the remaining files/folders (best-effort). The task is marked
+ // as errored once the whole folder finishes, in uploadFolderTask.
+ this.tasksInfo[taskId].hasFailedFiles = true;
});
};
@@ -359,6 +363,65 @@ export class UploadFoldersManager {
});
};
+ // Registers the queue pause/resume/cancel listeners for a folder task and returns a cleanup fn.
+ private readonly attachQueueListeners = (taskId: string): (() => void) => {
+ const cancelQueueListener = (task?: TaskData) => {
+ if (task?.id === taskId && task.status === TaskStatus.Cancelled) this.uploadFoldersQueue.kill();
+ };
+
+ const updateQueueListener = (task?: TaskData) => {
+ if (task?.id !== taskId) return;
+ if (task.status === TaskStatus.InProcess) this.uploadFoldersQueue.resume();
+ else if (task.status === TaskStatus.Paused) this.uploadFoldersQueue.pause();
+ };
+
+ tasksService.addListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener });
+ tasksService.addListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener });
+
+ return () => {
+ tasksService.removeListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener });
+ tasksService.removeListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener });
+ };
+ };
+
+ // Decides the final task status once the queue has drained: keep a fatal Error/Cancelled state,
+ // mark a partial failure, or finalize as Success and run the success side-effects.
+ private readonly finalizeFolderUpload = (taskFolder: TaskFolder, memberId?: string): void => {
+ const { root, taskId, options: payloadOptions } = taskFolder;
+
+ // The task may have been marked Error (a fatal failure such as folder creation) or Cancelled
+ // while the queue was running: don't overwrite that final state.
+ const finishedTask = tasksService.findTask(taskId);
+ if (finishedTask?.status === TaskStatus.Error || finishedTask?.status === TaskStatus.Cancelled) return;
+
+ if (this.tasksInfo[taskId].hasFailedFiles) {
+ // Some files failed, but the already-uploaded content is preserved (no rollback).
+ tasksService.updateTask({
+ taskId,
+ merge: { status: TaskStatus.Error, subtitle: t('tasks.subtitles.upload-partially-failed') as string },
+ });
+ return;
+ }
+
+ tasksService.updateTask({
+ taskId,
+ merge: {
+ itemUUID: { rootFolderUUID: this.tasksInfo[taskId].rootFolderItem?.uuid },
+ status: TaskStatus.Success,
+ },
+ });
+
+ const options = { withNotification: true, ...payloadOptions };
+ options.onSuccess?.();
+ referralService.trackFolderUpload();
+ logNetworkInfoForUpload({ folderName: root.name });
+
+ setTimeout(() => {
+ this.dispatch(planThunks.fetchUsageThunk());
+ if (memberId) this.dispatch(planThunks.fetchBusinessLimitUsageThunk());
+ }, 1000);
+ };
+
public readonly run = async (): Promise => {
const payloadWithTaskId = await generateTaskIdForFolders(this.payload);
@@ -375,8 +438,7 @@ export class UploadFoldersManager {
context.addEventListener('offline', connectionLostListener);
for (const taskFolder of payloadWithTaskId) {
- const { root, currentFolderId, options: payloadOptions, taskId } = taskFolder;
- const options = { withNotification: true, ...payloadOptions };
+ const { root, currentFolderId, taskId } = taskFolder;
this.tasksInfo[taskId] = {
progress: {
@@ -385,32 +447,9 @@ export class UploadFoldersManager {
},
};
- tasksService.updateTask({
- taskId,
- merge: {
- status: TaskStatus.InProcess,
- progress: 0,
- },
- });
+ tasksService.updateTask({ taskId, merge: { status: TaskStatus.InProcess, progress: 0 } });
- const cancelQueueListener = (task?: TaskData) => {
- const isCurrentTask = task && task.id === taskId;
- if (isCurrentTask && task.status === TaskStatus.Cancelled) {
- this.uploadFoldersQueue.kill();
- }
- };
-
- const updateQueueListener = (task?: TaskData) => {
- const isCurrentTask = task && task.id === taskId;
- if (isCurrentTask && task.status === TaskStatus.InProcess) {
- this.uploadFoldersQueue.resume();
- } else if (isCurrentTask && task.status === TaskStatus.Paused) {
- this.uploadFoldersQueue.pause();
- }
- };
-
- tasksService.addListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener });
- tasksService.addListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener });
+ const removeQueueListeners = this.attachQueueListeners(taskId);
try {
root.folderId = currentFolderId;
@@ -422,51 +461,30 @@ export class UploadFoldersManager {
if (connectionLost) throw new ConnectionLostError();
- tasksService.updateTask({
- taskId: taskId,
- merge: {
- itemUUID: { rootFolderUUID: this.tasksInfo[taskId].rootFolderItem?.uuid },
- status: TaskStatus.Success,
- },
- });
-
- options.onSuccess?.();
- referralService.trackFolderUpload();
- logNetworkInfoForUpload({ folderName: root.name });
-
- setTimeout(() => {
- this.dispatch(planThunks.fetchUsageThunk());
- if (memberId) this.dispatch(planThunks.fetchBusinessLimitUsageThunk());
- }, 1000);
+ this.finalizeFolderUpload(taskFolder, memberId);
} catch (err: unknown) {
const castedError = errorService.castError(err);
- const updatedTask = tasksService.findTask(taskId);
if (connectionLost) {
tasksService.updateTask({
- taskId: taskId,
- merge: {
- status: TaskStatus.Error,
- subtitle: t('error.connectionLostError') as string,
- },
+ taskId,
+ merge: { status: TaskStatus.Error, subtitle: t('error.connectionLostError') as string },
});
errorService.reportError(castedError);
break;
- } else if (updatedTask?.status !== TaskStatus.Cancelled && taskId === updatedTask?.id) {
+ }
+
+ const updatedTask = tasksService.findTask(taskId);
+ if (updatedTask?.status !== TaskStatus.Cancelled && taskId === updatedTask?.id) {
tasksService.updateTask({
- taskId: taskId,
- merge: {
- status: TaskStatus.Error,
- subtitle: t('tasks.subtitles.upload-failed') as string,
- },
+ taskId,
+ merge: { status: TaskStatus.Error, subtitle: t('tasks.subtitles.upload-failed') as string },
});
- // Log the error or report it but don't re-throw it to allow the next folder to be processed
+ // Report the error but keep going so the remaining folders are still processed.
errorService.reportError(castedError);
- continue;
}
} finally {
- tasksService.removeListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener });
- tasksService.removeListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener });
+ removeQueueListeners();
}
}
};
diff --git a/src/app/network/UploadManager.test.ts b/src/app/network/UploadManager.test.ts
index c879d30683..8f59590569 100644
--- a/src/app/network/UploadManager.test.ts
+++ b/src/app/network/UploadManager.test.ts
@@ -562,6 +562,108 @@ describe('checkUploadFiles', () => {
});
});
+ it('should keep uploading the remaining folder files when one file fails, then reject with the original error', async () => {
+ const bigSize = 60 * 1024 * 1024;
+ const failError = new AppError('Payment required', 402);
+ (uploadFile as Mock).mockImplementation((file: { name: string }) =>
+ file.name === 'fail.txt' ? Promise.reject(failError) : Promise.resolve(mockFile2),
+ );
+
+ vi.spyOn(tasksService, 'create').mockReturnValue('taskId');
+ vi.spyOn(tasksService, 'updateTask').mockReturnValue();
+ vi.spyOn(tasksService, 'addListener').mockReturnValue();
+ vi.spyOn(tasksService, 'removeListener').mockReturnValue();
+ vi.spyOn(errorService, 'castError').mockImplementation((e) => e as AppError);
+ vi.spyOn(errorService, 'reportError').mockReturnValue();
+
+ await expect(
+ uploadFileWithManager({
+ files: [
+ {
+ taskId: 'taskId',
+ relatedTaskId: 'folder-task',
+ filecontent: {
+ content: 'c1' as unknown as File,
+ type: 'text/plain',
+ name: 'fail.txt',
+ size: bigSize,
+ parentFolderId: 'folder-1',
+ },
+ userEmail: '',
+ parentFolderId: 'folder-1',
+ },
+ {
+ taskId: 'taskId2',
+ relatedTaskId: 'folder-task',
+ filecontent: {
+ content: 'c2' as unknown as File,
+ type: 'text/plain',
+ name: 'ok.txt',
+ size: bigSize,
+ parentFolderId: 'folder-1',
+ },
+ userEmail: '',
+ parentFolderId: 'folder-1',
+ },
+ ],
+ maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock,
+ uploadRepository: DatabaseUploadRepository.getInstance(),
+ options: {
+ ownerUserAuthenticationData: undefined,
+ sharedItemData: { isDeepFolder: false, currentFolderId: 'parentFolderId' },
+ isUploadedFromFolder: true,
+ },
+ }),
+ ).rejects.toThrow(failError);
+
+ const uploadedNames = (uploadFile as Mock).mock.calls.map((call) => call[0]?.name);
+ expect(uploadedNames).toContain('ok.txt');
+ });
+
+ it('should not retry a not-allowed file (402) and mark it as non-retryable in RetryManager', async () => {
+ const notAllowedError = new AppError('Payment required', 402);
+ const uploadFileSpy = (uploadFile as Mock).mockRejectedValue(notAllowedError);
+
+ vi.spyOn(tasksService, 'create').mockReturnValue('taskId');
+ vi.spyOn(tasksService, 'updateTask').mockReturnValue();
+ vi.spyOn(tasksService, 'addListener').mockReturnValue();
+ vi.spyOn(tasksService, 'removeListener').mockReturnValue();
+ vi.spyOn(errorService, 'castError').mockImplementation((e) => e as AppError);
+ vi.spyOn(errorService, 'reportError').mockReturnValue();
+
+ await expect(
+ uploadFileWithManager({
+ files: [
+ {
+ taskId: 'taskId',
+ relatedTaskId: 'folder-task',
+ filecontent: {
+ content: 'file-content' as unknown as File,
+ type: 'text/plain',
+ name: 'empty.txt',
+ size: 0,
+ parentFolderId: 'folder-1',
+ },
+ userEmail: '',
+ parentFolderId: 'folder-1',
+ },
+ ],
+ maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock,
+ uploadRepository: DatabaseUploadRepository.getInstance(),
+ options: {
+ ownerUserAuthenticationData: undefined,
+ sharedItemData: { isDeepFolder: false, currentFolderId: 'parentFolderId' },
+ isUploadedFromFolder: true,
+ },
+ }),
+ ).rejects.toThrow(notAllowedError);
+
+ expect(uploadFileSpy).toHaveBeenCalledTimes(1);
+ const retryTasks = RetryManager.getTasks();
+ expect(retryTasks).toHaveLength(1);
+ expect(retryTasks[0].retryable).toBe(false);
+ });
+
it('When uploading a file to a workspace, then it uses workspace credentials and same bucket for file and thumbnail', async () => {
const workspaceBucket = 'workspace-bucket-123';
const workspaceId = 'workspace-id-456';
diff --git a/src/app/network/UploadManager.ts b/src/app/network/UploadManager.ts
index d0fd4d5d24..33ebc0f453 100644
--- a/src/app/network/UploadManager.ts
+++ b/src/app/network/UploadManager.ts
@@ -1,6 +1,7 @@
import { queue, QueueObject } from 'async';
import { randomBytes } from 'node:crypto';
import { t } from 'i18next';
+import { AppError } from '@internxt/sdk';
import errorService from 'services/error.service';
import { HTTP_CODE_ERRORS, HTTP_STATUS_CODES } from '../core/constants';
import uploadFile from 'app/drive/services/file.service/uploadFile';
@@ -84,6 +85,9 @@ export const uploadFileWithManager = ({
class UploadManager {
private currentGroupBeingUploaded: FileSizeType = FileSizeType.Small;
private errored = false;
+ private someUploadFailed = false;
+ private firstUploadError?: Error;
+ private readonly nonRetryableTaskIds = new Set();
private uploadsProgress: Record = {};
private readonly abortController?: AbortController;
private readonly items: UploadManagerFileParams[];
@@ -255,8 +259,9 @@ class UploadManager {
!!this.abortController?.signal.aborted || !!fileData.abortController?.signal.aborted || error === 'abort';
const isLostConnectionError =
error instanceof ConnectionLostError || error.message === ErrorMessages.NetworkError;
+ const isNonRetryableError = UploadManager.isNonRetryableError(error, errorService.castError(error));
- if (uploadAttempts < MAX_UPLOAD_ATTEMPTS && !isUploadAborted && !isLostConnectionError) {
+ if (uploadAttempts < MAX_UPLOAD_ATTEMPTS && !isUploadAborted && !isLostConnectionError && !isNonRetryableError) {
upload();
} else {
this.handleUploadErrors({
@@ -306,6 +311,49 @@ class UploadManager {
this.onFileUploadCallback = onFileUploadCallback;
}
+ private static isNonRetryableError(error: any, castedError: { code?: string }): boolean {
+ return (
+ error?.status === HTTP_STATUS_CODES.PAYMENT_REQUIRED ||
+ castedError?.code === HTTP_CODE_ERRORS.FILE_UPLOAD_SIZE_EXCEEDED
+ );
+ }
+
+ // Marks the file task as errored, fires the relevant plan dialogs and (for non-folder uploads)
+ // kills the queue. Extracted from handleUploadErrors to keep its cognitive complexity low.
+ private applyUnexpectedUploadError({
+ taskId,
+ castedError,
+ isMaxSpaceError,
+ continueWithRemainingFiles,
+ }: {
+ taskId: string;
+ castedError: AppError;
+ isMaxSpaceError: boolean;
+ continueWithRemainingFiles: boolean;
+ }): void {
+ tasksService.updateTask({
+ taskId,
+ merge: { status: TaskStatus.Error, subtitle: t('tasks.subtitles.upload-failed') as string },
+ });
+ errorService.reportError(castedError);
+
+ if (castedError.code === HTTP_CODE_ERRORS.FILE_UPLOAD_SIZE_EXCEEDED) {
+ this.fileSizeExceededCallback?.();
+ }
+
+ if (isMaxSpaceError) {
+ this.maxSpaceOccupiedCallback();
+ }
+
+ // Non-folder uploads stop the whole batch; folder uploads keep going with the remaining files.
+ if (!continueWithRemainingFiles) {
+ if (!this.errored) {
+ this.uploadQueue.kill();
+ }
+ this.errored = true;
+ }
+ }
+
private handleUploadErrors({
error,
fileData,
@@ -346,29 +394,16 @@ class UploadManager {
const isUnexpectedError = task?.status !== TaskStatus.Cancelled && !isUploadAborted;
- // Handle unexpected errors
- if (isUnexpectedError) {
- tasksService.updateTask({
- taskId: taskId,
- merge: { status: TaskStatus.Error, subtitle: t('tasks.subtitles.upload-failed') as string },
- });
- errorService.reportError(castedError);
-
- // Handle file size exceeded
- if (castedError.code === HTTP_CODE_ERRORS.FILE_UPLOAD_SIZE_EXCEEDED) {
- this.fileSizeExceededCallback?.();
- }
+ const isMaxSpaceError = error?.status === HTTP_STATUS_CODES.MAX_SPACE_USED;
+ const continueWithRemainingFiles = !!this.options?.isUploadedFromFolder && !isMaxSpaceError;
- // Handle max space used error
- if (error?.status === HTTP_STATUS_CODES.MAX_SPACE_USED) {
- this.maxSpaceOccupiedCallback();
- }
+ if (UploadManager.isNonRetryableError(error, castedError)) {
+ this.nonRetryableTaskIds.add(taskId);
+ }
- // Ensure upload queue is killed and errored is set
- if (!this.errored) {
- this.uploadQueue.kill();
- }
- this.errored = true;
+ // Handle unexpected errors
+ if (isUnexpectedError) {
+ this.applyUnexpectedUploadError({ taskId, castedError, isMaxSpaceError, continueWithRemainingFiles });
}
// Handle upload aborted
@@ -381,6 +416,13 @@ class UploadManager {
return;
}
+ if (continueWithRemainingFiles) {
+ this.someUploadFailed = true;
+ this.firstUploadError ??= castedError;
+ next(null);
+ return;
+ }
+
// If relatedTaskId is present, kill upload queue
if (fileData.relatedTaskId) {
this.uploadQueue.kill();
@@ -567,12 +609,16 @@ class UploadManager {
for (let i = 0; i < uploadedFiles.length; i++) {
const uploadedFile = uploadedFiles[i];
if (uploadedFile) uploadedFilesData.push(uploadedFile);
- else
+ else {
+ const retryTaskId = files[i]?.taskId ?? files[i]?.relatedTaskId ?? '';
filesToRetry.push({
- taskId: files[i]?.taskId ?? files[i]?.relatedTaskId ?? '',
+ taskId: retryTaskId,
type: 'upload',
params: files[i],
+ // Not-allowed files stay visible in the list but cannot be retried.
+ retryable: !this.nonRetryableTaskIds.has(files[i]?.taskId ?? ''),
});
+ }
}
if (filesToRetry.length > 0) RetryManager.addTasks(filesToRetry);
@@ -594,6 +640,10 @@ class UploadManager {
if (bigSizedFiles.length > 0) await uploadFiles(bigSizedFiles, this.filesGroups.big.concurrency);
+ if (this.someUploadFailed) {
+ throw this.firstUploadError ?? new Error(t('error.uploadingItems') as string);
+ }
+
return { uploadedFiles: uploadedFilesData };
} catch (error) {
this.handleFailedUploads(filesWithTaskId);
diff --git a/src/app/tasks/components/TaskLogger/TaskLogger.tsx b/src/app/tasks/components/TaskLogger/TaskLogger.tsx
index 7bb08afd74..33c3440ce2 100644
--- a/src/app/tasks/components/TaskLogger/TaskLogger.tsx
+++ b/src/app/tasks/components/TaskLogger/TaskLogger.tsx
@@ -27,7 +27,7 @@ const TaskLogger = (): JSX.Element => {
const filesToRetryGroupedByTask = useMemo(
() =>
filesToRetry.reduce>((acc, file) => {
- const relatedTaskId = file.taskId;
+ const relatedTaskId = file.params?.relatedTaskId ?? file.taskId;
if (relatedTaskId) {
if (!acc[relatedTaskId]) {
acc[relatedTaskId] = [];
diff --git a/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.test.tsx b/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.test.tsx
index ed528eaf07..59817be49d 100644
--- a/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.test.tsx
+++ b/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.test.tsx
@@ -58,6 +58,15 @@ describe('TaskToRetyItem', () => {
expect(mockDownloadItem).toHaveBeenCalledWith(mockFile);
});
+ it('should not render a retry button and show the not-allowed label when the file is non-retryable', () => {
+ const notAllowedFile = { ...mockFile, retryable: false };
+ const notAllowedProps = { ...defaultProps, data: { files: [notAllowedFile], downloadItem: mockDownloadItem } };
+ const { queryByRole, getByText } = render();
+
+ expect(queryByRole('button')).not.toBeInTheDocument();
+ expect(getByText('tasks.messages.notAllowed')).toBeInTheDocument();
+ });
+
it('should render a spinner when the file status is "retrying"', () => {
const uploadingFile = { ...mockFile, status: 'retrying' };
const uploadingProps = { ...defaultProps, data: { files: [uploadingFile], downloadItem: mockDownloadItem } };
diff --git a/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.tsx b/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.tsx
index e2b407799a..ee968bfd6d 100644
--- a/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.tsx
+++ b/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.tsx
@@ -5,13 +5,14 @@ import { bytesToString } from 'app/drive/services/size.service';
import { formatDefaultDate } from 'services/date.service';
import { t } from 'i18next';
import { TaskLoggerButton } from '../TaskLoggerButton/TaskLoggerButton';
-import { CircleNotch } from '@phosphor-icons/react';
+import { CircleNotchIcon } from '@phosphor-icons/react';
import { RetryableTask } from 'app/network/RetryManager';
const TaskToRetyItem = ({ index, style, data }: ListChildComponentProps) => {
const file: RetryableTask = data.files[index];
- const { params, status } = file;
+ const { params, status, retryable } = file;
const { downloadItem } = data;
+ const isNotAllowed = retryable === false;
const getFileIcon = (type: string) => {
const IconComponent = iconService.getItemIcon(false, type);
return ;
@@ -33,8 +34,13 @@ const TaskToRetyItem = ({ index, style, data }: ListChildComponentProps) => {
- {status === 'failed' && downloadItem(file)} Icon={RestartIcon} />}
- {status === 'retrying' && }
+ {isNotAllowed && {t('tasks.messages.notAllowed')}}
+ {!isNotAllowed && status === 'failed' && (
+ downloadItem(file)} Icon={RestartIcon} />
+ )}
+ {!isNotAllowed && status === 'retrying' && (
+
+ )}
);
};