Claude Media
ShopifyのGraphQLレート制限をClaude Codeで守る — 待ち時間の計算とbulk切替

ShopifyのGraphQLレート制限をClaude Codeで守る — 待ち時間の計算とbulk切替

Shopify Admin GraphQLのコスト制限を、throttleStatusのcurrentlyAvailableとrestoreRateから待ち時間にするクライアントの書かせ方とbulkへの切替基準です。

Shopify Admin GraphQL APIの制限は、リクエスト数ではなく「クエリのコスト」で決まります。商品を250件ずつ取るループをClaude Codeに任せると、1回あたりのコストが大きいまま全力で回り、バケットが空になって止まります。

この記事では、レスポンスのextensions.costを読んで待ち時間を計算するクライアントを、Claude Codeに書かせて検証する手順を扱います。後半では、通常のクエリをやめてbulk operationに切り替える基準を示します。Shopify側の開発はClaude CodeでShopify Functionsを書く手順やShopifyテーマ開発にまとまっています。ここではAdmin GraphQL APIを呼ぶスクリプトだけが対象です。

制限は「回数」ではなくコストとバケットで決まる

Admin GraphQL APIの呼び出しは、アプリとストアの組み合わせごとに、プランに応じたバケットの大きさと回復速度が割り当てられます。別のアプリや別のストアの呼び出しは影響しません。回復速度はプランごとに次の値です。

数字

プランごとの回復速度

  • Standard

    100

  • Advanced Shopify

    200

  • Shopify Plus

    1000

  • Shopify for enterprise

    2000

    Commerce Components

単位はポイント/秒

仕組みはリーキーバケットです。リクエストごとにバケットへポイントが入り、バケットは一定の速度で漏れ続けます。平均コストが回復速度を下回っていれば、短い山は通ります。

フィールドごとのコスト

クエリのコストは、選んだフィールドの最大値で決まります。既定の値は次のとおりです。

返す型コスト
スカラー・列挙型コスト0
オブジェクトコスト1
コネクションコストfirst / last の大きさで決まる
ミューテーションコスト10

個別のフィールドには、手動でコストが設定される場合があります。表の値は目安で、真のコストは実際に実行して確かめるのが確実です。

見積もりと実コストの差は返金される

コストは実行前と実行後の2回計算されます。実行前の「requested cost」はフィールドの構成から、実行後の「actual cost」は返ってきた結果から出ます。

実行を始めるには、バケットにrequested cost以上の空きが必要です。終わると、requested costとactual costの差がバケットに戻ります。つまりfirst: 250と書いて実際には10件しかなくても、一時的に250件分の空きを要求します。

1回のクエリには1,000ポイントの上限がある

1回のクエリは、プランに関係なく1,000ポイントを超えられません。この判定は実行前に、requested costで行われます。上限内でも、ネストしたコネクションや合計値の計算でINTERNAL_SERVER_ERRORや5xxになることがあります。その場合は1ページの件数を減らす、関連レコードを別クエリに分ける、bulk operationを使う、のどれかです。

レスポンスのthrottleStatusを待ち時間に変える

すべてのレスポンスのextensionsに、コストとバケットの状態が入っています。

"extensions": {
  "cost": {
    "requestedQueryCost": 101,
    "actualQueryCost": 46,
    "throttleStatus": {
      "maximumAvailable": 1000,
      "currentlyAvailable": 954,
      "restoreRate": 50
    }
  }
}

currentlyAvailableが現在の空き、restoreRateが1秒あたりの回復量、maximumAvailableがバケットの大きさです。このJSONは公式ドキュメントの例で、ストアのプランによって数値は変わります。

次のリクエストに必要なコストがneededのとき、待ち時間は次の式で出せます。

待ち秒 = max(0, (needed - currentlyAvailable) / restoreRate)

たとえば次に500ポイント要るのに空きが120で、restoreRateが50なら、(500 - 120) / 50 = 7.6秒です。これは公式の式ではなく、回復速度が一定という前提からの計算です。neededには直前のレスポンスで知った値を使うか、requestedQueryCostの実績から推定します。

見積もりを実コストで補正する

neededが分からない最初の1回は、実際に投げてrequestedQueryCostを読むのが最短です。ヘッダShopify-GraphQL-Cost-Debug=1を付けると、フィールドごとの内訳がextensions.cost.fieldsに入ります。requestedTotalCostとrequestedChildrenCostが見えるので、どのコネクションが高いかを特定できます。

公式が示す再試行の作法

エラーを無視して叩き続けるとアプリは復帰できません。スロットルされたら、再試行できるまで追加のリクエストを止めます。推奨のバックオフは1秒です。上の式は、1秒の固定待ちより正確に空きを待つための手段として使えます。

Claude Codeに渡す指示と実装例

クライアントの責務は3つです。実行前に空きを確認する、実行後にthrottleStatusで状態を更新する、スロットル系のエラーでは待って再試行する。この3つを、数字と一緒にCLAUDE.mdへ書きます。

## Shopify Admin GraphQL
- 呼び出しは src/shopify/client.ts の `shopifyQuery` だけを使う。fetch を直接書かない
- レスポンスの extensions.cost.throttleStatus を読み、currentlyAvailable が
  次のクエリの requestedQueryCost を下回るなら (不足分 / restoreRate) 秒待つ
- 1クエリのコストは 1,000 以下。first は 250 以下にし、超えるなら分割する
- エラー時の再試行は最大5回、待ちは最低1秒
- 全件取得が数千件を超えるときは通常クエリでなく bulkOperationRunQuery を使う

実装は、次のような形になります。以下は書かせる内容の一例で、Claude Codeの実際の出力ではありません。

type Throttle = {
  maximumAvailable: number;
  currentlyAvailable: number;
  restoreRate: number;
};
 
let state: Throttle | null = null;
let stateAt = 0;
 
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
 
// 最後に観測した状態から、今の空きを推定する
function estimateAvailable(now: number): number {
  if (!state) return Infinity;
  const elapsed = (now - stateAt) / 1000;
  return Math.min(
    state.maximumAvailable,
    state.currentlyAvailable + elapsed * state.restoreRate,
  );
}
 
export async function shopifyQuery(
  shop: string, version: string, token: string,
  query: string, variables: object, needed = 1000,
) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const short = needed - estimateAvailable(Date.now());
    if (state && short > 0) {
      await sleep(Math.max(1000, (short / state.restoreRate) * 1000));
    }
    const res = await fetch(
      `https://${shop}.myshopify.com/admin/api/${version}/graphql.json`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-Shopify-Access-Token": token,
        },
        body: JSON.stringify({ query, variables }),
      },
    );
    const body = await res.json();
    const t = body.extensions?.cost?.throttleStatus;
    if (t) { state = t; stateAt = Date.now(); }
    if (res.status === 429 || /throttled/i.test(JSON.stringify(body.errors ?? ""))) {
      await sleep(1000);
      continue;
    }
    return body;
  }
  throw new Error("rate limit retries exhausted");
}

neededの既定値を最大の1,000にしているのは、最初の1回の保守的な見積もりのためです。2回目以降は、直前のrequestedQueryCostを渡す設計にすると待ちが短くなります。レート制限のページにはスロットル時のエラーコードの文字列が載っていないので、判定条件(429と文言)は実レスポンスのerrors[].extensions.codeを見てから固定してください。

権限を絞って、直接呼び出しを防ぐ

Claude Codeが検証のためにcurlでストアを叩くと、クライアントの外でバケットを消費します。.claude/settings.jsonで直接呼び出しを拒否し、テストだけを許可します。

{
  "permissions": {
    "allow": ["Bash(npm test *)", "Read", "Edit"],
    "deny": ["Bash(curl *myshopify.com*)"]
  }
}

パターンの書式は、権限ルール構文(Bash(git diff *)のように末尾の *で前方一致)に従います。ワイルドカードの位置によっては期待した範囲に当たらないことがあるため、書いた後に実際に拒否されるかを一度確かめてください。

ストアに触れずに待ち時間ロジックを確かめる

実ストアでバケットを空にして確かめるのは、他の運用に影響するので避けます。代わりに、throttleStatusを返す偽サーバーと偽タイマーでテストします。Claude Codeには次のように頼みます。

claude -p "src/shopify/client.ts のテストを書き、npm test が通るまで直して。\
currentlyAvailable=120, restoreRate=50 のレスポンスの後、\
requestedQueryCost=500 のクエリが7.6秒待つことを偽タイマーで検証する" \
  --allowedTools "Read,Edit,Bash(npm test *)"

非対話モードでは、--allowedToolsに並べたツールは確認なしで動きます。Bash(npm test *)のようにプレフィックスで絞ると、テスト以外のコマンドは許可されません。テストの確認は次の観点で見ます。

  • 空きが足りるときは待たずに送る
  • 空きが足りないときは式どおりの秒数を待つ
  • 待ち時間は最低1秒を下回らない
  • 5回失敗したら例外で止まる

bulk operationに切り替える基準

通常のクエリを速く回す工夫には限界があります。大量のデータを取るなら、単発クエリではなくbulk operationを使います。bulk operationには、単発クエリの最大コストの制限もレート制限もかかりません。

切り替えを考える目安は次のとおりです。

状況向く手段
数十〜数百件の取得、画面表示用向く手段通常のクエリ
全商品・全注文など、ページ送りが何十回も要る向く手段bulk operation
1ページが1,000ポイント近く、5xxが出る向く手段件数を減らす、またはbulk operation
更新を数千件まとめて実行する向く手段bulkOperationRunMutation

bulk queriesのページは、切り替える件数の閾値を定めていません。基準にするのは、ページ送りの回数と、上の待ち時間の式で見積もった所要時間です。たとえば1ページ約100ポイントでStandardプラン(100ポイント/秒)なら、バケットが空の状態では1秒に1ページが上限という計算になります。

bulkの制約も確認しておく

bulk operationにも制約があります。API版2026-01以降は、アプリごとにストアあたり最大5つのbulk queryを同時に実行できます。それより前の版は、bulkOperationRunQueryとbulkOperationRunMutationそれぞれ1つずつです。

bulk queryは10日以内に完了する必要があり、超えると停止してfailedになります。クエリの最上位に置けるフィールドは1つで、その下でコネクションをネストします。Storefront APIではbulk operationを使えません。

流れ

結果は、Webhookのbulk_operations/finishか、ステータスのポーリングで知ります。Webhookの到達は保証されないため、ポーリングも併用する場合があります。完了すると、urlにJSONLファイルのURLが入ります。結果が空ならurlはnullです。

手順

bulk queryで全件を取る流れ

  1. 1

    投入

    bulkOperationRunQueryにクエリ文字列を渡し、返ってきたstatusがCREATEDであることを確認します。

  2. 2

    待機

    bulk_operations/finishのWebhookを受けるか、bulkOperation(id:)(2026-01以降)でステータスを見ます。それ以前の版はcurrentBulkOperationです。

  3. 3

    取得

    statusがCOMPLETEDになったらurlのJSONLをダウンロードして、1行ずつ処理します。

ポーリングの頻度は、Webhookを併用すれば下げられます。

書かせたコードを動かす前の確認

次の4点を、実行前に見ます。

  1. firstが250を超えていないか。入力配列の上限は250で、超えるとエラーになります
  2. 同じアプリ・ストアの組み合わせで、別のジョブが同時に動いていないか。バケットは共有されます
  3. 版指定(/admin/api/{version}/)が固定されているか。bulkの同時実行数は版で変わります
  4. 商品変種を大量に作る処理がないか。50万変種を持つストアでは、productCreate、productUpdate、productVariantCreateに1日10,000変種の制限がかかります。Shopify Plusのストアは対象外です

最後の点は、コストとは別の資源ベースの制限で、throttleStatusには現れません。超えると429 Too Many Requestsが返ります。待ち時間の式では回避できないので、1日あたりの作成数を別に数える必要があります。

まとめ

制限の形は「バケットの大きさ」「回復速度」「1クエリ1,000ポイント」の3つで、クライアントはレスポンスのthrottleStatusだけで状態を追えます。Claude Codeへの指示には、この3つの数字と、fetchを直接書かないというルールを入れます。

数千件を超える全件取得は、待ち時間を磨くより、bulk operationへ移したほうが設計が単純になります。通常クエリは画面表示や少量の更新に使い、全件の読み出しと大量更新を分けると、バケットの取り合いが起きにくくなります。商品を書き込む側の設計はClaude CodeでShopifyの商品を移行するにあります。

他のAPIの制限を扱うクライアントを書かせる場合は、Notion APIのレート制限を守る移行スクリプトも同じ指示の型を使っています。自社側に制限を実装する場合はClaude CodeでRedisのレート制限を実装するが参考になります。REST呼び出しが残っているなら、先にClaude CodeでShopifyをGraphQLへ移行する手順で置き換えます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. shopify.dev/docs/apps/build/apis/graphql-admin/rate-limits
  2. shopify.dev/docs/api/usage/limits
  3. shopify.dev/docs/apps/build/apis/graphql-admin/bulk-operations/queries
  4. code.claude.com/docs/en/headless
この記事を共有:XはてブLinkedIn