Claude Media
Notion APIのレート制限をClaude Codeで守る移行スクリプトの作り方

Notion APIのレート制限をClaude Codeで守る移行スクリプトの作り方

Notion APIは多くのプランで平均3リクエスト/秒です。429とRetry-After、start_cursorのページネーション、503後の二重書き込み対策まで、Claude Codeに書かせる指示と検証手順をまとめます。

Notion APIのレート制限は、Business・Enterprise以外のプランだと平均3リクエスト/秒、60秒あたり180リクエストです。データベースの移行スクリプトをClaude Codeに「全件コピーして」とだけ頼むと、ループが全力で回り、数十件目で429が返り始めます。

この記事では、制限の中身を先に押さえ、Notion APIを直接呼ぶ移行スクリプトをClaude Codeに書かせるときの指示と、書かれたコードの確かめ方を扱います。MCPサーバー経由でNotionをつなぐ方法はNotion MCPサーバーの使い方にあります。ここではapi.notion.comをREST APIとして呼ぶ場合だけを対象にします。

先に知っておく制限は3種類ある

Notion APIの制限は、接続単位・ワークスペース単位・エンドポイント単位の3つです。スクリプトの設計は、この3つのどれに当たっても止まらないことが条件になります。

制限

移行スクリプトが当たる3つの制限

  • 接続ごとの上限

    60秒の窓あたりの予算が決まっています。Business・Enterpriseは600リクエスト/分(平均10/秒)、それ以外は180リクエスト/分(平均3/秒)です。

  • ワークスペース全体の上限

    そのワークスペースの全接続で共有されます。自分の接続が上限内でも、他の接続が使っていれば429になり、Retry-Afterが1分を超えることがあります。

  • エンドポイント固有の上限

    データソースのクエリは、接続あたり約3リクエスト/秒の別枠です。この枠はプラン別の表とは別に書かれているため、読み出し側は10/秒になる前提で組まないほうが安全です。

窓の使い方に決まりはなく、均等に消費しても、一気に使い切ってもかまいません。バーストで使い切った場合、次のリクエストに付くRetry-Afterは窓がリセットされるまでの秒数で、最大60秒です。

429のbodyには「どの上限か」が入っている

超過すると、HTTP 429とrate_limitedのエラーコードが返ります。additional_data.rate_limit_reasonが、どの上限に当たったかを示します。

rate_limit_reason意味スクリプトの動き
public_api_request_rate_limit意味この接続が速すぎるスクリプトの動きRetry-Afterだけ待つ
public_api_space_request_rate_limit意味ワークスペース共有の予算切れスクリプトの動きRetry-Afterだけ待つ
public_api_endpoint_rate_limit意味エンドポイント固有の上限スクリプトの動きRetry-Afterだけ待つ
collection_router_too_many_requests意味データソースのクエリが絞られたスクリプトの動きRetry-Afterだけ待つ
public_api_request_blocked意味この接続のAPIアクセスが制限されたスクリプトの動き再試行せず停止する

ほかの値が現れても、通常の429と同じくRetry-Afterを待つと書かれています。実装は「blockedだけ止め、残りは待つ」の2分岐で足ります。

Retry-Afterは整数の秒数で、ヘッダを読めないクライアント向けに、bodyのadditional_data.retry_afterにも同じ秒数が文字列で入ります。529(service_overload)はNotion側の一時的な過負荷で、429と同じ扱いで再試行します。

Claude Codeに渡す指示は「上限の数字」と「止める条件」を含める

「レート制限に気をつけて」は、Claude Codeには何も指定していないのと同じです。数字と分岐を明示すると、生成されるコードがほぼ決まります。次の指示は、公式の再試行方針をそのまま仕様にした例です。

Notion APIで、データソースAの全ページをデータソースBへコピーする
Pythonスクリプト migrate.py を作って。仕様は次のとおり。
 
- 認証は環境変数 NOTION_TOKEN。Notion-Version ヘッダは 2026-03-11
- 読み出しは POST /v1/data_sources/{id}/query。page_size は 100 を
  明示し、has_more が false になるまで next_cursor を start_cursor
  に渡して回す
- 書き込みは POST /v1/pages、parent は data_source_id で指定
- 全リクエストを1本の関数に通し、最低 0.4 秒の間隔を空ける
  (平均3件/秒の枠に対して2.5件/秒)
- 429 と 529 は Retry-After 秒だけ待って再試行。ヘッダが無ければ
  指数バックオフ+ジッター。最大6回で諦める
- ただし rate_limit_reason が public_api_request_blocked の429は
  再試行せず、即停止する
- 500/502/503/504 は GET と DELETE だけ再試行。POST では再試行しない
- 移行済みページの元ID→新IDを JSON Lines に追記し、再実行時は
  記録済みの元IDを飛ばす
- 実行前に --dry-run で件数だけ数えられるようにする

指示の中で効くのは、最後の2行です。記録ファイルによる再開と--dry-runがないと、途中で止まった移行をやり直すときに二重登録を避けられません。

生成されるスクリプトの骨格

指示どおりに書かれた場合の骨格は、次のような形になります(公式の再試行例と同じ方針で組んだ例です)。

import json, os, random, sys, time
import requests
 
BASE = "https://api.notion.com/v1"
HEADERS = {
    "Authorization": f"Bearer {os.environ['NOTION_TOKEN']}",
    "Notion-Version": "2026-03-11",
    "Content-Type": "application/json",
}
MIN_INTERVAL = 0.4  # 2.5 req/s。平均3 req/s の枠に余裕を残す
_last = 0.0
 
 
def call(method, path, body=None, max_attempts=6):
    global _last
    idempotent = method in ("GET", "DELETE")
    for attempt in range(max_attempts):
        wait = MIN_INTERVAL - (time.monotonic() - _last)
        if wait > 0:
            time.sleep(wait)
        _last = time.monotonic()
        res = requests.request(method, f"{BASE}{path}",
                               headers=HEADERS, json=body, timeout=60)
        if res.status_code < 400:
            return res.json()
 
        reason = (res.json().get("additional_data") or {}).get(
            "rate_limit_reason") if res.headers.get(
            "content-type", "").startswith("application/json") else None
        if reason == "public_api_request_blocked":
            sys.exit("接続のAPIアクセスが制限されています。Notionに問い合わせてください")
 
        retryable = res.status_code in (429, 529) or (
            idempotent and res.status_code in (500, 502, 503, 504))
        if not retryable or attempt == max_attempts - 1:
            res.raise_for_status()
 
        retry_after = res.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else min(2 ** attempt, 30)
        time.sleep(delay + random.uniform(0, 0.25))

間隔の下限を関数の入口で守り、再試行の待ち時間はその上に重ねる作りです。間隔の管理と再試行を別々の場所に書くと、再試行の連打が間隔の外側で走る形になりがちなので、1本の関数にまとめさせるのが要点です。

ページネーションはstart_cursorを不透明な値として渡す

データソースのクエリは、POSTのbodyにstart_cursorとpage_sizeを入れます。レスポンスはresults・has_more・next_cursorを返し、has_moreがtrueの間、next_cursorをそのまま次のstart_cursorに渡します。公式はカーソルを不透明な値として扱うよう書いているので、中身を解析したり自作したりしません。

def all_pages(data_source_id):
    cursor = None
    while True:
        body = {"page_size": 100}
        if cursor:
            body["start_cursor"] = cursor
        data = call("POST", f"/data_sources/{data_source_id}/query", body)
        yield from data["results"]
        if not data["has_more"]:
            return
        cursor = data["next_cursor"]

page_sizeは明示します。ページネーションの説明が2か所に分かれていて、旧ページは既定100・最大100、新しい概要ページは既定10件と書いており、既定値の記述が食い違っています。最大は100なので、100を指定すれば食い違いの影響を受けません。件数が100で割り切れるときの終端も、has_moreで判定すれば安全です。

読み出しの総数は、ceil(件数 / 100)回のクエリで済みます。1,000ページなら10回で、律速になるのは1件1リクエストの書き込み側です。

書き込みの再試行で二重登録を避ける

読み出しは何度繰り返しても結果が変わりませんが、POST /v1/pagesは再試行で二重登録を生みます。公式の再試行方針も、5xxの再試行をGETとDELETEに限っています。

さらに、書き込みが503を返しても、変更自体は保存されていることがあります。レスポンスを組み立てる時間が足りなかったときです。bodyのadditional_data.retry_guidanceに「書き込みを繰り返さず、オブジェクトを読み直す」といった指示が入り、ページやデータベースの作成ではcommitted_resource_idに新しいオブジェクトのIDが入ります。

落とし穴

503を受けてPOSTを再送すると、同じページが2件できる

ページの作成が503で返ってきたときは、committed_resource_idがあればそのIDでページを取得し、移行記録に新IDとして書きます。再送するのは、IDが無く、現在の状態を調べても保存されていないと分かったときだけです。Notionがすべての書き込みの保存有無を判別できるわけではないため、他の503ではIDが無いこともあります。

Claude Codeに渡す指示にも、この分岐は明記します。書いておかないと、POSTを429と同じ扱いで再送するコードになりやすい箇所です。

リクエスト内容側の上限にも当たる

レート制限とは別に、リクエストのサイズ上限があります。超えると400のvalidation_errorが返ります。移行元のデータをそのまま渡すと当たりやすいのは次の項目です。

項目上限
リッチテキストのtext.content上限2000文字
URL(text.link.urlを含む)上限2000文字
ブロックやリッチテキストの配列上限100要素
マルチセレクト上限100オプション
リレーション上限1リクエストで100ページ
1リクエスト全体上限ブロック1000個・500KB

リレーションの100は、1回のリクエストで追加できる数の上限です。プロパティが持てる数の上限ではありません。長文のメモ欄を2000文字ごとに分割する処理や、100件を超えるリレーションの分割送信は、指示に入れておくとよい項目です。

400は、再試行ではなくリクエストの修正で直します。スクリプトでは「400はログに残してそのページを失敗リストへ回し、全体は止めない」とするのが扱いやすい作りです。

公式SDKを使うなら、再試行は任せられる

JavaScript SDKは、429と529をすべてのメソッドで再試行します。500と503はGETとDELETEだけで、502と504は再試行しません。待ち時間にはRetry-After(既定で最大60秒)を使い、ヘッダが無いときは指数バックオフとジッターに切り替えます。

言語がJavaScriptやTypeScriptで構わないなら、SDKを使う指示にすると、再試行ロジックの生成と検証が丸ごと不要になります。ただし自前の間隔制御(3リクエスト/秒を超えて投げない仕組み)はSDKの再試行とは別物なので、バーストを避けたいなら移行ループの側で並列数を絞ります。

書かせたコードを動かす前に確かめる

Claude Codeに「テストして」と任せるより、確かめる対象を指定したほうが確実です。公式のベストプラクティスも、検証手段をClaudeに与えることを勧めています。

  1. 429を返すだけのローカルの擬似サーバーを立てる。Retry-Afterを2にし、3回目から200を返すようにして、待ち時間が2秒前後で、成功後にスクリプトが先へ進むことを見る
  2. public_api_request_blockedのbodyを返し、再試行せずに停止することを見る
  3. POSTに503を返し、committed_resource_id付きのbodyで、再送されず記録に新IDが入ることを見る
  4. --dry-runで件数を出し、元のデータソースのページ数と一致するか比べる
  5. 実データの前に、10件程度のテスト用データソースで本実行し、ページ数と移行記録の行数を照合する

実際のNotionに429を出させる必要はありません。公式が挙げる分岐(429・529・blocked・503)を、擬似サーバーが返すbodyで再現すれば足ります。

CLAUDE.mdに残しておく規約

移行スクリプトを何本も作るなら、制限の数字と再試行方針をCLAUDE.mdに書いておくと、毎回の指示が短くなります。

## Notion API を直接呼ぶコード
- 全リクエストを1関数に通し、間隔は 0.4 秒以上空ける
- 429/529 は Retry-After を待って再試行。blocked は停止
- POST の 5xx は再送せず、committed_resource_id で状態を確認する
- 一覧取得は page_size=100 を明示し、has_more を見て回す
- 移行済みIDを追記保存し、再実行時は飛ばす

想定する所要時間を先に見積もる

制限の数字から、実行時間は事前に計算できます。1,000ページの移行なら、書き込み1,000回と読み出し10回で約1,010リクエスト、2.5リクエスト/秒で約404秒、つまり7分弱です。1万ページなら約67分で、再試行の待ちが加わります。

この見積もりを--dry-runの出力に含めさせると、実行前に「夜間に回すか」を判断できます。平均3リクエスト/秒に対する2.5リクエスト/秒という設定は、同じワークスペースで別の接続が動いていても、ワークスペース共有の上限に当たりにくくするための余裕でもあります。

まとめ

数字(0.4秒間隔、page_size=100)、止める条件(blocked、POSTの5xx)、再開の仕組み(移行記録)の3つを指示に含めると、Claude Codeの出力は公式の再試行方針に沿う形に収まります。Notion側の上限は将来調整される予定と公式に書かれているため、間隔はコード中の定数に切り出しておくと、変更時の手直しが1か所で済みます。

429の一般的な読み方は、Claude API側の話ですがClaude APIの429が出てレート制限が低すぎると感じたときの確認手順にも共通する考え方があります。自社APIに制限を入れる側の実装は、Claude CodeでRedisのレート制限を実装するで扱っています。

参照した一次ソース

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

  1. developers.notion.com/reference/request-limits
  2. developers.notion.com/reference/intro
  3. developers.notion.com/reference/query-a-data-source
  4. developers.notion.com/reference/post-page
  5. code.claude.com/docs/en/best-practices
この記事を共有:XはてブLinkedIn