← Journal

Shopify GraphQL Admin API : 8 patterns pour ne pas se faire throttler

Comment ne pas exploser ton rate limit Shopify quand tu pousses 50 K produits. Bulk operations, cost predictor, batch chunks, retry exponentiel. Patterns que j'utilise en prod.

Shopify GraphQL Admin API tourne sur un système de “calculated query cost”. Tu n’as pas un rate limit en req/s, mais un budget en points. Tu en consommes selon la complexité de ta requête, pas selon le volume.

Si tu pousses 50 000 produits sans connaître les patterns, tu te fais throttler en 30 secondes et tu attends 4h pour reprendre. Voici les 8 patterns pour ne pas y passer.

1. Comprendre le système de cost

Shopify alloue 2 000 points au bucket de ton app, qui se recharge à 100 points/seconde. Chaque query a un coût calculé :

  • Field simple (id, title) : 1 point
  • Connection (products, orders) : count * cost_per_node
  • Mutation : 10 points minimum + cost des champs retournés
{
  products(first: 50) {  # ← 50 nodes × cost = 50 minimum
    edges {
      node {
        id          # 1 pt
        title       # 1 pt
        variants(first: 10) {  # 10 nodes × cost = 10 pts par produit = 500 pts
          edges {
            node { id sku }
          }
        }
      }
    }
  }
}

Cette query coûte environ 600 points. Tu peux la lancer 3 fois avant de saturer.

2. Toujours interroger extensions.cost

Toute réponse GraphQL inclut le cost réel :

{
  "data": { ... },
  "extensions": {
    "cost": {
      "requestedQueryCost": 600,
      "actualQueryCost": 587,
      "throttleStatus": {
        "maximumAvailable": 2000,
        "currentlyAvailable": 1413,
        "restoreRate": 100
      }
    }
  }
}

Avant de lancer la query suivante, vérifie currentlyAvailable. Si < 200, attends que le bucket se recharge.

3. Pattern : token bucket côté client

class ShopifyRateLimiter {
  available = 2000;
  restoreRate = 100;
  lastUpdate = Date.now();

  async wait(needed: number) {
    this.refresh();
    while (this.available < needed) {
      const wait = Math.ceil((needed - this.available) / this.restoreRate * 1000);
      await sleep(wait);
      this.refresh();
    }
    this.available -= needed;
  }

  refresh() {
    const elapsed = (Date.now() - this.lastUpdate) / 1000;
    this.available = Math.min(2000, this.available + elapsed * this.restoreRate);
    this.lastUpdate = Date.now();
  }

  update(cost: number, current: number) {
    this.available = current;
    this.lastUpdate = Date.now();
  }
}

Tu attends d’avoir le budget avant de lancer. Tu mets à jour avec le currentlyAvailable retourné par Shopify.

4. Pattern : pagination par cursor (jamais offset)

query Products($cursor: String) {
  products(first: 250, after: $cursor) {
    pageInfo {
      hasNextPage
      endCursor
    }
    edges {
      cursor
      node { id title }
    }
  }
}

Tu paginates page par page, en passant le endCursor à la requête suivante. Jamais de last + before sauf cas exceptionnel (lourd en cost).

5. Pattern : Bulk Operations (le secret pour > 10 000 items)

Pour > 10 000 produits, n’utilise pas la pagination classique. Use Bulk Operations :

mutation {
  bulkOperationRunQuery(
    query: """
      {
        products {
          edges {
            node {
              id
              title
              variants {
                edges {
                  node { id sku price inventoryQuantity }
                }
              }
            }
          }
        }
      }
    """
  ) {
    bulkOperation { id status }
    userErrors { field message }
  }
}

Shopify exécute la query en background, génère un fichier JSONL. Tu polles le statut avec currentBulkOperation. Quand status: COMPLETED, tu télécharges le fichier (jusqu’à plusieurs Go).

Coût : 10 points fixe pour lancer le bulk, peu importe le volume retourné. C’est imbattable pour les gros exports.

Limite : 1 bulk operation simultanée par store.

6. Pattern : Bulk Mutations pour les imports (Plus only)

Pour pousser 50 K produits, on utilise bulkOperationRunMutation (Shopify Plus uniquement) :

  1. Générer un fichier JSONL avec 1 mutation par ligne
  2. Upload sur Shopify via stagedUploadsCreate
  3. Lancer bulkOperationRunMutation avec l’URL du fichier
  4. Poller le résultat
mutation {
  bulkOperationRunMutation(
    mutation: "mutation call($input: ProductInput!) { productCreate(input: $input) { product { id } } }",
    stagedUploadPath: "tmp/12345/abc.jsonl"
  ) {
    bulkOperation { id }
    userErrors { field message }
  }
}

50 000 produits passent en 30-60 minutes vs 8-12 heures en API REST classique.

7. Pattern : retry exponentiel sur 429 et 5xx

Tu vas en prendre. Toujours retry avec backoff :

async function withRetry<T>(fn: () => Promise<T>, max = 5): Promise<T> {
  let lastErr;
  for (let i = 0; i < max; i++) {
    try {
      return await fn();
    } catch (err: any) {
      lastErr = err;
      if (err.status === 429 || err.status >= 500) {
        const wait = Math.pow(2, i) * 1000 + Math.random() * 500;
        await sleep(wait);
        continue;
      }
      throw err;  // erreurs 4xx hors 429 → ne pas retry
    }
  }
  throw lastErr;
}

Backoff exponentiel : 1s, 2s, 4s, 8s, 16s. Plus du jitter pour éviter les retry simultanés.

8. Pattern : split queries trop chères en plusieurs requêtes

Si une query coûte > 1000 points, split-la. Plutôt que products(first: 250) { variants(first: 100) } (cost > 25 000), tu fais :

  • 1 query : products(first: 250) { id } (cost ~250)
  • 250 queries : product(id: $id) { variants(first: 100) } en parallèle limité à 10 (cost 100 each)

Total : 250 + 250×100 = 25 250 points sur une fenêtre de temps plus longue. Tu prends 25 min mais tu ne te fais jamais throttler.

9. Bonus : le cost predictor

Avant de lancer une query inconnue, demande son cost via costPredict (en alpha) ou estimate-le avec extensions.cost.requestedQueryCost sur dryRun. Tu sais combien ça va coûter avant de payer.

La toolchain qui marche

J’utilise une stack simple en TypeScript :

  • @shopify/shopify-api pour l’auth + base GraphQL client
  • p-limit pour la concurrence (max 5 requêtes simultanées)
  • p-retry pour les retry
  • Mon propre token bucket
  • Zod pour valider les responses (Shopify retourne parfois des structures inattendues sur les anciennes APIs)

Code typique pour un push de 50K produits :

const limiter = pLimit(5);
const tokenBucket = new ShopifyRateLimiter();

await Promise.all(products.map(p => limiter(async () => {
  await tokenBucket.wait(50);  // estimation cost mutation
  const res = await withRetry(() => shopify.graphql(MUTATION, { input: p }));
  tokenBucket.update(res.extensions.cost.actualQueryCost,
                     res.extensions.cost.throttleStatus.currentlyAvailable);
})));

Les pièges que j’ai vus

  • Pas de validation de la response : tu push 10 K produits, tu reçois 10 K success, mais 30 % des SKU ont été silencieusement renommés par Shopify. Toujours validate.
  • Webhook qui hammer ton listener : un webhook inventory_levels/update peut firer 50 fois/sec sur un drop. Buffer-le côté client (n8n queue ou simple Redis stream).
  • Mutations sans userErrors checké : si userErrors est non-vide, la mutation a échoué malgré un HTTP 200. Toujours vérifier.

Le quota Plus

En Shopify Plus, tu passes à 10 000 points bucket + 500/s restore rate (5× le standard). C’est suffisant pour pratiquement tout. Si tu satures encore, c’est que ton archi a un problème pas le rate limit.

Audit

Si tu pousses des données en gros volume vers Shopify et que ça plante (retry loops infinis, throttling, données silencieusement perdues), l’audit gratuit que je fais inclut un test de charge sur ta toolchain actuelle + recommandations chiffrées sous 72 h.

// Vous reconnaissez vos enjeux ?

Bâtissons votre architecture ensemble.

Audit gratuit sous 24 h ouvrées. Devis chiffré sous 72 h. Aucun engagement derrière.

Réserver un audit
30+marques accompagnées
100%migration sans perte
< 24hréponse