-
Notifications
You must be signed in to change notification settings - Fork 136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Theme] Handle async errors #4599
Changes from all commits
0df804c
f85d195
fb77c01
10f9a58
fba3e1d
fb3c798
121b69e
95de751
594fc04
43c91c5
265d3e1
8e8fef2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@shopify/theme': patch | ||
--- | ||
|
||
Handle background async errors and avoid process exit in some scenarios. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
import {AbortError} from '@shopify/cli-kit/node/error' | ||
import {outputDebug} from '@shopify/cli-kit/node/output' | ||
import {renderError, renderFatalError} from '@shopify/cli-kit/node/ui' | ||
|
||
/** | ||
* Renders an error banner for a thrown error with a headline. | ||
* @param headline - The headline for the error. | ||
* @param error - The error to render. | ||
*/ | ||
export function renderThrownError(headline: string, error: Error | AbortError) { | ||
if (error instanceof AbortError) { | ||
error.message = `${headline}\n${error.message}` | ||
renderFatalError(error) | ||
} else { | ||
renderError({headline, body: error.message}) | ||
outputDebug(`${headline}\n${error.stack ?? error.message}`) | ||
} | ||
} | ||
|
||
/** | ||
* Creates a function that renders a failed syncing operation without interrupting the process. | ||
* @param resource - The file that was being operated on, or a headline for the error. | ||
* @param preset - The type of operation that failed. | ||
* @returns A function that accepts an error and renders it. | ||
*/ | ||
export function createSyncingCatchError(resource: string, preset?: 'delete' | 'upload') { | ||
const headline = | ||
{ | ||
delete: `Failed to delete file "${resource}" from remote theme.`, | ||
upload: `Failed to upload file "${resource}" to remote theme.`, | ||
default: resource, | ||
}[preset ?? 'default'] ?? resource | ||
|
||
return (error: Error) => { | ||
renderThrownError(headline, error) | ||
} | ||
} | ||
|
||
/** | ||
* Creates a function that renders a failed syncing operation and exits the process. | ||
* @param headline - The headline for the error. | ||
* @returns A function that accepts an error and renders it. | ||
*/ | ||
export function createAbortCatchError(headline: string) { | ||
return (error: Error): never => { | ||
renderThrownError(headline, error) | ||
process.exit(1) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,6 +5,7 @@ import { | |
getPatternsFromShopifyIgnore, | ||
} from './asset-ignore.js' | ||
import {Notifier} from './notifier.js' | ||
import {createSyncingCatchError} from './errors.js' | ||
import {DEFAULT_IGNORE_PATTERNS, timestampDateFormat} from '../constants.js' | ||
import {glob, readFile, ReadOptions, fileExists, mkdir, writeFile, removeFile} from '@shopify/cli-kit/node/fs' | ||
import {joinPath, basename, relativePath} from '@shopify/cli-kit/node/path' | ||
|
@@ -13,7 +14,6 @@ import {outputContent, outputDebug, outputInfo, outputToken, outputWarn} from '@ | |
import {buildThemeAsset} from '@shopify/cli-kit/node/themes/factories' | ||
import {AdminSession} from '@shopify/cli-kit/node/session' | ||
import {bulkUploadThemeAssets, deleteThemeAsset} from '@shopify/cli-kit/node/themes/api' | ||
import {renderError} from '@shopify/cli-kit/node/ui' | ||
import EventEmitter from 'node:events' | ||
import type { | ||
ThemeFileSystem, | ||
|
@@ -144,27 +144,26 @@ export function mountThemeFileSystem(root: string, options?: ThemeFileSystemOpti | |
return file.value || file.attachment || '' | ||
}) | ||
|
||
const syncPromise = contentPromise.then(async (content) => { | ||
if (!unsyncedFileKeys.has(fileKey)) return false | ||
const syncPromise = contentPromise | ||
.then(async (content) => { | ||
if (!unsyncedFileKeys.has(fileKey)) return false | ||
|
||
const [result] = await bulkUploadThemeAssets(Number(themeId), [{key: fileKey, value: content}], adminSession) | ||
const [result] = await bulkUploadThemeAssets(Number(themeId), [{key: fileKey, value: content}], adminSession) | ||
|
||
if (!result?.success) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (nit) Would prefer There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not familiar with the underlying API this calls in |
||
throw new Error( | ||
result?.errors?.asset | ||
? `\n\n${result.errors.asset.map((error) => `- ${error}`).join('\n')}` | ||
: 'Response was not successful.', | ||
) | ||
} | ||
|
||
if (result?.success) { | ||
unsyncedFileKeys.delete(fileKey) | ||
outputSyncResult('update', fileKey) | ||
} else if (result?.errors?.asset) { | ||
const errorMessage = `${fileKey}:\n${result.errors.asset.map((error) => `- ${error}`).join('\n')}` | ||
|
||
renderError({ | ||
headline: 'Failed to sync file to remote theme.', | ||
body: errorMessage, | ||
}) | ||
|
||
return false | ||
} | ||
|
||
return true | ||
}) | ||
return true | ||
}) | ||
.catch(createSyncingCatchError(fileKey, 'upload')) | ||
|
||
emitEvent(eventName, { | ||
fileKey, | ||
|
@@ -195,14 +194,13 @@ export function mountThemeFileSystem(root: string, options?: ThemeFileSystemOpti | |
emitEvent('unlink', {fileKey}) | ||
|
||
deleteThemeAsset(Number(themeId), fileKey, adminSession) | ||
.then(async (success) => { | ||
if (!success) throw new Error(`Failed to delete file "${fileKey}" from remote theme.`) | ||
.then((success) => { | ||
if (!success) throw new Error(`Response was not successful.`) | ||
|
||
unsyncedFileKeys.delete(fileKey) | ||
outputSyncResult('delete', fileKey) | ||
}) | ||
.catch((error) => { | ||
outputDebug(error.message) | ||
}) | ||
.catch(createSyncingCatchError(fileKey, 'delete')) | ||
} | ||
|
||
const directoriesToWatch = new Set( | ||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -1,15 +1,17 @@ | ||||||
import {partitionThemeFiles} from './theme-fs.js' | ||||||
import {rejectGeneratedStaticAssets} from './asset-checksum.js' | ||||||
import {renderTasksToStdErr} from './theme-ui.js' | ||||||
import {createSyncingCatchError} from './errors.js' | ||||||
import {AdminSession} from '@shopify/cli-kit/node/session' | ||||||
import {Result, Checksum, Theme, ThemeFileSystem} from '@shopify/cli-kit/node/themes/types' | ||||||
import {AssetParams, bulkUploadThemeAssets, deleteThemeAsset} from '@shopify/cli-kit/node/themes/api' | ||||||
import {renderError, renderWarning, Task} from '@shopify/cli-kit/node/ui' | ||||||
import {Task} from '@shopify/cli-kit/node/ui' | ||||||
import {outputDebug, outputInfo, outputNewline, outputWarn} from '@shopify/cli-kit/node/output' | ||||||
|
||||||
interface UploadOptions { | ||||||
nodelete?: boolean | ||||||
deferPartialWork?: boolean | ||||||
backgroundWorkCatch?: (error: Error) => never | ||||||
} | ||||||
|
||||||
type ChecksumWithSize = Checksum & {size: number} | ||||||
|
@@ -46,11 +48,16 @@ export function uploadTheme( | |||||
|
||||||
const workPromise = options?.deferPartialWork | ||||||
? themeCreationPromise | ||||||
: deleteJobPromise | ||||||
.then((result) => result.promise) | ||||||
.catch(() => { | ||||||
renderWarning({headline: 'Failed to delete outdated files from remote theme.'}) | ||||||
}) | ||||||
: deleteJobPromise.then((result) => result.promise) | ||||||
|
||||||
if (options?.backgroundWorkCatch) { | ||||||
// Aggregate all backgorund work in a single promise and handle errors | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
Promise.all([ | ||||||
themeCreationPromise, | ||||||
uploadJobPromise.then((result) => result.promise), | ||||||
deleteJobPromise.then((result) => result.promise), | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this would have already run on L51? I might be misreading this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Afaik, the I think? 🤔 |
||||||
]).catch(options.backgroundWorkCatch) | ||||||
} | ||||||
|
||||||
return { | ||||||
uploadResults, | ||||||
|
@@ -124,12 +131,15 @@ function buildDeleteJob( | |||||
const remoteFilesToBeDeleted = getRemoteFilesToBeDeleted(remoteChecksums, themeFileSystem) | ||||||
const orderedFiles = orderFilesToBeDeleted(remoteFilesToBeDeleted) | ||||||
|
||||||
let failedDeleteAttempts = 0 | ||||||
const progress = {current: 0, total: orderedFiles.length} | ||||||
const promise = Promise.all( | ||||||
orderedFiles.map((file) => | ||||||
deleteThemeAsset(theme.id, file.key, session) | ||||||
.catch((error) => { | ||||||
renderError({headline: `Failed to delete file "${file.key}" from remote theme.`, body: error.message}) | ||||||
.catch((error: Error) => { | ||||||
failedDeleteAttempts++ | ||||||
if (failedDeleteAttempts > 3) throw error | ||||||
createSyncingCatchError(file.key, 'delete')(error) | ||||||
}) | ||||||
.finally(() => { | ||||||
progress.current++ | ||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of
.catch()
ing on each of these could we wrap the whole thing in atry/catch
? Maybe not with the promises?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be much more verbose with async promises than
.catch
, though. We could also pass it as the second param in.then(() => {}, abort)
but I think most people are more familiar with.catch
🤔