Skip to content

Commit 672a963

Browse files
authored
Merge pull request #6244 from Shopify/make-retry-errors-resilient
Harden network retry classification
2 parents b811fb9 + 52011fb commit 672a963

File tree

4 files changed

+164
-11
lines changed

4 files changed

+164
-11
lines changed

.changeset/swift-nails-battle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/cli-kit': patch
3+
---
4+
5+
Add more retryable errors and improve consistency of identifying retryable conditions

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

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,4 +198,148 @@ describe('retryAwareRequest', () => {
198198

199199
await expect(networkRetryDisabled).rejects.toThrowError('ENOTFOUND')
200200
})
201+
202+
test('retries when request is aborted by client (AbortError message)', async () => {
203+
const mockRequestFn = vi
204+
.fn()
205+
.mockImplementationOnce(() => {
206+
throw new Error('the operation was aborted')
207+
})
208+
.mockImplementationOnce(() => {
209+
return Promise.resolve({
210+
status: 200,
211+
data: {ok: true},
212+
headers: new Headers(),
213+
})
214+
})
215+
216+
const mockScheduleDelayFn = vi.fn((fn, _delay) => fn())
217+
218+
const result = retryAwareRequest(
219+
{
220+
request: mockRequestFn,
221+
url: 'https://example.com/graphql.json',
222+
useNetworkLevelRetry: true,
223+
maxRetryTimeMs: 2000,
224+
},
225+
undefined,
226+
{
227+
defaultDelayMs: 10,
228+
scheduleDelay: mockScheduleDelayFn,
229+
},
230+
)
231+
232+
await vi.runAllTimersAsync()
233+
234+
await expect(result).resolves.toEqual({
235+
headers: expect.anything(),
236+
status: 200,
237+
data: {ok: true},
238+
})
239+
expect(mockRequestFn).toHaveBeenCalledTimes(2)
240+
})
241+
242+
test('retries when fetch wrapper has blank reason in message', async () => {
243+
const blankReasonMessage = 'request to https://example.com/admin/api/unstable/graphql.json failed, reason:'
244+
245+
const mockRequestFn = vi
246+
.fn()
247+
.mockImplementationOnce(() => {
248+
throw new Error(blankReasonMessage)
249+
})
250+
.mockImplementationOnce(() => {
251+
return Promise.resolve({
252+
status: 200,
253+
data: {ok: true},
254+
headers: new Headers(),
255+
})
256+
})
257+
258+
const mockScheduleDelayFn = vi.fn((fn, _delay) => fn())
259+
260+
const result = retryAwareRequest(
261+
{
262+
request: mockRequestFn,
263+
url: 'https://example.com/graphql.json',
264+
useNetworkLevelRetry: true,
265+
maxRetryTimeMs: 2000,
266+
},
267+
undefined,
268+
{
269+
defaultDelayMs: 10,
270+
scheduleDelay: mockScheduleDelayFn,
271+
},
272+
)
273+
274+
await vi.runAllTimersAsync()
275+
276+
await expect(result).resolves.toEqual({
277+
headers: expect.anything(),
278+
status: 200,
279+
data: {ok: true},
280+
})
281+
expect(mockRequestFn).toHaveBeenCalledTimes(2)
282+
})
283+
284+
test('retries when blank reason contains trailing whitespace/newlines', async () => {
285+
const blankReasonWithWhitespace =
286+
'request to https://example.com/admin/api/unstable/graphql.json failed, reason: \n\t'
287+
288+
const mockRequestFn = vi
289+
.fn()
290+
.mockImplementationOnce(() => {
291+
throw new Error(blankReasonWithWhitespace)
292+
})
293+
.mockImplementationOnce(() => {
294+
return Promise.resolve({
295+
status: 200,
296+
data: {ok: true},
297+
headers: new Headers(),
298+
})
299+
})
300+
301+
const result = retryAwareRequest(
302+
{
303+
request: mockRequestFn,
304+
url: 'https://example.com/graphql.json',
305+
useNetworkLevelRetry: true,
306+
maxRetryTimeMs: 2000,
307+
},
308+
undefined,
309+
{defaultDelayMs: 10, scheduleDelay: (fn) => fn()},
310+
)
311+
312+
await vi.runAllTimersAsync()
313+
314+
await expect(result).resolves.toEqual({
315+
headers: expect.anything(),
316+
status: 200,
317+
data: {ok: true},
318+
})
319+
expect(mockRequestFn).toHaveBeenCalledTimes(2)
320+
})
321+
322+
test('does not treat non-blank reason as retryable when no known patterns match', async () => {
323+
vi.useRealTimers()
324+
const nonBlankUnknownReason =
325+
'request to https://example.com/admin/api/unstable/graphql.json failed, reason: gateway policy'
326+
327+
const mockRequestFn = vi.fn().mockImplementationOnce(() => {
328+
throw new Error(nonBlankUnknownReason)
329+
})
330+
331+
const result = retryAwareRequest(
332+
{
333+
request: mockRequestFn,
334+
url: 'https://example.com/graphql.json',
335+
useNetworkLevelRetry: true,
336+
maxRetryTimeMs: 2000,
337+
},
338+
undefined,
339+
{defaultDelayMs: 10, scheduleDelay: (fn) => fn()},
340+
)
341+
342+
await expect(result).rejects.toThrowError(nonBlankUnknownReason)
343+
expect(mockRequestFn).toHaveBeenCalledTimes(1)
344+
})
201345
})

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

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,18 +72,21 @@ function isARetryableNetworkError(error: unknown): boolean {
7272
if (error instanceof Error) {
7373
const networkErrorMessages = [
7474
'socket hang up',
75-
'ECONNRESET',
76-
'ECONNABORTED',
77-
'ENOTFOUND',
78-
'ENETUNREACH',
75+
'econnreset',
76+
'econnaborted',
77+
'enotfound',
78+
'enetunreach',
7979
'network socket disconnected',
80-
'ETIMEDOUT',
81-
'ECONNREFUSED',
82-
'EAI_FAIL',
83-
'The operation was aborted.',
80+
'etimedout',
81+
'econnrefused',
82+
'eai_again',
83+
'epipe',
84+
'the operation was aborted',
8485
]
85-
const anyMatches = networkErrorMessages.some((issueMessage) => error.message.includes(issueMessage))
86-
return anyMatches
86+
const errorMessage = error.message.toLowerCase()
87+
const anyMatches = networkErrorMessages.some((issueMessage) => errorMessage.includes(issueMessage))
88+
const missingReason = /^request to .* failed, reason:\s*$/.test(errorMessage)
89+
return anyMatches || missingReason
8790
}
8891
return false
8992
}

packages/cli-kit/src/public/node/vendor/otel-js/service/BaseOtelService/BaseOtelService.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ export class BaseOtelService implements OtelService {
136136
instrument.add(finalValue, finalLabels)
137137
}
138138
// We flush metrics after every record - we do not await as we fire & forget.
139-
this.meterProvider.forceFlush({})
139+
// Catch any export errors to prevent unhandled rejections from crashing the CLI
140+
void this.meterProvider.forceFlush({}).catch(() => {})
140141
}
141142
record(firstValue, firstLabels)
142143
this.metrics.set(metricName, record)

0 commit comments

Comments
 (0)