Skip to content

Commit 115f677

Browse files
authored
Merge pull request #6557 from Shopify/treat-network-errors-as-handled
Treat transient network errors as AbortError instead of BugError
2 parents b4ad9dd + 771d001 commit 115f677

File tree

4 files changed

+217
-8
lines changed

4 files changed

+217
-8
lines changed
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+
Treat all network errors as user-facing AbortError instead of BugError in fetchApiVersions

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

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {retryAwareRequest} from './api.js'
1+
import {retryAwareRequest, isNetworkError, isTransientNetworkError} from './api.js'
22
import {ClientError} from 'graphql-request'
33
import {describe, test, vi, expect, beforeEach, afterEach} from 'vitest'
44

@@ -342,4 +342,153 @@ describe('retryAwareRequest', () => {
342342
await expect(result).rejects.toThrowError(nonBlankUnknownReason)
343343
expect(mockRequestFn).toHaveBeenCalledTimes(1)
344344
})
345+
346+
test('does not retry certificate/TLS/SSL errors (permanent network errors)', async () => {
347+
vi.useRealTimers()
348+
const certificateErrors = [
349+
'certificate has expired',
350+
"Hostname/IP does not match certificate's altnames",
351+
'TLS handshake failed',
352+
'SSL certificate problem: unable to get local issuer certificate',
353+
]
354+
355+
await Promise.all(
356+
certificateErrors.map(async (certError) => {
357+
const mockRequestFn = vi.fn().mockImplementation(() => {
358+
throw new Error(certError)
359+
})
360+
361+
const result = retryAwareRequest(
362+
{
363+
request: mockRequestFn,
364+
url: 'https://example.com/graphql.json',
365+
useNetworkLevelRetry: true,
366+
maxRetryTimeMs: 2000,
367+
},
368+
undefined,
369+
{defaultDelayMs: 10, scheduleDelay: (fn) => fn()},
370+
)
371+
372+
await expect(result).rejects.toThrowError(certError)
373+
expect(mockRequestFn).toHaveBeenCalledTimes(1)
374+
}),
375+
)
376+
})
377+
})
378+
379+
describe('isTransientNetworkError', () => {
380+
test('identifies transient network errors that should be retried', () => {
381+
const transientErrors = [
382+
'socket hang up',
383+
'ECONNRESET',
384+
'ECONNABORTED',
385+
'ENOTFOUND',
386+
'ENETUNREACH',
387+
'network socket disconnected',
388+
'ETIMEDOUT',
389+
'ECONNREFUSED',
390+
'EAI_AGAIN',
391+
'EPIPE',
392+
'the operation was aborted',
393+
'timeout occurred',
394+
'premature close',
395+
'getaddrinfo ENOTFOUND',
396+
]
397+
398+
for (const errorMsg of transientErrors) {
399+
expect(isTransientNetworkError(new Error(errorMsg))).toBe(true)
400+
}
401+
})
402+
403+
test('identifies blank reason network errors', () => {
404+
const blankReasonErrors = [
405+
'request to https://example.com failed, reason:',
406+
'request to https://example.com failed, reason: ',
407+
'request to https://example.com failed, reason:\n\t',
408+
]
409+
410+
for (const errorMsg of blankReasonErrors) {
411+
expect(isTransientNetworkError(new Error(errorMsg))).toBe(true)
412+
}
413+
})
414+
415+
test('does not identify certificate errors as transient (should not be retried)', () => {
416+
const permanentErrors = [
417+
'certificate has expired',
418+
'cert verification failed',
419+
'TLS handshake failed',
420+
'SSL certificate problem',
421+
"Hostname/IP does not match certificate's altnames",
422+
]
423+
424+
for (const errorMsg of permanentErrors) {
425+
expect(isTransientNetworkError(new Error(errorMsg))).toBe(false)
426+
}
427+
})
428+
429+
test('does not identify non-network errors as transient', () => {
430+
const nonNetworkErrors = [
431+
'Invalid JSON',
432+
'Syntax error',
433+
'undefined is not a function',
434+
'request failed with status 500',
435+
]
436+
437+
for (const errorMsg of nonNetworkErrors) {
438+
expect(isTransientNetworkError(new Error(errorMsg))).toBe(false)
439+
}
440+
})
441+
442+
test('returns false for non-Error objects', () => {
443+
expect(isTransientNetworkError('string error')).toBe(false)
444+
expect(isTransientNetworkError(null)).toBe(false)
445+
expect(isTransientNetworkError(undefined)).toBe(false)
446+
expect(isTransientNetworkError({message: 'ENOTFOUND'})).toBe(false)
447+
})
448+
})
449+
450+
describe('isNetworkError', () => {
451+
test('identifies all transient network errors', () => {
452+
const transientErrors = ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'socket hang up', 'premature close']
453+
454+
for (const errorMsg of transientErrors) {
455+
expect(isNetworkError(new Error(errorMsg))).toBe(true)
456+
}
457+
})
458+
459+
test('identifies permanent network errors (certificate/TLS/SSL)', () => {
460+
const permanentErrors = [
461+
'certificate has expired',
462+
'cert verification failed',
463+
'TLS handshake failed',
464+
'SSL certificate problem',
465+
"Hostname/IP does not match certificate's altnames",
466+
'unable to verify the first certificate',
467+
'self signed certificate in certificate chain',
468+
]
469+
470+
for (const errorMsg of permanentErrors) {
471+
expect(isNetworkError(new Error(errorMsg))).toBe(true)
472+
}
473+
})
474+
475+
test('does not identify non-network errors', () => {
476+
const nonNetworkErrors = [
477+
'Invalid JSON',
478+
'Syntax error',
479+
'undefined is not a function',
480+
'request failed with status 500',
481+
]
482+
483+
for (const errorMsg of nonNetworkErrors) {
484+
expect(isNetworkError(new Error(errorMsg))).toBe(false)
485+
}
486+
})
487+
488+
test('returns false for non-Error objects', () => {
489+
expect(isNetworkError('string error')).toBe(false)
490+
expect(isNetworkError(null)).toBe(false)
491+
expect(isNetworkError(undefined)).toBe(false)
492+
expect(isNetworkError({message: 'certificate error'})).toBe(false)
493+
})
345494
})

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

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,20 @@ type VerboseResponse<T> =
6868
| CanRetryErrorResponse
6969
| UnauthorizedErrorResponse
7070

71-
function isARetryableNetworkError(error: unknown): boolean {
71+
/**
72+
* Checks if an error is a transient network error that is likely to recover with retries.
73+
*
74+
* Use this function for retry logic. Use isNetworkError for error classification.
75+
*
76+
* Examples of transient errors (worth retrying):
77+
* - Connection timeouts, resets, and aborts
78+
* - DNS failures (enotfound, getaddrinfo, eai_again) - can be temporary
79+
* - Socket disconnects and hang ups
80+
* - Premature connection closes
81+
*/
82+
export function isTransientNetworkError(error: unknown): boolean {
7283
if (error instanceof Error) {
73-
const networkErrorMessages = [
84+
const transientErrorMessages = [
7485
'socket hang up',
7586
'econnreset',
7687
'econnaborted',
@@ -82,15 +93,45 @@ function isARetryableNetworkError(error: unknown): boolean {
8293
'eai_again',
8394
'epipe',
8495
'the operation was aborted',
96+
'timeout',
97+
'premature close',
98+
'getaddrinfo',
8599
]
86100
const errorMessage = error.message.toLowerCase()
87-
const anyMatches = networkErrorMessages.some((issueMessage) => errorMessage.includes(issueMessage))
101+
const anyMatches = transientErrorMessages.some((issueMessage) => errorMessage.includes(issueMessage))
88102
const missingReason = /^request to .* failed, reason:\s*$/.test(errorMessage)
89103
return anyMatches || missingReason
90104
}
91105
return false
92106
}
93107

108+
/**
109+
* Checks if an error is any kind of network-related error (connection issues, timeouts, DNS failures,
110+
* TLS/certificate errors, etc.) rather than an application logic error.
111+
*
112+
* These errors should be reported as user-facing errors (AbortError) rather than bugs (BugError),
113+
* regardless of whether they are transient or permanent.
114+
*
115+
* Examples include:
116+
* - Transient: connection timeouts, socket hang ups, temporary DNS failures
117+
* - Permanent: certificate validation failures, misconfigured SSL
118+
*/
119+
export function isNetworkError(error: unknown): boolean {
120+
// First check if it's a transient network error
121+
if (isTransientNetworkError(error)) {
122+
return true
123+
}
124+
125+
// Then check for permanent network errors (SSL/TLS/certificate issues)
126+
if (error instanceof Error) {
127+
const permanentNetworkErrorMessages = ['certificate', 'cert', 'tls', 'ssl', 'altnames']
128+
const errorMessage = error.message.toLowerCase()
129+
return permanentNetworkErrorMessages.some((issueMessage) => errorMessage.includes(issueMessage))
130+
}
131+
132+
return false
133+
}
134+
94135
async function runRequestWithNetworkLevelRetry<T extends {headers: Headers; status: number}>(
95136
requestOptions: RequestOptions<T>,
96137
): Promise<T> {
@@ -105,7 +146,7 @@ async function runRequestWithNetworkLevelRetry<T extends {headers: Headers; stat
105146
return await requestOptions.request()
106147
} catch (err) {
107148
lastSeenError = err
108-
if (!isARetryableNetworkError(err)) {
149+
if (!isTransientNetworkError(err)) {
109150
throw err
110151
}
111152
outputDebug(`Retrying request to ${requestOptions.url} due to network error ${err}`)

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
restRequestUrl,
1515
isThemeAccessSession,
1616
} from '../../../private/node/api/rest.js'
17+
import {isNetworkError} from '../../../private/node/api.js'
1718
import {RequestModeInput, shopifyFetch} from '../http.js'
1819
import {PublicApiVersions} from '../../../cli/api/graphql/admin/generated/public_api_versions.js'
1920

@@ -182,13 +183,26 @@ async function fetchApiVersions(session: AdminSession, preferredBehaviour?: Requ
182183
throw new AbortError(
183184
`Error connecting to your store ${session.storeFqdn}: ${error.message} ${error.response.status} ${error.response.data}`,
184185
)
185-
} else {
186-
throw new BugError(
187-
`Unknown error connecting to your store ${session.storeFqdn}: ${
186+
}
187+
188+
// Check for network-level errors (connection issues, timeouts, DNS failures, TLS/certificate errors, etc.)
189+
// All network errors should be treated as user-facing errors, not CLI bugs
190+
// Note: Some of these may have been retried already by lower-level retry logic
191+
if (isNetworkError(error)) {
192+
throw new AbortError(
193+
`Network error connecting to your store ${session.storeFqdn}: ${
188194
error instanceof Error ? error.message : String(error)
189195
}`,
196+
'Check your internet connection and try again.',
190197
)
191198
}
199+
200+
// Unknown errors are likely bugs in the CLI
201+
throw new BugError(
202+
`Unknown error connecting to your store ${session.storeFqdn}: ${
203+
error instanceof Error ? error.message : String(error)
204+
}`,
205+
)
192206
}
193207
}
194208

0 commit comments

Comments
 (0)