@shopify/cli-kit
Version:
A set of utilities, interfaces, and models that are common across all the platform features
323 lines • 12.1 kB
JavaScript
import { sanitizedHeadersOutput } from './api/headers.js';
import { sanitizeURL } from './api/urls.js';
import { sleepWithBackoffUntil } from './sleep-with-backoff.js';
import { outputDebug } from '../../public/node/output.js';
import { recordRetry } from '../../public/node/analytics.js';
import { ClientError } from 'graphql-request';
import { performance } from 'perf_hooks';
export const allAPIs = ['admin', 'storefront-renderer', 'partners', 'business-platform', 'app-management'];
const DEFAULT_RETRY_DELAY_MS = 1000;
const DEFAULT_RETRY_LIMIT = 10;
const interestingResponseHeaders = new Set([
'cache-control',
'content-type',
'etag',
'x-request-id',
'server-timing',
'retry-after',
]);
function responseHeaderIsInteresting(header) {
return interestingResponseHeaders.has(header);
}
/**
* Checks if an error is a transient network error that is likely to recover with retries.
*
* Use this function for retry logic. Use isNetworkError for error classification.
*
* Examples of transient errors (worth retrying):
* - Connection timeouts, resets, and aborts
* - DNS failures (enotfound, getaddrinfo, eai_again) - can be temporary
* - Socket disconnects and hang ups
* - Premature connection closes
*/
export function isTransientNetworkError(error) {
if (error instanceof Error) {
const transientErrorMessages = [
'socket hang up',
'econnreset',
'econnaborted',
'enotfound',
'enetunreach',
'network socket disconnected',
'etimedout',
'econnrefused',
'eai_again',
'epipe',
'the operation was aborted',
'timeout',
'premature close',
'getaddrinfo',
];
const errorMessage = error.message.toLowerCase();
const anyMatches = transientErrorMessages.some((issueMessage) => errorMessage.includes(issueMessage));
const missingReason = /^request to .* failed, reason:\s*$/.test(errorMessage);
return anyMatches || missingReason;
}
return false;
}
/**
* Checks if an error is any kind of network-related error (connection issues, timeouts, DNS failures,
* TLS/certificate errors, etc.) rather than an application logic error.
*
* These errors should be reported as user-facing errors (AbortError) rather than bugs (BugError),
* regardless of whether they are transient or permanent.
*
* Examples include:
* - Transient: connection timeouts, socket hang ups, temporary DNS failures
* - Permanent: certificate validation failures, misconfigured SSL
*/
export function isNetworkError(error) {
// First check if it's a transient network error
if (isTransientNetworkError(error)) {
return true;
}
// Then check for permanent network errors (SSL/TLS/certificate issues)
if (error instanceof Error) {
const permanentNetworkErrorMessages = ['certificate', 'cert', 'tls', 'ssl', 'altnames'];
const errorMessage = error.message.toLowerCase();
return permanentNetworkErrorMessages.some((issueMessage) => errorMessage.includes(issueMessage));
}
return false;
}
async function runRequestWithNetworkLevelRetry(requestOptions) {
if (!requestOptions.useNetworkLevelRetry) {
return requestOptions.request();
}
let lastSeenError;
for await (const _delayMs of sleepWithBackoffUntil(requestOptions.maxRetryTimeMs)) {
try {
return await requestOptions.request();
}
catch (err) {
lastSeenError = err;
if (!isTransientNetworkError(err)) {
throw err;
}
// Record command retries
if (requestOptions.recordCommandRetries) {
recordRetry(requestOptions.url, `network-retry:${err.message}`);
}
outputDebug(`Retrying request to ${requestOptions.url} due to network error ${err}`);
}
}
throw lastSeenError;
}
async function makeVerboseRequest(requestOptions) {
const t0 = performance.now();
let duration = 0;
const responseHeaders = {};
const sanitizedUrl = sanitizeURL(requestOptions.url);
let response = {};
try {
response = await runRequestWithNetworkLevelRetry(requestOptions);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
response.headers.forEach((value, key) => {
if (responseHeaderIsInteresting(key))
responseHeaders[key] = value;
});
// eslint-disable-next-line no-catch-all/no-catch-all
}
catch (err) {
const t1 = performance.now();
duration = Math.round(t1 - t0);
if (err instanceof ClientError) {
if (err.response.headers) {
for (const [key, value] of err.response.headers) {
if (responseHeaderIsInteresting(key))
responseHeaders[key] = value;
}
}
const sanitizedHeaders = sanitizedHeadersOutput(responseHeaders);
if (errorsIncludeStatus429(err)) {
let delayMs;
try {
delayMs = responseHeaders['retry-after'] ? Number.parseInt(responseHeaders['retry-after'], 10) : undefined;
// eslint-disable-next-line no-catch-all/no-catch-all
}
catch {
// ignore errors in extracting retry-after header
}
return {
status: 'can-retry',
clientError: err,
duration,
sanitizedHeaders,
sanitizedUrl,
requestId: responseHeaders['x-request-id'],
delayMs,
};
}
else if (err.response.status === 401) {
return {
status: 'unauthorized',
clientError: err,
duration,
sanitizedHeaders,
sanitizedUrl,
requestId: responseHeaders['x-request-id'],
delayMs: 500,
};
}
return {
status: 'client-error',
clientError: err,
duration,
sanitizedHeaders,
sanitizedUrl,
requestId: responseHeaders['x-request-id'],
};
}
return {
status: 'unknown-error',
error: err,
duration,
sanitizedHeaders: sanitizedHeadersOutput(responseHeaders),
sanitizedUrl,
requestId: responseHeaders['x-request-id'],
};
}
const t1 = performance.now();
duration = Math.round(t1 - t0);
return {
status: 'ok',
response,
duration,
sanitizedHeaders: sanitizedHeadersOutput(responseHeaders),
sanitizedUrl,
requestId: responseHeaders['x-request-id'],
};
}
function errorsIncludeStatus429(error) {
if (error.response.status === 429) {
return true;
}
// GraphQL returns a 401 with a string error message when auth fails
// Therefore error.response.errors can be a string or GraphQLError[]
if (typeof error.response.errors === 'string') {
return false;
}
return error.response.errors?.some((error) => error.extensions?.code === '429') ?? false;
}
export async function simpleRequestWithDebugLog(requestOptions, errorHandler) {
const result = await makeVerboseRequest(requestOptions);
outputDebug(`Request to ${result.sanitizedUrl} completed in ${result.duration} ms
With response headers:
${result.sanitizedHeaders}
`);
switch (result.status) {
case 'ok': {
return result.response;
}
case 'client-error': {
if (errorHandler) {
throw errorHandler(result.clientError, result.requestId);
}
else {
throw result.clientError;
}
}
case 'unknown-error': {
if (errorHandler) {
throw errorHandler(result.error, result.requestId);
}
else {
throw result.error;
}
}
case 'can-retry': {
if (errorHandler) {
throw errorHandler(result.clientError, result.requestId);
}
else {
throw result.clientError;
}
}
case 'unauthorized': {
if (errorHandler) {
throw errorHandler(result.clientError, result.requestId);
}
else {
throw result.clientError;
}
}
}
}
/**
* Makes a HTTP request to some API, retrying if response headers indicate a retryable error.
*
* If a request fails with a 429, the retry-after header determines a delay before an automatic retry is performed.
*
* If unauthorizedHandler is provided, then it will be called in the case of a 401 and a retry performed. This allows
* for a token refresh for instance.
*
* If there's a network error, e.g. DNS fails to resolve, then API calls are automatically retried.
*
* @param request - A function that returns a promise of the response
* @param url - The URL to request
* @param errorHandler - A function that handles errors
* @param unauthorizedHandler - A function that handles unauthorized errors
* @param retryOptions - Options for the retry
* @returns The response from the request
*/
export async function retryAwareRequest(requestOptions, errorHandler, retryOptions = {
scheduleDelay: setTimeout,
}) {
let retriesUsed = 0;
const limitRetriesTo = retryOptions.limitRetriesTo ?? DEFAULT_RETRY_LIMIT;
let result = await makeVerboseRequest(requestOptions);
outputDebug(`Request to ${result.sanitizedUrl} completed in ${result.duration} ms
With response headers:
${result.sanitizedHeaders}
`);
while (true) {
if (result.status === 'ok') {
if (retriesUsed > 0) {
outputDebug(`Request to ${result.sanitizedUrl} succeeded after ${retriesUsed} retries`);
}
return result.response;
}
else if (result.status === 'client-error') {
if (errorHandler) {
throw errorHandler(result.clientError, result.requestId);
}
else {
throw result.clientError;
}
}
else if (result.status === 'unknown-error') {
if (errorHandler) {
throw errorHandler(result.error, result.requestId);
}
else {
throw result.error;
}
}
else if (result.status === 'unauthorized') {
throw result.clientError;
}
if (limitRetriesTo <= retriesUsed) {
outputDebug(`${limitRetriesTo} retries exhausted for request to ${result.sanitizedUrl}`);
if (errorHandler) {
throw errorHandler(result.clientError, result.requestId);
}
else {
throw result.clientError;
}
}
retriesUsed += 1;
// Record command retries
if (requestOptions.recordCommandRetries) {
recordRetry(requestOptions.url, `http-retry-${retriesUsed}:${result.status}:`);
}
// prefer to wait based on a header if given; the caller's preference if not; and a default if neither.
const retryDelayMs = result.delayMs ?? retryOptions.defaultDelayMs ?? DEFAULT_RETRY_DELAY_MS;
outputDebug(`Scheduling retry request #${retriesUsed} to ${result.sanitizedUrl} in ${retryDelayMs} ms`);
// eslint-disable-next-line no-await-in-loop
result = await new Promise((resolve) => {
retryOptions.scheduleDelay(() => {
resolve(makeVerboseRequest(requestOptions));
}, retryDelayMs);
});
}
}
//# sourceMappingURL=api.js.map