/**
* Core Shopify GraphQL client.
* Handles Admin and Storefront API requests with rate-limit awareness.
*/
import type { ShopifyGraphQLResponse } from './types.js';
export interface ShopifyClientConfig {
storeDomain: string;
accessToken: string;
storefrontToken: string;
}
const API_VERSION = '2025-01';
const RATE_LIMIT_RETRY_DELAY_MS = 1000;
const MAX_RETRIES = 3;
export class ShopifyClient {
private readonly storeDomain: string;
private readonly accessToken: string;
private readonly storefrontToken: string;
constructor(config: ShopifyClientConfig) {
if (!config.storeDomain) {
throw new Error('ShopifyClient: storeDomain is required');
}
if (!config.accessToken) {
throw new Error('ShopifyClient: accessToken is required');
}
if (!config.storefrontToken) {
throw new Error('ShopifyClient: storefrontToken is required');
}
this.storeDomain = config.storeDomain;
this.accessToken = config.accessToken;
this.storefrontToken = config.storefrontToken;
}
/**
* Execute a GraphQL query against the Shopify Admin API.
* Uses X-Shopify-Access-Token authentication.
*/
async adminQuery<T = Record<string, unknown>>(
query: string,
variables?: Record<string, unknown>,
): Promise<ShopifyGraphQLResponse<T>> {
const url = `https://${this.storeDomain}/admin/api/${API_VERSION}/graphql.json`;
return this.executeWithRetry<T>(url, query, variables, {
'X-Shopify-Access-Token': this.accessToken,
});
}
/**
* Execute a GraphQL query against the Shopify Storefront API.
* Uses X-Shopify-Storefront-Access-Token authentication.
*/
async storefrontQuery<T = Record<string, unknown>>(
query: string,
variables?: Record<string, unknown>,
): Promise<ShopifyGraphQLResponse<T>> {
const url = `https://${this.storeDomain}/api/${API_VERSION}/graphql.json`;
return this.executeWithRetry<T>(url, query, variables, {
'X-Shopify-Storefront-Access-Token': this.storefrontToken,
});
}
/**
* Execute a GraphQL request with retry logic for rate limiting.
*/
private async executeWithRetry<T>(
url: string,
query: string,
variables: Record<string, unknown> | undefined,
authHeaders: Record<string, string>,
): Promise<ShopifyGraphQLResponse<T>> {
let lastError: Error | undefined;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders,
},
body: JSON.stringify({ query, variables }),
});
// Check for rate limiting via HTTP 429
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const delayMs = retryAfter
? parseInt(retryAfter, 10) * 1000
: RATE_LIMIT_RETRY_DELAY_MS * (attempt + 1);
await this.sleep(delayMs);
continue;
}
// Check rate limit headers (Admin API uses X-Shopify-Shop-Api-Call-Limit)
const callLimit = response.headers.get(
'X-Shopify-Shop-Api-Call-Limit',
);
if (callLimit) {
const [used, max] = callLimit.split('/').map(Number);
if (used !== undefined && max !== undefined && used >= max * 0.9) {
// Approaching rate limit; add a small delay to back off
await this.sleep(RATE_LIMIT_RETRY_DELAY_MS);
}
}
if (!response.ok) {
throw new Error(
`Shopify GraphQL error: ${response.status} ${response.statusText}`,
);
}
const body = (await response.json()) as ShopifyGraphQLResponse<T>;
// Check for throttling errors in the GraphQL response
const throttled = body.errors?.some(
(e) => e.extensions?.['code'] === 'THROTTLED',
);
if (throttled) {
await this.sleep(RATE_LIMIT_RETRY_DELAY_MS * (attempt + 1));
continue;
}
return body;
} catch (error) {
lastError =
error instanceof Error ? error : new Error(String(error));
// Only retry on network errors, not on business logic errors
if (attempt < MAX_RETRIES - 1) {
await this.sleep(RATE_LIMIT_RETRY_DELAY_MS * (attempt + 1));
}
}
}
throw lastError ?? new Error('Shopify GraphQL request failed after retries');
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}