UNPKG

@shopify/cli-kit

Version:

A set of utilities, interfaces, and models that are common across all the platform features

323 lines • 12.1 kB
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