Skip to content

Commit e27d778

Browse files
authored
Merge pull request #5226 from Shopify/01-17-improve_dev-session_retry_and_error_report
Improve dev-session retry and error report
2 parents afb1553 + 3388086 commit e27d778

File tree

2 files changed

+80
-30
lines changed

2 files changed

+80
-30
lines changed

packages/app/src/cli/services/dev/processes/dev-session.test.ts

+18-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {setupDevSessionProcess, pushUpdatesForDevSession} from './dev-session.js'
1+
import {setupDevSessionProcess, pushUpdatesForDevSession, resetDevSessionStatus} from './dev-session.js'
22
import {DeveloperPlatformClient} from '../../../utilities/developer-platform-client.js'
33
import {AppLinkedInterface} from '../../../models/app/app.js'
44
import {AppEventWatcher} from '../app-events/app-event-watcher.js'
@@ -77,6 +77,7 @@ describe('pushUpdatesForDevSession', () => {
7777
app = testAppLinked()
7878
appWatcher = new AppEventWatcher(app)
7979
abortController = new AbortController()
80+
resetDevSessionStatus()
8081
options = {
8182
developerPlatformClient,
8283
appWatcher,
@@ -188,4 +189,20 @@ describe('pushUpdatesForDevSession', () => {
188189
expect(stdout.write).toHaveBeenCalledWith(expect.stringContaining('Scopes updated'))
189190
expect(contextSpy).toHaveBeenCalledWith({outputPrefix: 'dev-session', stripAnsi: false}, expect.anything())
190191
})
192+
193+
test('update is retried if there is an error', async () => {
194+
// Given
195+
developerPlatformClient.devSessionUpdate = vi.fn().mockRejectedValue(new Error('Test error'))
196+
197+
// When
198+
await pushUpdatesForDevSession({stderr, stdout, abortSignal: abortController.signal}, options)
199+
await appWatcher.start({stdout, stderr, signal: abortController.signal})
200+
await flushPromises()
201+
appWatcher.emit('all', {app, extensionEvents: [{type: 'updated', extension: testWebhookExtensions()}]})
202+
await flushPromises()
203+
204+
// Then
205+
expect(developerPlatformClient.refreshToken).toHaveBeenCalledOnce()
206+
expect(developerPlatformClient.devSessionUpdate).toHaveBeenCalledTimes(2)
207+
})
191208
})

packages/app/src/cli/services/dev/processes/dev-session.ts

+62-29
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ import {endHRTimeInMs, startHRTime} from '@shopify/cli-kit/node/hrtime'
1313
import {performActionWithRetryAfterRecovery} from '@shopify/cli-kit/common/retry'
1414
import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components'
1515
import {JsonMapType} from '@shopify/cli-kit/node/toml'
16-
import {AbortError} from '@shopify/cli-kit/node/error'
1716
import {isUnitTest} from '@shopify/cli-kit/node/context/local'
1817
import {getArrayRejectingUndefined} from '@shopify/cli-kit/common/array'
18+
import {AbortError} from '@shopify/cli-kit/node/error'
19+
import {ClientError} from 'graphql-request'
1920
import {Writable} from 'stream'
2021

2122
interface DevSessionOptions {
@@ -48,6 +49,12 @@ interface UserError {
4849
category: string
4950
}
5051

52+
interface DevSessionPayload {
53+
shopFqdn: string
54+
appId: string
55+
assetsUrl: string
56+
}
57+
5158
type DevSessionResult =
5259
| {status: 'updated' | 'created' | 'aborted'}
5360
| {status: 'remote-error'; error: UserError[]}
@@ -65,6 +72,10 @@ export function devSessionStatus() {
6572
}
6673
}
6774

75+
export function resetDevSessionStatus() {
76+
isDevSessionReady = false
77+
}
78+
6879
export async function setupDevSessionProcess({
6980
app,
7081
apiKey,
@@ -88,12 +99,7 @@ export const pushUpdatesForDevSession: DevProcessFunction<DevSessionOptions> = a
8899
{stderr, stdout, abortSignal: signal},
89100
options,
90101
) => {
91-
const {developerPlatformClient, appWatcher} = options
92-
93-
isDevSessionReady = false
94-
const refreshToken = async () => {
95-
return developerPlatformClient.refreshToken()
96-
}
102+
const {appWatcher} = options
97103

98104
const processOptions = {...options, stderr, stdout, signal, bundlePath: appWatcher.buildOutputPath}
99105

@@ -106,7 +112,7 @@ export const pushUpdatesForDevSession: DevProcessFunction<DevSessionOptions> = a
106112
return
107113
}
108114

109-
// If there are any errors build errors, don't update the dev session
115+
// If there are any build errors, don't update the dev session
110116
const anyError = event.extensionEvents.some((eve) => eve.buildResult?.status === 'error')
111117
if (anyError) return
112118

@@ -133,19 +139,16 @@ export const pushUpdatesForDevSession: DevProcessFunction<DevSessionOptions> = a
133139
})
134140

135141
const networkStartTime = startHRTime()
136-
await performActionWithRetryAfterRecovery(async () => {
137-
const result = await bundleExtensionsAndUpload({...processOptions, app: event.app})
138-
await handleDevSessionResult(result, {...processOptions, app: event.app}, event)
139-
const endTime = endHRTimeInMs(event.startTime)
140-
const endNetworkTime = endHRTimeInMs(networkStartTime)
141-
outputDebug(`✅ Event handled [Network: ${endNetworkTime}ms -- Total: ${endTime}ms]`, processOptions.stdout)
142-
}, refreshToken)
142+
const result = await bundleExtensionsAndUpload({...processOptions, app: event.app})
143+
await handleDevSessionResult(result, {...processOptions, app: event.app}, event)
144+
outputDebug(
145+
`✅ Event handled [Network: ${endHRTimeInMs(networkStartTime)}ms - Total: ${endHRTimeInMs(event.startTime)}ms]`,
146+
processOptions.stdout,
147+
)
143148
})
144149
.onStart(async (event) => {
145-
await performActionWithRetryAfterRecovery(async () => {
146-
const result = await bundleExtensionsAndUpload({...processOptions, app: event.app})
147-
await handleDevSessionResult(result, {...processOptions, app: event.app})
148-
}, refreshToken)
150+
const result = await bundleExtensionsAndUpload({...processOptions, app: event.app})
151+
await handleDevSessionResult(result, {...processOptions, app: event.app})
149152
})
150153
}
151154

@@ -168,7 +171,7 @@ async function handleDevSessionResult(
168171

169172
// If we failed to create a session, exit the process. Don't throw an error in tests as it can't be caught due to the
170173
// async nature of the process.
171-
if (!isDevSessionReady && !isUnitTest()) throw new AbortError('Failed to create dev session')
174+
if (!isDevSessionReady && !isUnitTest()) throw new AbortError('Failed to start dev session.')
172175
}
173176

174177
/**
@@ -227,11 +230,7 @@ async function bundleExtensionsAndUpload(options: DevSessionProcessOptions): Pro
227230

228231
// Get a signed URL to upload the zip file
229232
if (currentBundleController.signal.aborted) return {status: 'aborted'}
230-
const signedURL = await getExtensionUploadURL(options.developerPlatformClient, {
231-
apiKey: options.appId,
232-
organizationId: options.organizationId,
233-
id: options.appId,
234-
})
233+
const signedURL = await getSignedURLWithRetry(options)
235234

236235
// Upload the zip file
237236
if (currentBundleController.signal.aborted) return {status: 'aborted'}
@@ -244,33 +243,67 @@ async function bundleExtensionsAndUpload(options: DevSessionProcessOptions): Pro
244243
headers: form.getHeaders(),
245244
})
246245

247-
const payload = {shopFqdn: options.storeFqdn, appId: options.appId, assetsUrl: signedURL}
246+
const payload: DevSessionPayload = {shopFqdn: options.storeFqdn, appId: options.appId, assetsUrl: signedURL}
248247

249248
// Create or update the dev session
250249
if (currentBundleController.signal.aborted) return {status: 'aborted'}
251250
try {
252251
if (isDevSessionReady) {
253-
const result = await options.developerPlatformClient.devSessionUpdate(payload)
252+
const result = await devSessionUpdateWithRetry(payload, options.developerPlatformClient)
254253
const errors = result.devSessionUpdate?.userErrors ?? []
255254
if (errors.length) return {status: 'remote-error', error: errors}
256255
return {status: 'updated'}
257256
} else {
258-
const result = await options.developerPlatformClient.devSessionCreate(payload)
257+
const result = await devSessionCreateWithRetry(payload, options.developerPlatformClient)
259258
const errors = result.devSessionCreate?.userErrors ?? []
260259
if (errors.length) return {status: 'remote-error', error: errors}
261260
return {status: 'created'}
262261
}
263262
// eslint-disable-next-line @typescript-eslint/no-explicit-any
264263
} catch (error: any) {
265264
if (error.statusCode === 401) {
266-
// Re-throw the error so the recovery procedure can be executed
267265
throw new Error('Unauthorized')
266+
} else if (error instanceof ClientError) {
267+
if (error.response.status === 401 || error.response.status === 403) {
268+
throw new AbortError('Auth session expired. Please run `shopify app dev` again.')
269+
} else {
270+
outputDebug(JSON.stringify(error.response, null, 2), options.stdout)
271+
throw new AbortError('Unknown error')
272+
}
268273
} else {
269274
return {status: 'unknown-error', error}
270275
}
271276
}
272277
}
273278

279+
async function getSignedURLWithRetry(options: DevSessionProcessOptions) {
280+
return performActionWithRetryAfterRecovery(
281+
async () =>
282+
getExtensionUploadURL(options.developerPlatformClient, {
283+
apiKey: options.appId,
284+
organizationId: options.organizationId,
285+
id: options.appId,
286+
}),
287+
() => options.developerPlatformClient.refreshToken(),
288+
)
289+
}
290+
291+
async function devSessionUpdateWithRetry(payload: DevSessionPayload, developerPlatformClient: DeveloperPlatformClient) {
292+
return performActionWithRetryAfterRecovery(
293+
async () => developerPlatformClient.devSessionUpdate(payload),
294+
() => developerPlatformClient.refreshToken(),
295+
)
296+
}
297+
298+
// If the Dev Session Create fails, we try to refresh the token and retry the operation
299+
// This only happens if an error is thrown. Won't be triggered if we receive an error inside the response.
300+
async function devSessionCreateWithRetry(payload: DevSessionPayload, developerPlatformClient: DeveloperPlatformClient) {
301+
return performActionWithRetryAfterRecovery(
302+
async () => developerPlatformClient.devSessionCreate(payload),
303+
() => developerPlatformClient.refreshToken(),
304+
)
305+
}
306+
274307
async function processUserErrors(
275308
errors: UserError[] | Error | string,
276309
options: DevSessionProcessOptions,

0 commit comments

Comments
 (0)