Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d15f427
Add comprehensive error handling and retry logic for upload-media
adamsilverstein Jan 23, 2026
127754b
Update packages/upload-media/src/store/actions.ts
adamsilverstein Jan 29, 2026
71dc73a
Fix memory leak by storing and clearing retry timer references
adamsilverstein Jan 29, 2026
2230e7d
Add test coverage for retry reducer and actions
adamsilverstein Feb 5, 2026
3eba115
Add test coverage for retry selectors and actions
adamsilverstein Feb 24, 2026
a62a0c2
Fix prettier formatting in test files
adamsilverstein Feb 24, 2026
38a056c
Fix CI: integrate retry logic into cancelItem and fix test setup
adamsilverstein Feb 24, 2026
340bec5
Fix TypeScript: add missing RetrySettings type and QueueItem fields
adamsilverstein Feb 24, 2026
60ec4be
Fix AbortController already aborted when retry fires
adamsilverstein Feb 25, 2026
c705631
Clear retry timer on cancellation, move timers out of Redux
adamsilverstein Feb 25, 2026
a4ed3e4
Break mutual recursion between cancelItem and scheduleRetry
adamsilverstein Feb 25, 2026
ca87761
Type RETRYABLE_CODES as ErrorCode[] for type safety
adamsilverstein Feb 25, 2026
a867449
Remove misleading nextRetryCount variable, inline calculation
adamsilverstein Feb 25, 2026
7c5d3d4
Fix hasExceededMaxRetries when retry is disabled
adamsilverstein Feb 25, 2026
7335ceb
Suppress console.error noise in cancel-related tests
adamsilverstein Feb 25, 2026
306ff0e
Add test coverage for AbortController state after retry and timer cle…
adamsilverstein Feb 25, 2026
82e8a67
Update reducer tests for retryTimerId removal
adamsilverstein Feb 25, 2026
8d8548d
Fix TypeScript error: cast code to ErrorCode in isRetryable check
adamsilverstein Feb 25, 2026
b0c5a00
Replace createTimer with User Timings API measure
adamsilverstein Feb 25, 2026
7f1aad0
Clear retry timer in removeItem to prevent Map entry leak
adamsilverstein Feb 25, 2026
c76ec28
Move retryTimers and clearRetryTimer to utils/retry
adamsilverstein Feb 25, 2026
02d5f3b
Address PR #74917 review feedback
adamsilverstein Feb 26, 2026
4cf8fae
Fix lint:tsconfig by adding missing warning reference
adamsilverstein Feb 26, 2026
f6e336f
Regenerate package-lock.json after adding @wordpress/warning
adamsilverstein Feb 28, 2026
c34e93a
Fix save lock and detect missing image sizes on load
adamsilverstein Mar 2, 2026
e2be44b
Merge trunk into 74366-add-retry-and-error-handling
adamsilverstein Mar 17, 2026
5393bbc
restore package lock
adamsilverstein Mar 17, 2026
4964e4b
Merge trunk into 74366-add-retry-and-error-handling
adamsilverstein Mar 17, 2026
205584c
Remove retry logic to focus PR on error handling only
adamsilverstein Mar 23, 2026
eef41e8
Add automatic retry with exponential backoff for retryable upload errors
adamsilverstein Mar 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/upload-media/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
228 changes: 228 additions & 0 deletions packages/upload-media/src/error-messages.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
61 changes: 59 additions & 2 deletions packages/upload-media/src/store/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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 );
}
Expand All @@ -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 ) {
Expand All @@ -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 );
};
}
16 changes: 16 additions & 0 deletions packages/upload-media/src/store/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading