Claude Media
Claude Codeでgh api paginateの取りこぼしを防ぐ — レート制限と件数照合

Claude Codeでgh api paginateの取りこぼしを防ぐ — レート制限と件数照合

組織の全リポジトリをgh apiで取るとき、Linkヘッダーのたどり方、403と429での待ち方、APIの件数との照合までを1本のスクリプトにまとめ、Claude Codeに任せる権限設定も添えます。

gh api --paginateは、次のページがなくなるまで順に取得するフラグです。ところが、途中で403が返ったときの待ち方や、取れた件数が正しいかの確認は担当範囲に入っていません。数百のリポジトリを棚卸しするスクリプトでは、そこが取りこぼしの入口になります。

この記事では、組織の全リポジトリを漏れなく取るスクリプトを1本書きます。Linkヘッダーをたどり、403と429で待ち、最後にAPIが返す件数と突き合わせます。Claude Codeには、このスクリプトだけを実行させる設定も付けます。

gh api --paginateだけで足りる範囲

最短の書き方は次の1行です。--slurpは--paginateと組み合わせて使い、全ページの配列を外側の配列に包みます。

gh api --paginate --slurp "orgs/ACME/repos?per_page=100" | jq 'add'

数十件のリポジトリを一度だけ取るなら、これで足ります。足りなくなるのは、次の3つが重なったときです。

限界

--paginateだけでは見えないもの

  • 待ち方

    gh apiのマニュアルは、次のページがなくなるまで順にリクエストすると説明しています。レート制限に当たったときの待機やリトライには触れていません。

  • 件数の正しさ

    取れた配列の長さが、組織の持つリポジトリ数と合っているかは別問題です。照合の元になる数を自分で取りにいく必要があります。

  • 後続のリクエスト

    一覧のあとにリポジトリごとの詳細を引くと、リクエスト数は一覧の約100倍に膨らみます。予算の計算は一覧の外で起きます。

もう1つ、書き方の罠があります。gh apiは-fや-Fでパラメータを足すと、メソッドをGETからPOSTに切り替えます。per_pageを-f per_page=100で渡すと、一覧取得のつもりがPOSTになります。クエリはURLに書きます。

全リポジトリ取得で上限に当たる場所

REST APIの上限は、1時間あたりの回数(primary)と、短時間の集中を防ぐ上限(secondary)の2層です。数字を先に並べます。

数字

認証方法ごとのprimary上限(1時間あたり)

  • 未認証

    60

    リクエスト

  • 個人の認証

    5,000

    リクエスト

  • Actionsのトークン

    1,000

    リポジトリごと

REST APIの場合

Actionsで動かすなら、GITHUB_TOKENの上限が1リポジトリあたり1時間1,000リクエストです。ローカルで個人のトークンを使う場合の5,000より小さい点に注意が要ります。なお、Enterprise Cloudの組織が所有するアプリ経由などでは上限が15,000になる条件もあります。

secondaryのほうは、次のような起き方をします。

  • 同時リクエストは100まで。REST APIとGraphQL APIで共有される
  • 1つのエンドポイントへのリクエストは、REST APIで毎分900ポイントまで。ほとんどのGETは1ポイント
  • 60秒の実時間あたり、CPU時間90秒まで

一覧の取得そのものは軽い処理です。100件ずつ取れば、2,300リポジトリでも23リクエストで済みます。重くなるのは一覧のあとです。2,300件それぞれに詳細を1回引くと2,300リクエストになり、一覧と合わせて2,323。1時間5,000の枠の半分近くを一度の棚卸しで使います。

同時実行は避けます。ベストプラクティスの説明には、secondaryを避けるにはリクエストを並列でなく直列に出す、とあります。1秒待つ指示があるのはPOSTなど書き込み系の連続で、読み取りのGETには書かれていません。直列で回せば、同時100の上限には構造上触れません。

取りこぼしを見つける件数照合

ページ送りで取りこぼす原因は、主に3つです。

  1. per_pageに100より大きい値を渡すと、エラーにならずに100へ切り詰められる。想定より少ない件数が黙って返る
  2. 取得の途中でリポジトリが増減し、ページの境目がずれて重複や欠落が起きうる
  3. 途中の失敗を握りつぶして、取れた分だけで先へ進む

1つめは設計で避けられます。per_pageは100を指定し、次ページのURLは自分でpage=Nを組み立てずにLinkヘッダーからそのまま使います。ベストプラクティスにも、ページ送りのクエリを手で組み立てず、Linkヘッダーで次に取れるページを判断するよう書かれています。ページが1つで収まるときはLinkヘッダー自体が付かないので、「Linkがない」は「終わり」と同じ意味になります。

2つめには並び順で備えます。組織のリポジトリ一覧はsortの既定がcreatedで、full_nameを選ぶとdirectionの既定はascです。名前の昇順に固定すると、ずれの原因は、取得済みの範囲に入る名前の増減に絞られます。完全には防げないので、最後に件数を照合します。

3つめの照合元は、組織本体のエンドポイントです。GET /orgs/{org}の応答例にはpublic_reposとtotal_private_reposが並んでいます。取得結果をfull_nameで重複除去した件数が、この2つの合計と一致すれば、少なくとも件数の上では取りこぼしがありません。

total_private_reposは、呼び出す人の権限によっては応答に含まれない場合があります。ない場合に備えて、スクリプトは公開リポジトリだけをpublic_reposと照合する側へ切り替えます。

Linkヘッダーをたどるスクリプト

ここまでをまとめたスクリプトです。読み取り専用で、叩くのはGETだけです。

#!/usr/bin/env bash
# 使い方: ./list-org-repos.sh <org> > repos.jsonl
# 読み取り専用(GET のみ)。ページ送りは Link ヘッダーの rel="next" をたどる。
set -euo pipefail
 
ORG="${1:?org を指定してください}"
MAX_RETRY=5
LOW_WATER=10   # x-ratelimit-remaining がこの値以下なら、リセットまで待つ
 
hdr() { # hdr <name> : 直近レスポンスのヘッダー値(小文字で照合)
  printf '%s\n' "$HEADERS" | awk -v k="$(printf '%s' "$1" | tr 'A-Z' 'a-z')" \
    'BEGIN{FS=": "} tolower($1)==k {sub(/^[^:]*: */,""); print; exit}'
}
 
fetch() { # fetch <path> : HEADERS / BODY を埋める。失敗したら非ゼロで終了
  local path="$1" attempt=0 raw status wait
  while :; do
    raw=$(gh api -i "$path" 2>/dev/null | tr -d '\r' || true)
    HEADERS=$(printf '%s\n' "$raw" | sed '/^$/q')
    BODY=$(printf '%s\n' "$raw" | sed '1,/^$/d')
    status=$(printf '%s\n' "$HEADERS" | head -n1 | awk '{print $2}')
    case "$status" in
      2*) return 0 ;;
      403|429)
        attempt=$((attempt + 1))
        [ "$attempt" -gt "$MAX_RETRY" ] && { echo "リトライ上限: $path" >&2; return 1; }
        if [ -n "$(hdr retry-after)" ]; then
          wait=$(( $(hdr retry-after) + 1 ))
        elif [ "$(hdr x-ratelimit-remaining)" = "0" ]; then
          wait=$(( $(hdr x-ratelimit-reset) - $(date +%s) + 1 ))
          [ "$wait" -lt 1 ] && wait=1
        else
          wait=$(( 60 * 2 ** (attempt - 1) ))   # 60, 120, 240 ... 秒
        fi
        echo "status=$status ${wait}秒待ちます (試行 $attempt/$MAX_RETRY)" >&2
        sleep "$wait"
        ;;
      *) echo "status=${status:-なし}: $path" >&2; return 1 ;;
    esac
  done
}
 
# 1) 期待件数: 組織の public_repos + total_private_repos
fetch "orgs/$ORG"
EXPECTED=$(printf '%s' "$BODY" | jq '(.public_repos // 0) + (.total_private_repos // 0)')
HAS_PRIVATE=$(printf '%s' "$BODY" | jq 'has("total_private_repos")')
 
# 2) 全ページ取得。次ページは Link ヘッダーの URL をそのまま使う
TMP=$(mktemp); trap 'rm -f "$TMP"' EXIT
next="orgs/$ORG/repos?per_page=100&sort=full_name&direction=asc"
pages=0
while [ -n "$next" ]; do
  fetch "$next"
  pages=$((pages + 1))
  printf '%s' "$BODY" | jq -c '.[]' >> "$TMP"
  next=$(hdr link | tr ',' '\n' | sed -n 's/.*<\(.*\)>; rel="next".*/\1/p')
  next="${next#https://api.github.com/}"
  rem=$(hdr x-ratelimit-remaining)
  if [ -n "$rem" ] && [ "$rem" -le "$LOW_WATER" ] && [ -n "$next" ]; then
    sleep $(( $(hdr x-ratelimit-reset) - $(date +%s) + 1 ))
  fi
done
 
# 3) 重複を除いて出力し、件数を照合する
# total_private_repos が応答に無いときは、公開リポジトリだけを public_repos と照合する
if [ "$HAS_PRIVATE" = "true" ]; then SEL='.'; else SEL='select(.private == false)'; fi
UNIQ=$(jq -s "unique_by(.full_name) | map($SEL) | length" "$TMP")
TOTAL=$(wc -l < "$TMP" | tr -d ' ')
jq -c -s 'unique_by(.full_name)[]' "$TMP"
echo "pages=$pages rows=$TOTAL unique=$UNIQ expected=$EXPECTED private_count_present=$HAS_PRIVATE" >&2
[ "$UNIQ" -eq "$EXPECTED" ] || { echo "件数が一致しません (取得 $UNIQ / API の合計 $EXPECTED)" >&2; exit 2; }

作りは3段です。

  • fetchが1ページ取得し、2xx以外なら待つか中断する
  • メインのループが、Linkヘッダーのrel="next"のURLをgh apiの引数に直して次を取る
  • 最後に重複を除いた件数を、組織の合計と比べる

gh api -iはステータス行とヘッダーを本文の前に出力します。ここからLink、Retry-After、X-Ratelimit-Remainingを読みます。次ページのURLはhttps://api.github.com/で始まる絶対URLなので、先頭を削ってパスにしてから渡します。

403と429での待ち方を決める

primaryとsecondaryのどちらに当たっても、応答は403か429です。見分けはヘッダーで行います。待ち方は次の順で決めます。

状況見るヘッダー待つ時間
Retry-Afterがある見るヘッダーretry-after待つ時間その秒数が過ぎるまで
残りが0見るヘッダーx-ratelimit-remaining、x-ratelimit-reset待つ時間resetのUTCエポック秒まで
どちらもない見るヘッダーなし待つ時間最低1分、続くなら指数的に延ばす

3行目は、スクリプトでは60秒、120秒、240秒と倍にして、5回目で打ち切る実装にしています。回数と初期値は決まりではなく、自分で決める値です。待たずに叩き続けると、連携が停止される可能性があると説明されています。

成功した応答でも、x-ratelimit-remainingが小さければ先に待ちます。スクリプトのLOW_WATERが10なのは、手元の設計上の目安です。リージョンが違うと残り回数が前後することがあり、GET /rate_limitの値とヘッダーが食い違えばヘッダーを信じる、というのが仕様の説明です。正確な残り数に依存する作りは避けます。

GET /rate_limitはprimaryの消費に数えられませんが、secondaryには数えられることがあります。ループの中で毎回呼ぶ使い方は向きません。

ネットワークに出ずに分岐を確かめる

429や403を意図して起こすことはできません。そこで、gh api -iの応答を固定で返す代用品をPATHの先頭に置きます。代用品は2ページ目の1回目だけ403とRetry-After: 1を返し、3ページで5件を渡し切ります。

代用品のgh(ネットワークには出ません)
#!/usr/bin/env bash
# gh api -i <path> の簡易代用品(ネットワークには出ない)
D="$(dirname "$0")/../data"; path="$3"
[ "$1 $2" = "api -i" ] || exit 9
crlf() { sed 's/$/\r/'; }
case "$path" in
  orgs/acme) printf 'HTTP/2.0 200 OK\nX-Ratelimit-Remaining: 4999\n\n' | crlf; cat "$D/org.json";;
  orgs/acme/repos*|organizations/1/repos*)
    p=$(printf '%s' "$path" | sed -n 's/.*[?&]page=\([0-9]*\).*/\1/p'); p=${p:-1}
    if [ "$p" = 2 ] && [ ! -f "$D/.fail_done" ]; then
      touch "$D/.fail_done"
      printf 'HTTP/2.0 403 Forbidden\nRetry-After: 1\nX-Ratelimit-Remaining: 4990\n\n' | crlf
      echo '{"message":"You have exceeded a secondary rate limit."}'; exit 1
    fi
    nxt=""; [ "$p" -lt 3 ] && nxt="Link: <https://api.github.com/organizations/1/repos?per_page=100&sort=full_name&direction=asc&page=$((p+1))>; rel=\"next\", <https://api.github.com/organizations/1/repos?per_page=100&page=3>; rel=\"last\""
    { printf 'HTTP/2.0 200 OK\n'; [ -n "$nxt" ] && printf '%s\n' "$nxt"; printf 'X-Ratelimit-Remaining: 4990\n\n'; } | crlf; cat "$D/p$p.json";;
  *) printf 'HTTP/2.0 404 Not Found\n\n' | crlf; echo '{}'; exit 1;;
esac

組織が合計5件を返す設定で実行すると、待機を挟んで完走します。

PATH="$PWD/bin:$PATH" ./list-org-repos.sh acme > repos.jsonl
status=403 2秒待ちます (試行 1/5)
pages=3 rows=5 unique=5 expected=5 private_count_present=true

組織の合計を6に変えると、同じ5件を取ったうえで終了コード2で止まります。

pages=3 rows=5 unique=5 expected=6 private_count_present=true
件数が一致しません (取得 5 / API の合計 6)

これは代用品を相手にした出力で、実際のAPIの応答ではありません。分岐が意図どおり動くかを見るためのもので、実際のAPIでは、待ち時間の長さやRetry-Afterの有無は状況で変わります。

Claude Codeにはスクリプトだけを実行させる

スクリプトの安全性は、スクリプトの中身で決まります。Claude Codeの許可ルールは、Claudeが書いたコマンドの文字列に対して照合されます。Bash(./list-org-repos.sh *)を許可すると、許可されるのは「このスクリプトを実行する」という文字列です。中で何をするかは審査されません。許可の前に、中身がGETだけであることを読んで確かめます。

{
  "permissions": {
    "allow": [
      "Bash(./list-org-repos.sh *)"
    ],
    "deny": [
      "Bash(gh api *-X*)",
      "Bash(gh api *-f *)",
      "Bash(gh api *-F *)",
      "Bash(gh api *--input*)"
    ]
  }
}

組み込みの読み取り専用コマンドの集合はls、cat、grep、読み取り形のgitなどで、一覧にghは載っていません。許可がなければ毎回確認が出ます。また、> repos.jsonlのようなリダイレクトは、書き込み先が編集ルールと作業ディレクトリの範囲で検査されます。

denyルールは、Claudeが通常書く形の呼び出しを止めるもので、bash -c 'gh api -X PUT ...'のような別の書き方までは止まりません。ルールの限界と、書き込み系のフラグを塞ぐdenyの全体像は、ブランチ保護をgh apiで棚卸しする手順にまとめています。厳密に止めたいときは、そちらの記事のとおりトークンの権限を絞ります。

実行の指示はCLAUDE.mdに置くと安定します。

## GitHub APIの取得ルール
- リポジトリ一覧は ./list-org-repos.sh <org> だけで取る。gh api を直接ループしない
- 並列実行(& や xargs -P)を使わない。リクエストは直列にする
- 終了コードが0でなければ、標準エラーの最終行を読んで報告する。取れた分だけで集計しない
- 完了報告には pages / unique / expected の値をそのまま添える

完了報告に件数を求めるのが要点です。Claudeが「全件取得しました」とだけ答える形では、取りこぼしの有無を人が確かめられません。uniqueとexpectedが並んでいれば、一致を目で確認できます。

GraphQLで取る場合とGITHUB_TOKENの使い方

REST APIでなく、GraphQLのgh api graphql --paginateでも一覧は取れます。マニュアルの例では、クエリが$endCursor: String変数を受け取り、pageInfo{ hasNextPage, endCursor }を返す形にします。GraphQL APIはRESTとは別にprimary上限を持ちます。件数の照合や待ち方の設計は同じ考え方で組めます。

Actionsで同じ処理を回すなら、GH_TOKENにGITHUB_TOKENを渡して実行します。上限が1リポジトリ1時間1,000リクエストなので、一覧のあとに各リポジトリへ詳細を引く構成は、先に件数を掛け算で見積もっておきます。

組織の全リポジトリに対する一括設定の注意は、Claude Code Reviewを組織の全リポジトリに有効化する手順で扱っています。別のAPIのレート制限を守る移行スクリプトは、Notion APIのレート制限をClaude Codeで守る移行スクリプトが参考になります。

まとめ

漏れのない取得は、待つ、たどる、数える、の3点で成り立ちます。--paginateは「たどる」だけを引き受けるので、待ち方と件数の照合は自分のスクリプトで補います。Claude Codeに任せるときは、実行するコマンドを1本に絞り、終了コードと件数を報告させる形にしておくと、取りこぼしが静かに混ざる経路が減ります。

参照した一次ソース

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

  1. code.claude.com/docs/en/permissions
  2. docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
  3. docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api
  4. docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-re…
  5. cli.github.com/manual/gh_api
  6. docs.github.com/en/rest/repos/repos
  7. docs.github.com/en/rest/orgs/orgs
この記事を共有:XはてブLinkedIn