Upload Media: Add error taxonomy, localized messages, and dev diagnostics#74917
Conversation
This comment was marked as outdated.
This comment was marked as outdated.
|
Size Change: +812 B (+0.01%) Total Size: 8.6 MB 📦 View Changed
|
|
Flaky tests detected in 0da5356. 🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/27751942722
|
22b6f13 to
34b509a
Compare
c5d67ea to
7d17226
Compare
There was a problem hiding this comment.
Pull request overview
This PR implements comprehensive error handling and retry logic for the upload-media package to address GitHub Issue #74366. The implementation adds an error classification system with 16 error types, automatic retry with exponential backoff for transient failures, user-friendly localized error messages, and retry state management with a new ItemStatus.PendingRetry status.
Changes:
- Added
ErrorCodeenum with 16 error types categorized as retryable (network, timeout, server, VIPS worker errors) and non-retryable (validation, permission, file size, MIME type errors) - Implemented automatic retry with exponential backoff and jitter for failed uploads, with configurable retry settings (default: 3 attempts, 1s-30s delays, 2x multiplier)
- Created comprehensive error message system with localized, actionable user guidance for all error types
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/upload-media/src/upload-error.ts | Added ErrorCode enum and isRetryable getter to classify errors |
| packages/upload-media/src/error-messages.ts | New file providing user-friendly i18n error messages for all error codes |
| packages/upload-media/src/store/utils/retry.ts | New file with exponential backoff calculation and error classification logic |
| packages/upload-media/src/store/utils/debug-logger.ts | Added logging functions for retry scheduling, execution, and max retries exceeded |
| packages/upload-media/src/store/types.ts | Added RetrySettings, ScheduleRetryAction, ItemStatus.PendingRetry, and retry-related QueueItem fields |
| packages/upload-media/src/store/constants.ts | Added retry configuration constants (max attempts, delays, multiplier, jitter) |
| packages/upload-media/src/store/reducer.ts | Added ScheduleRetry case and default retry settings initialization |
| packages/upload-media/src/store/actions.ts | Modified cancelItem to check retry eligibility, added scheduleRetry and executeRetry actions |
| packages/upload-media/src/store/private-selectors.ts | Added selectors for retry state (pending items, retry timestamp, retry count, max retries check) |
| packages/upload-media/src/store/test/retry.ts | New test file with 28 unit tests for retry utilities |
| packages/upload-media/src/index.ts | Exported ErrorCode, error message functions, and retry-related types |
| packages/upload-media/README.md | Updated documentation for cancelItem, scheduleRetry, and executeRetry actions |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
1fb78d7 to
89e2a02
Compare
9e4d2bc to
05918ca
Compare
Implements automatic retry with exponential backoff for failed uploads, error classification system, and user-friendly error messages. Changes: - Add ErrorCode enum with 16 error types (network, validation, processing) - Add isRetryable getter to UploadError class for automatic retry classification - Add retry configuration constants (max attempts, delays, backoff multiplier) - Add RetrySettings interface and ItemStatus.PendingRetry status - Add scheduleRetry/executeRetry actions for automatic retry scheduling - Add retry state selectors (getPendingRetryItems, getItemRetryCount, etc.) - Add retry logging functions for debugging - Add calculateRetryDelay utility with exponential backoff and jitter - Add shouldRetryError utility for error classification - Add user-friendly i18n error messages for all error codes - Add comprehensive unit tests for retry utilities (28 tests) The retry system automatically retries failed uploads for transient errors (network failures, timeouts, server errors) while immediately failing for non-retryable errors (validation, permissions, file size limits). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Resolve conflict in sideloadItem: keep measure() instrumentation while adopting trunk's new onSuccess(subSize) callback that accumulates SubSizeData onto the parent item.
There was a problem hiding this comment.
Review from Claude
I took a pass through the PR after resolving the latest trunk merge conflict. A few substantive issues to flag before this lands, plus a couple of cleanups.
🔴 Error-handling infrastructure is not wired into the code it's meant to handle
The new ErrorCode enum + error-messages.ts + isRetryable getter form a nice vocabulary, but nothing in the package actually produces those codes, and nothing consumes the helpers:
- Actual
new UploadError( { code: … } )sites use string codes like'HEIC_DECODE_ERROR','EMPTY_FILE','SIZE_ABOVE_LIMIT','MIME_TYPE_NOT_ALLOWED_FOR_USER','MIME_TYPE_NOT_SUPPORTED','MEDIA_TRANSCODING_ERROR','IMAGE_TRANSCODING_ERROR','IMAGE_ROTATION_ERROR'— none of which appear inErrorCode. RETRYABLE_CODEScontainsNETWORK_ERROR,TIMEOUT_ERROR,SERVER_ERROR,VIPS_WORKER_ERROR. Since none of those codes is ever thrown in the current code paths,upload-error.isRetryableeffectively always returnsfalse.getErrorMessage()/getRetryMessage()/getMaxRetriesExceededMessage()are not imported from anywhere, andErrorCode/ these helpers are not re-exported frompackages/upload-media/src/index.ts, so no consumer (this package or plugin code) can use them either.
Suggested direction before merge (pick one):
- Minimum: migrate all existing throw sites to use
ErrorCodevalues, addErrorCodeto the enum for the remaining legacy codes (HEIC_DECODE_ERROR,EMPTY_FILE,SIZE_ABOVE_LIMIT,MIME_TYPE_NOT_ALLOWED_FOR_USER,MIME_TYPE_NOT_SUPPORTED,MEDIA_TRANSCODING_ERROR), and exportErrorCode+getErrorMessagefromindex.ts. Also typeUploadError.codeasErrorCode | stringinstead of rawstring. - Or: drop
error-messages.tsandErrorCodefrom this PR and land them alongside the UI that consumes them. Otherwise CI will happily ship dead code.
🟡 debug-logger.ts is a compile-time no-op
DEBUG_ENABLED = false is a module-level constant, so measure() always short-circuits in every build. If the goal is to leave hooks in the code for people to turn on during local work, that's fine — but it's worth being explicit:
- Gate it with
process.env.NODE_ENV === 'development'orglobalThis.SCRIPT_DEBUG, matching the@wordpress/warningpattern. Or - Expose a
setDebugEnabled()/ read from a well-known global so it can be flipped without editing the source.
Otherwise a future reader has to grep the file to find the constant and rebuild the package to use it.
🟡 PR description is stale
README.mdis listed as modified, but the diff shows no README change.- "Retry logic will be handled in a separate PR" — true for auto-retry/scheduling, but the user-initiated
retryItemaction,retryCountfield on QueueItem, andRetryItemreducer/tests are all here (they actually landed in earlier trunk work, but the current description doesn't acknowledge that context). getRetryMessage()andgetMaxRetriesExceededMessage()shipped with this PR but are scoped to the retry UX that's explicitly not in this PR.
Description should reflect: "adds error-handling vocabulary + logging instrumentation, no consumer wiring yet (follow-up PR)" — or you wire up the consumer and update the description accordingly.
🟡 test-results/.last-run.json is accidentally committed
Already flagged by @andrewserong. Still in the branch:
test-results/.last-run.json | 10 +
Please remove and add test-results/ to .gitignore (currently only test/storybook-playwright/test-results is ignored).
🟡 Unrelated test additions
packages/upload-media/src/store/test/reducer.ts adds tests for PauseQueue, ResumeQueue, CacheBlobUrl, RevokeBlobUrls — which are good tests, but none are touched by this PR. If the intent is "improve reducer coverage while I'm here," that's reasonable to keep, but mention it in the description. Otherwise consider splitting them out so this PR stays focused.
🟡 No CHANGELOG entry
packages/upload-media/CHANGELOG.md has an Unreleased entry from #75257 but nothing for this PR. Add a bullet.
🟢 Nice touches
- The
finishUploadidempotency guard inuploadItem(preventing double-dispatch when bothonFileChangeandonSuccessfire) is a good incidental fix. Worth calling out in the description so it doesn't look like noise. @wordpress/warningcalls incancelItemare useful in dev builds; they only emit underSCRIPT_DEBUG, so no prod overhead. 👍- Sideload
measure()instrumentation plays nicely with the newonSuccess(subSize)trunk API after the merge.
Nits
error-messages.ts:getErrorMessagetakescode: string; once the enum is wired up, tightening that toErrorCode | stringwill catch typos at callsites.- The batch-completed
warning()fires inside thecancelItemfinishing branch, which means a silent cancel of the last item in a batch still logs "Batch completed". If that's intentional, fine, but worth a comment.
Final sweep + suggested PR descriptionMerge conflict vs trunk is resolved and pushed ( CodeRabbit (run via Remaining items worth addressing before mergeSummarizing from my earlier review for visibility:
|
Delete test-results/.last-run.json (Playwright run artifact) and add test-results/ to .gitignore so it won't be committed again.
Replace DEBUG_ENABLED = false with a runtime check of globalThis.SCRIPT_DEBUG, matching the @wordpress/warning pattern. Production builds become no-ops automatically, developers can flip SCRIPT_DEBUG to see the Upload Media track in the DevTools Performance panel without editing the source.
Drop getRetryMessage() and getMaxRetriesExceededMessage() — these belong with the retry scheduling work shipping in a follow-up PR, not the error-classification scaffolding.
Previously the ErrorCode enum and error-messages.ts helpers were
scaffolding with no producers or consumers: throw sites used bare
strings ('HEIC_DECODE_ERROR', 'EMPTY_FILE', etc.), isRetryable
always returned false, and nothing was exported from index.ts.
Changes:
- Rework ErrorCode to include the codes actually thrown today
(EMPTY_FILE, SIZE_ABOVE_LIMIT, MIME_TYPE_NOT_SUPPORTED,
MIME_TYPE_NOT_ALLOWED_FOR_USER, HEIC_DECODE_ERROR,
IMAGE_TRANSCODING_ERROR, IMAGE_ROTATION_ERROR,
MEDIA_TRANSCODING_ERROR) plus retry-targets kept for the
follow-up PR (NETWORK_ERROR, TIMEOUT_ERROR, SERVER_ERROR).
- Drop speculative codes that nothing threw or consumed.
- Migrate validate-file-size, validate-mime-type,
validate-mime-type-for-user, and private-actions throw sites
to use ErrorCode members instead of string literals.
- Rewrite error-messages.ts so every enum entry has a matching
localized message, and tighten getErrorMessage's parameter
type to ErrorCode | string.
- Export ErrorCode, getErrorMessage, and ErrorMessageConfig
from the package index so consumers can actually use them.
Resolve conflicts in packages/upload-media/package.json and package-lock.json by keeping the new @wordpress/warning dependency introduced in this PR alongside trunk's uuid bump to ^14.0.0. Move the PR's changelog entries back under the Unreleased heading since this PR is not yet merged; trunk's 0.30.0 release contains only the #75257 entry.
Resolve conflicts in upload-media CHANGELOG and store actions: - CHANGELOG: combine Unreleased entries from both sides; keep trunk's scaled-suffix bug fixes alongside this PR's enhancements. - actions.ts: keep trunk's parentId guard on the cancellation logger and layer this PR's warning() call inside it; adopt trunk's local batchId variable in the batch-complete check. - private-actions.ts: keep this PR's ErrorCode enum members on the IMAGE_TRANSCODING_ERROR / IMAGE_ROTATION_ERROR throws while adopting trunk's localized user-facing messages.
…error-handling # Conflicts: # packages/upload-media/CHANGELOG.md
…error-handling # Conflicts: # packages/upload-media/CHANGELOG.md
The cancelItem retry-integration tests exercise the cancel-logging path, where cancelItem() deliberately calls warning() for silent cancels and for top-level items without an onError handler. The jest-console guard failed those tests because the warning was unexpected. Assert toHaveWarned() so the expected diagnostic is acknowledged.
|
What's the status of this PR? I remember there being quite a bit of overlap with the retry PR, and in its current form it looks like (Apologies, I couldn't remember where we were up to with it!) |
The auto-retry follow-up (#76765) shipped its own retryability check based on error-message patterns (RETRYABLE_MESSAGE_PATTERNS in store/utils/retry.ts), because uploads reject with plain Error instances from fetch/api-fetch rather than UploadError carrying a code. That leaves this PR's UploadError#isRetryable getter, the RETRYABLE_CODES allowlist, and the aspirational NETWORK_ERROR / TIMEOUT_ERROR / SERVER_ERROR codes (and their messages) unused and unproduced. Remove them so the ErrorCode enum only describes failures the package actually throws, keeping getErrorMessage as the user-facing message layer. Also relocate the unmerged #74917 CHANGELOG entries from the released 0.33.0 section back to Unreleased, where they belong, and drop the isRetryable mention.
|
Good memory, you were right on both counts. The retry follow-up (#76765) already merged and classifies retryability via error-message patterns, so this PR's So the scope now is diagnostics + error taxonomy, not user-facing error handling per se:
Updated the title/description to match. |
andrewserong
left a comment
There was a problem hiding this comment.
Thanks for the extra context @adamsilverstein! Good idea to get this in, and also to keep the wiring up of the error messages to a follow-up 👍
This looks close to me, just left a bunch of comments from running the PR through a Claude session (so take the comments with a grain of salt, they might be a little off).
| code: ErrorCode | string, | ||
| fileName: string | ||
| ): ErrorMessageConfig { | ||
| const messages: Record< string, ErrorMessageConfig > = { |
There was a problem hiding this comment.
Tiniest of nits: this function creates an object and then performs a lookup to it, but it'll only ever look up one item. We could also do this via a switch statement which might read a little neater (this is highly subjective though — if you prefer the way you've done it, ignore this comment!)
There was a problem hiding this comment.
Kept the object map here — as you noted it's subjective, and I find the keyed config a touch more readable than a long switch. Happy to flip it if you feel strongly.
| } | ||
|
|
||
| interface UploadErrorArgs { | ||
| code: string; |
There was a problem hiding this comment.
This PR introduces the enum but currently doesn't appear to limit the type to it? (I might have gotten that wrong, so apologies if I've missed it!)
Should we also be typing things elsewhere to enforce use of the enum, or is the enum mostly a convenience to guard against typos? (Which is also fine as an approach)
There was a problem hiding this comment.
Intentional — code is typed ErrorCode | string so that codes originating outside this package (e.g. server-side or future call sites) still pass through without a type error. getErrorMessage() looks the code up in the enum-keyed map and falls back to GENERAL for anything unrecognized, so the enum drives the known set while the | string keeps it forward-compatible.
| warning( | ||
| `Upload cancelled for item ${ id }: ${ error.message }` | ||
| ); | ||
| // eslint-disable-next-line no-console -- Deliberately log errors here. | ||
| console.error( 'Upload cancelled', error ); |
There was a problem hiding this comment.
This now performs to logging calls (one a warning and one an error). Should we pick one?
There was a problem hiding this comment.
Consolidated with the same refactor — the diagnostic path now goes through the SCRIPT_DEBUG console.debug helper, leaving a single console.error for the no-handler case.
| // User action | ||
| ABORTED = 'ABORTED', | ||
|
|
||
| // Generic fallback | ||
| GENERAL = 'GENERAL', |
There was a problem hiding this comment.
It looks like both ABORTED and GENERAL aren't being used yet (though they do have string translations). Are they included here intentionally, or do they need wiring up?
There was a problem hiding this comment.
GENERAL is now wired up as the fallback (thrown in actions.ts). ABORTED had no throw site, so I removed it in b72165c rather than ship an unused translated string.
| } ); | ||
| } ); | ||
|
|
||
| describe( `${ Type.RevokeBlobUrls }`, () => { |
There was a problem hiding this comment.
Some of these reducer tests seem unrelated to the other changes, are these leftovers from the other PR, or are these added intentionally?
There was a problem hiding this comment.
You're right, these were leftovers from when the retry logic lived on this branch. Removed in b8024dd; the reducer test file now matches trunk exactly. PauseQueue/ResumeQueue are already covered there (landed with the retry work in #76765), and CacheBlobUrl/RevokeBlobUrls were out of scope for this PR.
Replace the @wordpress/warning calls added for upload diagnostics with a new SCRIPT_DEBUG-gated debug() helper in debug-logger.ts. warning() deliberately throws and catches an Error so it can pause debuggers on caught exceptions; pointing that at normal lifecycle events (silent cancellations, batch completion) yanked any developer with that breakpoint into the debugger on every batch finish. console.debug is the right semantics for non-error diagnostics and does not throw. The no-handler cancellation path now logs only console.error (a genuine error) instead of both warning() and console.error. Drop the now-unused @wordpress/warning dependency. Also type UploadError.code as ErrorCode | string and use ErrorCode.GENERAL in place of the bare 'UPLOAD_ERROR' string in the parent-cancel fallback, wiring up the previously unused enum member.
|
Thanks @andrewserong - good catches from that Claude session. Addressed in ac1e4a6:
On the two I left as-is:
|
| { "path": "../url" }, | ||
| { "path": "../vips" } | ||
| { "path": "../vips" }, | ||
| { "path": "../warning" } |
There was a problem hiding this comment.
Tiny nit: we no longer need this warning entry.
There was a problem hiding this comment.
Removed in 38ea9fd — @wordpress/warning is no longer a dependency.
ErrorCode.ABORTED had a translated message but no throw site; the cancel path logs via console.error rather than throwing an UploadError. Drop it until a code path actually needs it (GENERAL remains the wired fallback). Addresses review feedback from andrewserong.
PauseQueue and ResumeQueue are already covered by trunk's reducer tests (landed with the retry work in #76765), and CacheBlobUrl/RevokeBlobUrls test reducers unrelated to this PR's error-taxonomy scope. These were leftovers from when retry logic lived on this branch; the reducer test file now matches trunk. Addresses review feedback from andrewserong.
@wordpress/warning is no longer a dependency of the package after the switch to a SCRIPT_DEBUG-gated console.debug helper. Addresses review feedback from andrewserong.
The standard test commands write Playwright output to artifacts/test-results (via WP_ARTIFACTS_PATH), which is already covered by the ignored /artifacts directory. A root-level test-results/ only appears when running Playwright directly, and this entry was added defensively after an accidental commit during development. It is unrelated to this PR, so remove it.
Cover the new public API exported from the package entry point: every ErrorCode maps to a non-empty title/description, the file name is interpolated into the description, distinct codes yield distinct messages, and an unknown code falls back to the generic GENERAL message.

Fixes #74366
Summary
Adds an error-code taxonomy, localized error messages, and dev-only diagnostics to
@wordpress/upload-media.This started as half of a combined error-handling + retry PR. Auto-retry has since landed separately in #76765, which classifies retryable failures by matching
fetch/api-fetcherror messages (uploads reject with plainError, notUploadError). The retry-classification scaffolding originally in this PR (UploadError#isRetryable, aRETRYABLE_CODESallowlist, and aspirationalNETWORK_ERROR/TIMEOUT_ERROR/SERVER_ERRORcodes) was therefore redundant and has been removed - this PR no longer overlaps with retry.What's in this PR
Error taxonomy
ErrorCodeenum covering every code the package actually throws (EMPTY_FILE,SIZE_ABOVE_LIMIT,MIME_TYPE_NOT_SUPPORTED,MIME_TYPE_NOT_ALLOWED_FOR_USER,HEIC_DECODE_ERROR,IMAGE_TRANSCODING_ERROR,IMAGE_ROTATION_ERROR,MEDIA_TRANSCODING_ERROR,GENERAL).validate-file-size,validate-mime-type,validate-mime-type-for-user,store/private-actions) migrated from bare string codes toErrorCodemembers.error-messages.ts- localized, i18n-ready message map (title/description/ optionalaction) for every code, with a sane fallback.ErrorCode,getErrorMessage, and theErrorMessageConfigtype are exported from the package entry point so consumers can render friendly messages off a rawUploadError. Not yet wired into any Notice UI - rendering these in the editor is a follow-up.Dev-only diagnostic logging
store/utils/debug-logger.tsexposes adebug()helper that writes toconsole.debug. Unlike@wordpress/warningit doesn't throw a caughtError, so it won't trip "pause on caught exceptions".cancelItemlogs item cancellations and batch completions via thisconsole.debugdiagnostic; top-level cancellations without anonErrorhandler are surfaced viaconsole.error. All gated onSCRIPT_DEBUG, so no production overhead.Performance instrumentation (User Timings API)
store/utils/debug-logger.tsexposes ameasure()helper that writes entries to a custom "Upload Media" track in the DevTools Performance panel.uploadItem,sideloadItem,resizeCropItem,rotateItem,transcodeImageItem.globalThis.SCRIPT_DEBUG, so production builds skip theperformance.measurecall entirely.Incidental fix
uploadItemnow guards against double-dispatch offinishOperationwhen bothonFileChangeandonSuccessfire for the same attachment (idempotentfinishUploadclosure).Test plan
@wordpress/upload-media), including new coverage forgetErrorMessage(everyErrorCodemaps to a message, file name interpolation, unknown-code fallback)prettier --check/ lint clean on changed filesSCRIPT_DEBUG = true, verify theconsole.debugdiagnostics surface on cancel and batch completionSCRIPT_DEBUG = true, confirm the "Upload Media" track appears in the DevTools Performance panel for an upload with sub-size sideloadsmaxUploadFileSize, verify the resultingUploadErrorpassed toonErrorhascode === ErrorCode.SIZE_ABOVE_LIMIT