Skip to content

Commit c18a059

Browse files
authored
Merge pull request #6470 from Shopify/record-retries-theme-events
Record retryable requests to monorail for theme commands
2 parents 21e748f + 48744b6 commit c18a059

File tree

3 files changed

+243
-0
lines changed

3 files changed

+243
-0
lines changed

packages/cli-kit/src/private/node/api.test.ts

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import {retryAwareRequest, isNetworkError, isTransientNetworkError} from './api.js'
2+
import {recordRetry} from '../../public/node/analytics.js'
23
import {ClientError} from 'graphql-request'
34
import {describe, test, vi, expect, beforeEach, afterEach} from 'vitest'
45

6+
vi.mock('../../public/node/analytics.js', () => ({
7+
recordRetry: vi.fn(),
8+
}))
9+
510
describe('retryAwareRequest', () => {
611
beforeEach(() => {
712
vi.useFakeTimers()
@@ -374,6 +379,229 @@ describe('retryAwareRequest', () => {
374379
}),
375380
)
376381
})
382+
383+
test('records retry events when recordRetries is enabled', async () => {
384+
const rateLimitedResponse = {
385+
status: 200,
386+
errors: [
387+
{
388+
extensions: {
389+
code: '429',
390+
},
391+
} as any,
392+
],
393+
headers: new Headers({'retry-after': '100'}),
394+
}
395+
396+
const successResponse = {
397+
status: 200,
398+
data: {hello: 'world!'},
399+
headers: new Headers(),
400+
}
401+
402+
const mockRequestFn = vi
403+
.fn()
404+
.mockImplementationOnce(() => {
405+
throw new ClientError(rateLimitedResponse, {query: ''})
406+
})
407+
.mockImplementation(() => {
408+
return Promise.resolve(successResponse)
409+
})
410+
411+
const mockScheduleDelayFn = vi.fn((fn) => fn())
412+
413+
const result = retryAwareRequest(
414+
{
415+
request: mockRequestFn,
416+
url: 'https://themes.example.com/api',
417+
useNetworkLevelRetry: true,
418+
maxRetryTimeMs: 10000,
419+
recordCommandRetries: true,
420+
},
421+
undefined,
422+
{
423+
scheduleDelay: mockScheduleDelayFn,
424+
},
425+
)
426+
await vi.runAllTimersAsync()
427+
428+
await expect(result).resolves.toEqual({
429+
headers: expect.anything(),
430+
status: 200,
431+
data: {hello: 'world!'},
432+
})
433+
434+
expect(recordRetry).toHaveBeenCalledTimes(1)
435+
expect(recordRetry).toHaveBeenCalledWith('https://themes.example.com/api', 'http-retry-1:can-retry:')
436+
})
437+
438+
test('does not record retry events when recordRetries is disabled', async () => {
439+
const rateLimitedResponse = {
440+
status: 200,
441+
errors: [
442+
{
443+
extensions: {
444+
code: '429',
445+
},
446+
} as any,
447+
],
448+
headers: new Headers(),
449+
}
450+
451+
const successResponse = {
452+
status: 200,
453+
data: {hello: 'world!'},
454+
headers: new Headers(),
455+
}
456+
457+
const mockRequestFn = vi
458+
.fn()
459+
.mockImplementationOnce(() => {
460+
throw new ClientError(rateLimitedResponse, {query: ''})
461+
})
462+
.mockImplementation(() => {
463+
return Promise.resolve(successResponse)
464+
})
465+
466+
const mockScheduleDelayFn = vi.fn((fn) => fn())
467+
468+
const result = retryAwareRequest(
469+
{
470+
request: mockRequestFn,
471+
url: 'https://app.example.com/api',
472+
useNetworkLevelRetry: true,
473+
maxRetryTimeMs: 10000,
474+
recordCommandRetries: false,
475+
},
476+
undefined,
477+
{
478+
scheduleDelay: mockScheduleDelayFn,
479+
},
480+
)
481+
await vi.runAllTimersAsync()
482+
483+
await expect(result).resolves.toEqual({
484+
headers: expect.anything(),
485+
status: 200,
486+
data: {hello: 'world!'},
487+
})
488+
489+
expect(recordRetry).not.toHaveBeenCalled()
490+
})
491+
492+
test('records multiple retry events with correct attempt numbers', async () => {
493+
const rateLimitedResponse = {
494+
status: 200,
495+
errors: [
496+
{
497+
extensions: {
498+
code: '429',
499+
},
500+
} as any,
501+
],
502+
headers: new Headers(),
503+
}
504+
505+
const successResponse = {
506+
status: 200,
507+
data: {success: true},
508+
headers: new Headers(),
509+
}
510+
511+
const mockRequestFn = vi
512+
.fn()
513+
.mockImplementationOnce(() => {
514+
throw new ClientError(rateLimitedResponse, {query: ''})
515+
})
516+
.mockImplementationOnce(() => {
517+
throw new ClientError(rateLimitedResponse, {query: ''})
518+
})
519+
.mockImplementation(() => {
520+
return Promise.resolve(successResponse)
521+
})
522+
523+
const mockScheduleDelayFn = vi.fn((fn) => fn())
524+
525+
const result = retryAwareRequest(
526+
{
527+
request: mockRequestFn,
528+
url: 'https://themes.example.com/upload',
529+
useNetworkLevelRetry: true,
530+
maxRetryTimeMs: 10000,
531+
recordCommandRetries: true,
532+
},
533+
undefined,
534+
{
535+
scheduleDelay: mockScheduleDelayFn,
536+
},
537+
)
538+
await vi.runAllTimersAsync()
539+
540+
await expect(result).resolves.toEqual({
541+
headers: expect.anything(),
542+
status: 200,
543+
data: {success: true},
544+
})
545+
546+
expect(recordRetry).toHaveBeenCalledTimes(2)
547+
expect(recordRetry).toHaveBeenNthCalledWith(1, 'https://themes.example.com/upload', 'http-retry-1:can-retry:')
548+
expect(recordRetry).toHaveBeenNthCalledWith(2, 'https://themes.example.com/upload', 'http-retry-2:can-retry:')
549+
})
550+
551+
test('records retry events for too many requests status', async () => {
552+
const rateLimitedResponse = {
553+
status: 200,
554+
errors: [
555+
{
556+
extensions: {
557+
code: '429',
558+
},
559+
} as any,
560+
],
561+
headers: new Headers(),
562+
}
563+
564+
const successResponse = {
565+
status: 200,
566+
data: {authenticated: true},
567+
headers: new Headers(),
568+
}
569+
570+
const mockRequestFn = vi
571+
.fn()
572+
.mockImplementationOnce(() => {
573+
throw new ClientError(rateLimitedResponse, {query: ''})
574+
})
575+
.mockImplementation(() => {
576+
return Promise.resolve(successResponse)
577+
})
578+
579+
const mockScheduleDelayFn = vi.fn((fn) => fn())
580+
581+
const result = retryAwareRequest(
582+
{
583+
request: mockRequestFn,
584+
url: 'https://themes.example.com/auth',
585+
useNetworkLevelRetry: true,
586+
maxRetryTimeMs: 10000,
587+
recordCommandRetries: true,
588+
},
589+
undefined,
590+
{
591+
scheduleDelay: mockScheduleDelayFn,
592+
},
593+
)
594+
await vi.runAllTimersAsync()
595+
596+
await expect(result).resolves.toEqual({
597+
headers: expect.anything(),
598+
status: 200,
599+
data: {authenticated: true},
600+
})
601+
602+
expect(recordRetry).toHaveBeenCalledTimes(1)
603+
expect(recordRetry).toHaveBeenCalledWith('https://themes.example.com/auth', 'http-retry-1:can-retry:')
604+
})
377605
})
378606

379607
describe('isTransientNetworkError', () => {

packages/cli-kit/src/private/node/api.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {sanitizedHeadersOutput} from './api/headers.js'
22
import {sanitizeURL} from './api/urls.js'
33
import {sleepWithBackoffUntil} from './sleep-with-backoff.js'
44
import {outputDebug} from '../../public/node/output.js'
5+
import {recordRetry} from '../../public/node/analytics.js'
56
import {Headers} from 'form-data'
67
import {ClientError} from 'graphql-request'
78
import {performance} from 'perf_hooks'
@@ -17,9 +18,11 @@ export type NetworkRetryBehaviour =
1718
| {
1819
useNetworkLevelRetry: true
1920
maxRetryTimeMs: number
21+
recordCommandRetries?: boolean
2022
}
2123
| {
2224
useNetworkLevelRetry: false
25+
recordCommandRetries?: boolean
2326
}
2427

2528
type RequestOptions<T> = {
@@ -149,6 +152,12 @@ async function runRequestWithNetworkLevelRetry<T extends {headers: Headers; stat
149152
if (!isTransientNetworkError(err)) {
150153
throw err
151154
}
155+
156+
// Record command retries
157+
if (requestOptions.recordCommandRetries) {
158+
recordRetry(requestOptions.url, `network-retry:${(err as Error).message}`)
159+
}
160+
152161
outputDebug(`Retrying request to ${requestOptions.url} due to network error ${err}`)
153162
}
154163
}
@@ -371,6 +380,11 @@ ${result.sanitizedHeaders}
371380
}
372381
retriesUsed += 1
373382

383+
// Record command retries
384+
if (requestOptions.recordCommandRetries) {
385+
recordRetry(requestOptions.url, `http-retry-${retriesUsed}:${result.status}:`)
386+
}
387+
374388
// prefer to wait based on a header if given; the caller's preference if not; and a default if neither.
375389
const retryDelayMs = result.delayMs ?? retryOptions.defaultDelayMs ?? DEFAULT_RETRY_DELAY_MS
376390
outputDebug(`Scheduling retry request #${retriesUsed} to ${result.sanitizedUrl} in ${retryDelayMs} ms`)

packages/cli-kit/src/public/node/themes/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const THEME_API_NETWORK_BEHAVIOUR: RequestModeInput = {
3737
useNetworkLevelRetry: true,
3838
useAbortSignal: false,
3939
maxRetryTimeMs: 90 * 1000,
40+
recordCommandRetries: true,
4041
}
4142

4243
export async function fetchTheme(id: number, session: AdminSession): Promise<Theme | undefined> {

0 commit comments

Comments
 (0)