GitHub Search API の 1,000件 上限 — 7,705件 一致でも 11ページ目は HTTP 422
上限に当たっても、ほとんどの場合エラーは出ない

GitHub の検索APIを叩くコードを書き直していて、返ってきた件数が合わないことに気づいた。API 自身は「7,705件 一致した」と言っているのに、最後まで読んでも手元には1,000件しかない。エラーは1つも出ていない。
2026-09-13 に、上限がどこにあるのか・どういうときに気づけるのか・全件を取るには何本に割る必要があるのかを、実際に叩いて測った。以下の数字はすべてその日の実測で、認証なし(トークンなし)の素のリクエストで取っている。
上限は1,000件。11ページ目だけがエラーになる
題材は language:javascript stars:>1000(JavaScript で書かれた、スター1,000以上のリポジトリ)。
$ curl -s "https://api.github.com/search/repositories?q=language:javascript+stars:%3E1000&per_page=1"
{
"total_count": 7705,
"incomplete_results": false,
...
7,705件 一致している。per_page=100 で読み進めると、10ページ目(901〜1,000件目)までは HTTP 200 で普通に返ってくる。11ページ目を要求すると、ここで初めて止まる。
$ curl -s -w "HTTP %{http_code}" ".../search/repositories?q=...&per_page=100&page=11"
HTTP 422
{
"message": "Only the first 1000 search results are available",
"documentation_url": "https://docs.github.com/v3/search/",
"status": "422"
}
メッセージは明快だ。最初の1,000件だけが取得できる。これは件数の上限であって、ページ番号の上限ではない。per_page=50 なら21ページ目が、per_page=30 なら34ページ目が同じ422を返す。境目は常に1,000件目にある。
危ないのは422が出る場合ではなく、出ない場合
422 は「1,001件目以降を要求した」ときにだけ出る。つまり、こう書いたコードは422を一生見ない。
const pages = Math.min(Math.ceil(wanted / 100), 10); // 10ページで打ち止め
for (let p = 1; p <= pages; p++) { ... }
10ページで止める実装は、上限に当たったこと自体を検知できない。全ページが200で、JSONも正しく、例外も飛ばない。手元のデータが1,000行で終わっているだけだ。7,705件のうち6,705件が無かったことになっている、という事実はどこにも書かれない。
これは「エラーを握り潰した」のではなく、そもそもエラーが発生していないパターンなので、try/catch を足しても捕まらない。捕まえる方法は1つしかない。API が申告した total_count と、自分が実際に受け取った件数を、毎回 比べてログに書く。
私が作っているスクレイパー側では、上限値を定数で持って(SEARCH_CAP = 1000)、1ページ目を読んだ時点で「一致 7,705件(このAPIが返せるのは最大1,000件)」という1行をログの先頭に出すようにしている。422 を受けたときも、例外の文言に回避方法(後述の期間分割)をそのまま入れている。黙って途中で終わるのがいちばん高くつく。
回避は期間で割る。割ったら合計が一致するか確かめる
公式の回答も「検索条件を絞れ」で、実務上は created:(作成日)で区間に切るのがいちばん機械的にやりやすい。同じ検索を5本に割って、それぞれの total_count を測った。
| 検索条件に足した区間 | 一致した件数 | 1,000件で足りるか |
|---|---|---|
created:*..2013-12-31 | 1,851 | 足りない(要 再分割) |
created:2014-01-01..2016-12-31 | 2,566 | 足りない(要 再分割) |
created:2017-01-01..2019-12-31 | 1,770 | 足りない(要 再分割) |
created:2020-01-01..2022-12-31 | 786 | 足りる |
created:2023-01-01..2026-12-31 | 732 | 足りる |
| 5本の合計 | 7,705 | 分割前と一致 |
合計が分割前の 7,705 とぴったり一致した。これが分割の検算になる。合計が元の件数より少なければ、区間に穴が空いている(境界の日付を書き間違えた、など)。多ければ区間が重なっている。どちらも黙って壊れるので、区間を作ったら必ず足し算で突き合わせる。
そして、この5本のうち3本はまだ1,000件を超えている。「年単位に割れば済む」わけではなく、1,000件を下回るまで再帰的に割る必要がある。2014〜2016年の2,566件は、1年ごとに割ってようやく800件台に入るかどうかという水準だ。上限は「1回の検索あたり」なので、分割数がそのままリクエスト数になる。次の節の回数制限と、ここで正面衝突する。
範囲の書き方を1文字間違えると、エラーではなく0件が返る
この測定中に自分が踏んだ罠。最初、下限のない区間を created:..2013-12-31 と書いた。
| 書き方 | 応答 | 一致件数 |
|---|---|---|
created:..2013-12-31 | HTTP 200 | 0 |
created:*..2013-12-31 | HTTP 200 | 1,851 |
created:<2014-01-01 | HTTP 200 | 1,851 |
開始側を省略した ..日付 は、構文エラーとして弾かれるのではなく 200 で 0件 が返る。ワイルドカードを入れた *..日付 か、比較演算子の <日付 なら1,851件。
私はこれで一度「2013年以前に作られたスター1,000以上の JavaScript リポジトリは0件」という結果を受け取っている。0件はもっともらしい答えに見えるのが厄介だ(昔のリポジトリは少ないだろう、と読めてしまう)。実際は1,851件あった。0件は答えではなく、質問が届かなかったことの表れである場合がある。検索語を組み立てるコードを書いたなら、0件が返った検索は「本当に0件なのか」を別の書き方で1回だけ確かめる価値がある。
検索の回数制限は、認証なしで1分に10回
上の測定を続けていたら、5本目のスライスで total_count が undefined になった。応答ヘッダに理由が書いてある。
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Used: 10
X-RateLimit-Resource: search
X-RateLimit-Reset: 1789231660
X-RateLimit-Resource: search の上限は 10。認証なしの検索は1分あたり10回で、これは他のAPI(毎時60回)とは別枠で管理されている。6本の検索を7秒おきに投げるだけで枯れた。
ここで大事なのは、自分の上限は毎回 応答ヘッダに書いてあるということ。ドキュメントの数字を定数で持つより、X-RateLimit-Remaining と X-RateLimit-Reset を読んで、0なら回復時刻まで待つほうが確実だ。トークンを付けたときの上限はこの記事では測っていない(トークンなしで運用しているため)。ヘッダを読む実装なら、測らなくても正しく振る舞う。
なお、回数制限に当たったときの応答は 403 または 429 で、422(1,000件の上限)とは別物だ。同じ「取れなかった」でも、待てば直るものと、待っても直らないので条件を割るしかないものを混ぜてはいけない。私のコードでは前者だけ回復時刻まで待ち、後者は待たずに分割の指示を出して終わる。
ついでの発見: watchers_count はスター数と同じ値だった
検索APIの行をそのまま表に流し込むとき、もう1つ地雷がある。watchers_count が使えない。
facebook/react-devtools
stargazers_count : 11010
watchers_count : 11010 ← スター数と同じ
subscribers_count: 5 ← 本当のウォッチャー数
watchers_count は歴史的な事情でスター数の別名になっていて、検索APIの応答にはこの値しか入っていない。実際に見張っている人数(subscribers_count)は5人だった。2,202倍ずれた数字を「ウォッチャー数」という列名で配ることになる。
subscribers_count は検索結果の行には入っていないので、欲しければリポジトリ単体のAPI(/repos/{owner}/{repo})を1件ずつ叩く必要がある。自分の道具では、正しく測れない列は出さないことにした。空欄よりも、嘘の数字が入った列のほうが害が大きい。
同じ形の壁は、件数の上限だけではない
今回の壁は「1,000件」という数字そのものよりも、それに当たったことが正常応答として返ってくるという形のほうが厄介だった。同じ形はほかのデータ源でも踏んでいて、たとえばドメインの登録情報を引くときは、共有の中継サーバが クライアントによって403を返し、それが「未登録」と区別できない という形で出てきた。ページのHTMLを読むときは、サーバが返したHTMLに本文が1行も無いのに200が返るという形だった。
共通する対処は3つだけだった。
- 相手が申告した件数と、自分が受け取った件数を毎回比べる。合わなければログに残す。合わせられないなら、その旨を出力の行に書く
- 0件を「答え」として受け取らない。0件の理由(該当なし/上限/拒否/構文が届いていない)を分けて持つ
- 分割したら足し算で検算する。分割は穴が空いても重複しても静かに間違う
この壁を回り込む形で作った道具
上の挙動を前提に組んだものを公開している。GitHub Repo Search(Apify)は、公式の REST API 経由で検索して、一致件数と取得件数の差をログの先頭に出し、422 を受けたら期間分割の書き方をそのままメッセージに入れて止まる。watchers 列は上記の理由で持っていない。代わりに daysSinceLastPush(最後に push されてから何日)と starsPerYear(年あたりのスター数。作られたばかりのものが不当に高く出ないよう最低1ヶ月として計算)を足してある。10年で4万スターと1年で4万スターを同じ数字で並べても、生きているかどうかは分からないからだ。
筆者: GRAMSHIFT — Android アプリと自動化ツールを個人開発。この記事の数値は 2026-09-13 に認証なしで api.github.com へ実際にリクエストして取得したもので、測定用スクリプトと本文の下書きは Claude Code との作業の中で書いた。total_count は日々動くので、数字ではなく書き方ごと載せてある。