diff --git a/package-lock.json b/package-lock.json index 6baad99eca16ef..92acec5e08bbd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62723,6 +62723,7 @@ "@wordpress/private-apis": "file:../private-apis", "@wordpress/url": "file:../url", "@wordpress/vips": "file:../vips", + "@wordpress/warning": "file:../warning", "uuid": "^9.0.1" }, "engines": { diff --git a/packages/upload-media/README.md b/packages/upload-media/README.md index ddba7e4ae7e8cd..181105e4197acc 100644 --- a/packages/upload-media/README.md +++ b/packages/upload-media/README.md @@ -61,6 +61,8 @@ _Parameters_ Cancels an item in the queue based on an error. +For retryable errors (network failures, server errors, etc.), the item is automatically retried with exponential backoff up to MAX_RETRIES times before permanently failing. + _Parameters_ - _id_ `QueueItemId`: Item ID. @@ -71,6 +73,8 @@ _Parameters_ Retries a failed item in the queue. +Resets the item's operations to re-prepare from scratch, since the operation list is consumed as operations complete. + _Parameters_ - _id_ `QueueItemId`: Item ID. diff --git a/packages/upload-media/src/error-messages.ts b/packages/upload-media/src/error-messages.ts new file mode 100644 index 00000000000000..0cdc453228f834 --- /dev/null +++ b/packages/upload-media/src/error-messages.ts @@ -0,0 +1,228 @@ +/** + * User-friendly error messages for upload failures. + * + * Provides localized, human-readable messages for all error codes + * with actionable guidance for users. + */ + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { ErrorCode } from './upload-error'; + +/** + * Configuration for an error message. + */ +export interface ErrorMessageConfig { + /** Short title describing the error type. */ + title: string; + /** Detailed description of what happened. */ + description: string; + /** Optional actionable guidance for the user. */ + action?: string; +} + +/** + * Gets a user-friendly error message configuration for an error code. + * + * @param code The error code from UploadError. + * @param fileName The name of the file that failed to upload. + * @return Error message configuration with title, description, and action. + */ +export function getErrorMessage( + code: string, + fileName: string +): ErrorMessageConfig { + const messages: Record< string, ErrorMessageConfig > = { + [ ErrorCode.NETWORK_ERROR ]: { + title: __( 'Network error' ), + description: sprintf( + /* translators: %s: file name */ + __( 'Failed to upload "%s" due to a network issue.' ), + fileName + ), + action: __( 'Check your internet connection and try again.' ), + }, + [ ErrorCode.TIMEOUT_ERROR ]: { + title: __( 'Upload timed out' ), + description: sprintf( + /* translators: %s: file name */ + __( 'The upload of "%s" took too long.' ), + fileName + ), + action: __( + 'Try uploading a smaller file or check your connection.' + ), + }, + [ ErrorCode.SERVER_ERROR ]: { + title: __( 'Server error' ), + description: sprintf( + /* translators: %s: file name */ + __( 'The server encountered an error processing "%s".' ), + fileName + ), + action: __( 'Please try again later.' ), + }, + [ ErrorCode.FILE_TOO_LARGE ]: { + title: __( 'File too large' ), + description: sprintf( + /* translators: %s: file name */ + __( '"%s" exceeds the maximum upload size.' ), + fileName + ), + action: __( 'Please reduce the file size and try again.' ), + }, + [ ErrorCode.INVALID_MIME_TYPE ]: { + title: __( 'Unsupported file type' ), + description: sprintf( + /* translators: %s: file name */ + __( '"%s" is not a supported file type.' ), + fileName + ), + action: __( 'Please upload a different file format.' ), + }, + [ ErrorCode.INVALID_IMAGE_DIMENSIONS ]: { + title: __( 'Invalid image dimensions' ), + description: sprintf( + /* translators: %s: file name */ + __( '"%s" does not match the expected dimensions.' ), + fileName + ), + action: __( + 'The image size does not match the target thumbnail size.' + ), + }, + [ ErrorCode.PERMISSION_DENIED ]: { + title: __( 'Permission denied' ), + description: sprintf( + /* translators: %s: file name */ + __( 'You do not have permission to upload "%s".' ), + fileName + ), + action: __( 'Please contact your site administrator.' ), + }, + [ ErrorCode.NOT_FOUND ]: { + title: __( 'Not found' ), + description: sprintf( + /* translators: %s: file name */ + __( 'The upload destination for "%s" was not found.' ), + fileName + ), + action: __( 'Please refresh the page and try again.' ), + }, + [ ErrorCode.VALIDATION_ERROR ]: { + title: __( 'Validation failed' ), + description: sprintf( + /* translators: %s: file name */ + __( '"%s" failed validation.' ), + fileName + ), + action: __( 'Please check the file and try again.' ), + }, + [ ErrorCode.IMAGE_TRANSCODING_ERROR ]: { + title: __( 'Image processing failed' ), + description: sprintf( + /* translators: %s: file name */ + __( 'Failed to process "%s".' ), + fileName + ), + action: __( 'The image may be corrupted. Try a different file.' ), + }, + [ ErrorCode.IMAGE_ROTATION_ERROR ]: { + title: __( 'Image rotation failed' ), + description: sprintf( + /* translators: %s: file name */ + __( 'Failed to rotate "%s".' ), + fileName + ), + action: __( 'The image may be corrupted. Try a different file.' ), + }, + [ ErrorCode.VIPS_WORKER_ERROR ]: { + title: __( 'Image processing error' ), + description: sprintf( + /* translators: %s: file name */ + __( 'An error occurred while processing "%s".' ), + fileName + ), + action: __( 'Please try again.' ), + }, + [ ErrorCode.MEMORY_ERROR ]: { + title: __( 'Not enough memory' ), + description: sprintf( + /* translators: %s: file name */ + __( 'Not enough memory to process "%s".' ), + fileName + ), + action: __( + 'Try closing other tabs or uploading a smaller image.' + ), + }, + [ ErrorCode.ABORTED ]: { + title: __( 'Upload cancelled' ), + description: sprintf( + /* translators: %s: file name */ + __( 'The upload of "%s" was cancelled.' ), + fileName + ), + }, + [ ErrorCode.GENERAL ]: { + title: __( 'Upload failed' ), + description: sprintf( + /* translators: %s: file name */ + __( 'Failed to upload "%s".' ), + fileName + ), + action: __( 'Please try again.' ), + }, + }; + + return ( + messages[ code ] || { + title: __( 'Upload failed' ), + description: sprintf( + /* translators: %s: file name */ + __( 'Failed to upload "%s".' ), + fileName + ), + action: __( 'Please try again.' ), + } + ); +} + +/** + * Gets a short retry message for displaying retry status. + * + * @param retryCount The current retry attempt number. + * @param maxRetries The maximum number of retries allowed. + * @return A localized message about the retry attempt. + */ +export function getRetryMessage( + retryCount: number, + maxRetries: number +): string { + return sprintf( + /* translators: 1: current attempt number, 2: maximum attempts */ + __( 'Retrying\u2026 (attempt %1$d of %2$d)' ), + retryCount, + maxRetries + ); +} + +/** + * Gets a message indicating that all retries have been exhausted. + * + * @param maxRetries The maximum number of retries that were attempted. + * @return A localized message about exhausted retries. + */ +export function getMaxRetriesExceededMessage( maxRetries: number ): string { + return sprintf( + /* translators: %d: number of retry attempts */ + __( 'Upload failed after %d attempts.' ), + maxRetries + ); +} diff --git a/packages/upload-media/src/store/actions.ts b/packages/upload-media/src/store/actions.ts index ae6b69f425062d..be78f78c31ac03 100644 --- a/packages/upload-media/src/store/actions.ts +++ b/packages/upload-media/src/store/actions.ts @@ -24,14 +24,20 @@ import type { RetryItemAction, State, } from './types'; -import { Type } from './types'; +import { OperationType, Type } from './types'; import type { addItem, processItem, removeItem, revokeBlobUrls, } from './private-actions'; +import { + MAX_RETRIES, + BASE_RETRY_DELAY_MS, + MAX_RETRY_DELAY_MS, +} from './constants'; import { vipsCancelOperations } from './utils'; +import { UploadError } from '../upload-error'; import { validateMimeType } from '../validate-mime-type'; import { validateMimeTypeForUser } from '../validate-mime-type-for-user'; import { validateFileSize } from '../validate-file-size'; @@ -138,6 +144,10 @@ export function addItems( { /** * Cancels an item in the queue based on an error. * + * For retryable errors (network failures, server errors, etc.), the item + * is automatically retried with exponential backoff up to MAX_RETRIES times + * before permanently failing. + * * @param id Item ID. * @param error Error instance. * @param silent Whether to cancel the item silently, @@ -158,6 +168,41 @@ export function cancelItem( id: QueueItemId, error: Error, silent = false ) { return; } + // Auto-retry retryable errors with exponential backoff. + const retryCount = item.retryCount ?? 0; + if ( + error instanceof UploadError && + error.isRetryable && + retryCount < MAX_RETRIES + ) { + const delay = Math.min( + BASE_RETRY_DELAY_MS * Math.pow( 2, retryCount ), + MAX_RETRY_DELAY_MS + ); + + // Mark as retrying (increments retryCount, clears error). + dispatch< RetryItemAction >( { + type: Type.RetryItem, + id, + } ); + + // Reset operations to re-prepare the item from scratch, + // since the operation list is consumed as operations complete. + dispatch( { + type: Type.OperationFinish, + id, + item: { + operations: [ OperationType.Prepare ], + currentOperation: undefined, + }, + } ); + + // Wait with exponential backoff, then re-process. + await new Promise( ( resolve ) => setTimeout( resolve, delay ) ); + dispatch.processItem( id ); + return; + } + item.abortController?.abort(); // Cancel any ongoing vips operations for this item. @@ -167,7 +212,6 @@ export function cancelItem( id: QueueItemId, error: Error, silent = false ) { const { onError } = item; onError?.( error ?? new Error( 'Upload cancelled' ) ); if ( ! onError && error ) { - // TODO: Find better way to surface errors with sideloads etc. // eslint-disable-next-line no-console -- Deliberately log errors here. console.error( 'Upload cancelled', error ); } @@ -191,6 +235,9 @@ export function cancelItem( id: QueueItemId, error: Error, silent = false ) { /** * Retries a failed item in the queue. * + * Resets the item's operations to re-prepare from scratch, + * since the operation list is consumed as operations complete. + * * @param id Item ID. */ export function retryItem( id: QueueItemId ) { @@ -211,6 +258,16 @@ export function retryItem( id: QueueItemId ) { id, } ); + // Reset operations to re-prepare the item from scratch. + dispatch( { + type: Type.OperationFinish, + id, + item: { + operations: [ OperationType.Prepare ], + currentOperation: undefined, + }, + } ); + dispatch.processItem( id ); }; } diff --git a/packages/upload-media/src/store/constants.ts b/packages/upload-media/src/store/constants.ts index d0b6f5591387ae..8d05e7849689e0 100644 --- a/packages/upload-media/src/store/constants.ts +++ b/packages/upload-media/src/store/constants.ts @@ -28,3 +28,19 @@ export const CLIENT_SIDE_SUPPORTED_MIME_TYPES: readonly string[] = [ 'image/webp', 'image/avif', ] as const; + +/** + * Maximum number of automatic retry attempts for retryable errors. + */ +export const MAX_RETRIES = 3; + +/** + * Base delay in milliseconds for exponential backoff between retries. + * Actual delay = BASE_RETRY_DELAY_MS * 2^(retryCount - 1), capped at MAX_RETRY_DELAY_MS. + */ +export const BASE_RETRY_DELAY_MS = 1000; + +/** + * Maximum delay in milliseconds between retries. + */ +export const MAX_RETRY_DELAY_MS = 10000; diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index b06af25f2d6d43..ccc855b87603da 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -17,6 +17,7 @@ import { cloneFile, convertBlobToFile, renameFile } from '../utils'; import { CLIENT_SIDE_SUPPORTED_MIME_TYPES } from './constants'; import { StubFile } from '../stub-file'; import { UploadError } from '../upload-error'; +import { measure } from './utils/debug-logger'; import { vipsResizeImage, vipsRotateImage, @@ -28,6 +29,7 @@ import type { AddAction, AdditionalData, AddOperationsAction, + Attachment, BatchId, CacheBlobUrlAction, ImageFormat, @@ -798,21 +800,39 @@ export function uploadItem( id: QueueItemId ) { return; } + const startTime = performance.now(); + let finished = false; + + const finishUpload = ( attachment: Partial< Attachment > ) => { + if ( finished ) { + return; + } + finished = true; + + measure( { + measureName: `Upload ${ item.file.name }`, + startTime, + tooltipText: item.file.name, + properties: [ + [ 'Item ID', item.id ], + [ 'File name', item.file.name ], + ], + } ); + + dispatch.finishOperation( id, { attachment } ); + }; + select.getSettings().mediaUpload( { filesList: [ item.file ], additionalData: item.additionalData, signal: item.abortController?.signal, onFileChange: ( [ attachment ] ) => { if ( attachment && ! isBlobURL( attachment.url ) ) { - dispatch.finishOperation( id, { - attachment, - } ); + finishUpload( attachment ); } }, onSuccess: ( [ attachment ] ) => { - dispatch.finishOperation( id, { - attachment, - } ); + finishUpload( attachment ); }, onError: ( error ) => { dispatch.cancelItem( id, error ); @@ -843,12 +863,24 @@ export function sideloadItem( id: QueueItemId ) { return; } + const startTime = performance.now(); + mediaSideload( { file: item.file, attachmentId: post as number, additionalData, signal: item.abortController?.signal, onFileChange: ( [ attachment ] ) => { + measure( { + measureName: `Sideload ${ item.file.name }`, + startTime, + tooltipText: item.file.name, + properties: [ + [ 'Item ID', item.id ], + [ 'File name', item.file.name ], + ], + } ); + dispatch.finishOperation( id, { attachment } ); dispatch.resumeItemByPostId( post as number ); }, @@ -882,6 +914,8 @@ export function resizeCropItem( id: QueueItemId, args?: ResizeCropItemArgs ) { return; } + const startTime = performance.now(); + // Add dimension suffix for sub-sizes (thumbnails). const addSuffix = Boolean( item.parentId ); // Add '-scaled' suffix for big image threshold resizing. @@ -898,6 +932,16 @@ export function resizeCropItem( id: QueueItemId, args?: ResizeCropItemArgs ) { scaledSuffix ); + measure( { + measureName: `ResizeCrop ${ item.file.name }`, + startTime, + tooltipText: item.file.name, + properties: [ + [ 'Item ID', item.id ], + [ 'File name', item.file.name ], + ], + } ); + const blobUrl = createBlobURL( file ); dispatch< CacheBlobUrlAction >( { type: Type.CacheBlobUrl, @@ -952,6 +996,8 @@ export function rotateItem( id: QueueItemId, args?: RotateItemArgs ) { return; } + const startTime = performance.now(); + try { const file = await vipsRotateImage( item.id, @@ -960,6 +1006,16 @@ export function rotateItem( id: QueueItemId, args?: RotateItemArgs ) { item.abortController?.signal ); + measure( { + measureName: `Rotate ${ item.file.name }`, + startTime, + tooltipText: item.file.name, + properties: [ + [ 'Item ID', item.id ], + [ 'File name', item.file.name ], + ], + } ); + const blobUrl = createBlobURL( file ); dispatch< CacheBlobUrlAction >( { type: Type.CacheBlobUrl, @@ -1016,6 +1072,8 @@ export function transcodeImageItem( return; } + const startTime = performance.now(); + const outputMimeType = `image/${ args.outputFormat }` as | 'image/jpeg' | 'image/png' @@ -1034,6 +1092,16 @@ export function transcodeImageItem( interlaced ); + measure( { + measureName: `Transcode ${ item.file.name }`, + startTime, + tooltipText: item.file.name, + properties: [ + [ 'Item ID', item.id ], + [ 'File name', item.file.name ], + ], + } ); + const blobUrl = createBlobURL( file ); dispatch< CacheBlobUrlAction >( { type: Type.CacheBlobUrl, diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index 20524bf85ef83f..9712b79f981cc3 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -10,6 +10,7 @@ type WPDataRegistry = ReturnType< typeof createRegistry >; import { store as uploadStore } from '..'; import { ItemStatus, OperationType } from '../types'; import { unlock } from '../../lock-unlock'; +import { UploadError, ErrorCode } from '../../upload-error'; jest.mock( '@wordpress/blob', () => ( { __esModule: true, @@ -423,6 +424,118 @@ describe( 'actions', () => { expect( onError ).not.toHaveBeenCalled(); } ); + + it( 'auto-retries retryable errors instead of cancelling', async () => { + jest.useFakeTimers(); + + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: jpegFile, + } ); + const item = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + const retryableError = new UploadError( { + code: ErrorCode.NETWORK_ERROR, + message: 'Network failure', + file: jpegFile, + } ); + + // Start cancellation (which triggers auto-retry). + const cancelPromise = registry + .dispatch( uploadStore ) + .cancelItem( item.id, retryableError ); + + // Advance past the backoff delay. + jest.advanceTimersByTime( 2000 ); + await cancelPromise; + + // Item should still be in the queue (retried, not removed). + const items = unlock( + registry.select( uploadStore ) + ).getAllItems(); + expect( items ).toHaveLength( 1 ); + expect( items[ 0 ].retryCount ).toBe( 1 ); + expect( items[ 0 ].error ).toBeUndefined(); + + jest.useRealTimers(); + } ); + + it( 'does not auto-retry non-retryable errors', async () => { + const consoleErrorSpy = jest + .spyOn( console, 'error' ) + .mockImplementation( () => {} ); + + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: jpegFile, + } ); + const item = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + const nonRetryableError = new UploadError( { + code: ErrorCode.PERMISSION_DENIED, + message: 'Forbidden', + file: jpegFile, + } ); + + await registry + .dispatch( uploadStore ) + .cancelItem( item.id, nonRetryableError ); + + // Item should be removed (not retried). + expect( + unlock( registry.select( uploadStore ) ).getAllItems() + ).toHaveLength( 0 ); + + consoleErrorSpy.mockRestore(); + } ); + + it( 'stops retrying after MAX_RETRIES attempts', async () => { + jest.useFakeTimers(); + const onError = jest.fn(); + + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: jpegFile, + onError, + } ); + + const retryableError = new UploadError( { + code: ErrorCode.NETWORK_ERROR, + message: 'Network failure', + file: jpegFile, + } ); + + // Retry 3 times (MAX_RETRIES = 3). + for ( let i = 0; i < 3; i++ ) { + const item = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + const cancelPromise = registry + .dispatch( uploadStore ) + .cancelItem( item.id, retryableError ); + jest.advanceTimersByTime( 20000 ); + await cancelPromise; + } + + // 4th attempt should fail permanently. + const item = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + expect( item.retryCount ).toBe( 3 ); + + await registry + .dispatch( uploadStore ) + .cancelItem( item.id, retryableError ); + + // Item should now be removed. + expect( + unlock( registry.select( uploadStore ) ).getAllItems() + ).toHaveLength( 0 ); + expect( onError ).toHaveBeenCalledWith( retryableError ); + + jest.useRealTimers(); + } ); } ); describe( 'resizeCropItem', () => { diff --git a/packages/upload-media/src/store/test/reducer.ts b/packages/upload-media/src/store/test/reducer.ts index 1dc2082798f42b..eb4ed4d4a0e0ab 100644 --- a/packages/upload-media/src/store/test/reducer.ts +++ b/packages/upload-media/src/store/test/reducer.ts @@ -382,6 +382,112 @@ describe( 'reducer', () => { } ); } ); + describe( `${ Type.PauseQueue }`, () => { + it( 'sets queueStatus to paused', () => { + const initialState: State = { + queueStatus: 'active', + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + }, + queue: [], + }; + const state = reducer( initialState, { + type: Type.PauseQueue, + } ); + + expect( state.queueStatus ).toBe( 'paused' ); + } ); + } ); + + describe( `${ Type.ResumeQueue }`, () => { + it( 'sets queueStatus to active', () => { + const initialState: State = { + queueStatus: 'paused', + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + }, + queue: [], + }; + const state = reducer( initialState, { + type: Type.ResumeQueue, + } ); + + expect( state.queueStatus ).toBe( 'active' ); + } ); + } ); + + describe( `${ Type.CacheBlobUrl }`, () => { + it( 'caches a blob URL for an item', () => { + const initialState: State = { + queueStatus: 'active', + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + }, + queue: [], + }; + const state = reducer( initialState, { + type: Type.CacheBlobUrl, + id: '1', + blobUrl: 'blob:http://example.com/1', + } ); + + expect( state.blobUrls[ '1' ] ).toEqual( [ + 'blob:http://example.com/1', + ] ); + } ); + + it( 'appends to existing blob URLs for an item', () => { + const initialState: State = { + queueStatus: 'active', + blobUrls: { + '1': [ 'blob:http://example.com/1' ], + }, + settings: { + mediaUpload: jest.fn(), + }, + queue: [], + }; + const state = reducer( initialState, { + type: Type.CacheBlobUrl, + id: '1', + blobUrl: 'blob:http://example.com/2', + } ); + + expect( state.blobUrls[ '1' ] ).toEqual( [ + 'blob:http://example.com/1', + 'blob:http://example.com/2', + ] ); + } ); + } ); + + describe( `${ Type.RevokeBlobUrls }`, () => { + it( 'removes blob URLs for an item', () => { + const initialState: State = { + queueStatus: 'active', + blobUrls: { + '1': [ 'blob:http://example.com/1' ], + '2': [ 'blob:http://example.com/2' ], + }, + settings: { + mediaUpload: jest.fn(), + }, + queue: [], + }; + const state = reducer( initialState, { + type: Type.RevokeBlobUrls, + id: '1', + } ); + + expect( state.blobUrls[ '1' ] ).toBeUndefined(); + expect( state.blobUrls[ '2' ] ).toEqual( [ + 'blob:http://example.com/2', + ] ); + } ); + } ); + describe( `${ Type.UpdateProgress }`, () => { it( 'updates the progress of an item', () => { const initialState: State = { diff --git a/packages/upload-media/src/store/utils/debug-logger.ts b/packages/upload-media/src/store/utils/debug-logger.ts new file mode 100644 index 00000000000000..d74c1636149a35 --- /dev/null +++ b/packages/upload-media/src/store/utils/debug-logger.ts @@ -0,0 +1,67 @@ +/** + * Debug logger for client-side media processing. + * + * Provides performance measurement via the User Timings API. + * Set DEBUG_ENABLED to true to enable measurements in DevTools. + */ + +// Set to true to enable debug logging for client-side media processing. +// Keep disabled by default to avoid test failures from unexpected console output. +const DEBUG_ENABLED = false; + +interface MeasureOptions { + measureName: string; + startTime: number; + endTime?: number; + tooltipText?: string; + properties?: Array< [ string, string ] >; +} + +/** + * Records a performance measure visible in DevTools Performance panel. + * + * Uses the User Timings API (performance.measure) to create entries + * under a custom "Upload Media" track in DevTools. + * + * @param options Measure options. + * @param options.measureName Name for the performance measure entry. + * @param options.startTime Start time from performance.now(). + * @param options.endTime End time from performance.now(). Defaults to current time. + * @param options.tooltipText Tooltip text shown in DevTools. + * @param options.properties Key-value pairs shown in DevTools detail view. + */ +export function measure( options: MeasureOptions ): void { + if ( ! DEBUG_ENABLED ) { + return; + } + + const { + measureName, + startTime, + endTime = performance.now(), + tooltipText, + properties, + } = options; + + const detail: Record< string, unknown > = { + devtools: { + dataType: 'track-entry', + track: 'Upload Media', + tooltipText, + properties: properties?.map( ( [ key, value ] ) => ( { + key, + value, + } ) ), + }, + }; + + try { + performance.measure( measureName, { + start: startTime, + end: endTime, + detail, + } ); + } catch { + // Silently ignore if User Timings API is unavailable. + } +} diff --git a/packages/upload-media/src/upload-error.ts b/packages/upload-media/src/upload-error.ts index d712e9dcdb6966..0863a52b7e11d4 100644 --- a/packages/upload-media/src/upload-error.ts +++ b/packages/upload-media/src/upload-error.ts @@ -1,3 +1,48 @@ +/** + * Error codes for upload operations. + * + * These codes categorize different types of failures that can occur + * during the upload process, allowing for appropriate retry strategies + * and user-friendly error messages. + */ +export enum ErrorCode { + // Retryable network errors + NETWORK_ERROR = 'NETWORK_ERROR', + TIMEOUT_ERROR = 'TIMEOUT_ERROR', + SERVER_ERROR = 'SERVER_ERROR', // 5xx responses + + // Non-retryable client errors + VALIDATION_ERROR = 'VALIDATION_ERROR', + PERMISSION_DENIED = 'PERMISSION_DENIED', // 403 + NOT_FOUND = 'NOT_FOUND', // 404 + FILE_TOO_LARGE = 'FILE_TOO_LARGE', + INVALID_MIME_TYPE = 'INVALID_MIME_TYPE', + INVALID_IMAGE_DIMENSIONS = 'INVALID_IMAGE_DIMENSIONS', // Sideload dimension mismatch + + // Processing errors (conditionally retryable) + IMAGE_TRANSCODING_ERROR = 'IMAGE_TRANSCODING_ERROR', + IMAGE_ROTATION_ERROR = 'IMAGE_ROTATION_ERROR', + VIPS_WORKER_ERROR = 'VIPS_WORKER_ERROR', + MEMORY_ERROR = 'MEMORY_ERROR', + + // User action + ABORTED = 'ABORTED', + + // Generic + GENERAL = 'GENERAL', +} + +/** + * Error codes that are safe to retry automatically. + * These are typically transient issues that may resolve on retry. + */ +const RETRYABLE_CODES: ErrorCode[] = [ + ErrorCode.NETWORK_ERROR, + ErrorCode.TIMEOUT_ERROR, + ErrorCode.SERVER_ERROR, + ErrorCode.VIPS_WORKER_ERROR, +]; + interface UploadErrorArgs { code: string; message: string; @@ -23,4 +68,16 @@ export class UploadError extends Error { this.code = code; this.file = file; } + + /** + * Determines if this error is safe to retry automatically. + * + * Retryable errors are typically transient issues like network + * failures or server errors that may resolve on a subsequent attempt. + * + * @return Whether the error can be retried. + */ + get isRetryable(): boolean { + return RETRYABLE_CODES.includes( this.code as ErrorCode ); + } } diff --git a/packages/upload-media/tsconfig.json b/packages/upload-media/tsconfig.json index 20ebdb1f6791a0..fa58e8e3b3d353 100644 --- a/packages/upload-media/tsconfig.json +++ b/packages/upload-media/tsconfig.json @@ -14,6 +14,7 @@ { "path": "../preferences" }, { "path": "../private-apis" }, { "path": "../url" }, - { "path": "../vips" } + { "path": "../vips" }, + { "path": "../warning" } ] }