速水拓真 営業×AI 実践シリーズ
書き下ろし

Notion APIのレート制限で詰まった話 — 3リクエスト/秒を守る実装と、失敗した設計

書き下ろし(当サイト初出)

はじめに

Notion APIでデータベースを一括更新するスクリプトを書いたら、途中から429(rate limited)で止まり、半分だけ更新された状態になりました。しかも厄介なことに、エラーが出るのは決まって処理の後半です。前半は成功しているので「動いている」ように見えてしまいます。

原因はNotion APIの平均3リクエスト/秒という制限でした。私は50件のページを for ループで一気に叩いていました。

この記事では、429で壊れない実装を書きます。ポイントは「sleepを入れる」ではなく、リトライを前提とした設計にすることです。

  • 前提: Python 3.9+ / Notionのインテグレーショントークンを取得済み
  • ゴール: 100件の更新を、429で止まらず、途中で落ちても再開できる形にする

まず、素朴な実装が壊れる理由

最初に私が書いた(そして壊れた)コードです。

import os
import requests

TOKEN = os.environ["NOTION_TOKEN"]
DB_ID = os.environ["NOTION_DATABASE_ID"]

headers = {
    "Authorization": f"Bearer {TOKEN}",
    "Notion-Version": "2022-06-28",
    "Content-Type": "application/json",
}

# これが壊れる: 50件を間隔ゼロで叩く
for page_id in page_ids:
    requests.patch(
        f"https://api.notion.com/v1/pages/{page_id}",
        headers=headers,
        json={"properties": {"ステータス": {"select": {"name": "完了"}}}},
    )

この実装が壊れる3つの理由:

  1. レート制限を守っていない — Notion APIは平均3リクエスト/秒。単純な for ループはそれを軽く超えます
  2. 429を無視しているrequests.patch の戻り値を一切見ていないので、失敗しても気づきません
  3. 途中で落ちると再開できない — どこまで成功したかを記録していないため、再実行すると二重更新になります

私の場合、3番目が一番痛いものでした。エラーに気づいた時点で「どこまで終わったか」が分からず、全件を手で確認する羽目になりました。

修正版: リトライと進捗記録を入れる

import json
import os
import time
from pathlib import Path

import requests

TOKEN = os.environ["NOTION_TOKEN"]
DB_ID = os.environ["NOTION_DATABASE_ID"]
PROGRESS = Path("notion_update_progress.json")

HEADERS = {
    "Authorization": f"Bearer {TOKEN}",
    "Notion-Version": "2022-06-28",
    "Content-Type": "application/json",
}

# Notion APIの公称レートは平均3リクエスト/秒。
# 0.34秒間隔 = 約2.9リクエスト/秒 に抑えて余裕を持たせる。
MIN_INTERVAL = 0.34
MAX_RETRY = 5


def load_progress() -> set:
    """前回どこまで成功したかを読み込む(再開用)"""
    if PROGRESS.exists():
        return set(json.loads(PROGRESS.read_text())["done"])
    return set()


def save_progress(done: set) -> None:
    PROGRESS.write_text(json.dumps({"done": sorted(done)}))


def patch_page(page_id: str, properties: dict) -> dict:
    """429/5xx を指数バックオフでリトライする"""
    url = f"https://api.notion.com/v1/pages/{page_id}"
    for attempt in range(MAX_RETRY):
        resp = requests.patch(url, headers=HEADERS, json={"properties": properties},
                              timeout=30)

        if resp.status_code == 200:
            return resp.json()

        # 429 と 5xx はリトライ対象。4xx(429以外)は即エラー
        if resp.status_code == 429 or resp.status_code >= 500:
            # Retry-After があればそれを尊重する
            wait = float(resp.headers.get("Retry-After", 2 ** attempt))
            time.sleep(wait)
            continue

        resp.raise_for_status()

    raise RuntimeError(f"リトライ上限({MAX_RETRY})に到達: {page_id}")


def main(page_ids: list, properties: dict) -> None:
    done = load_progress()
    todo = [p for p in page_ids if p not in done]

    print(f"対象 {len(page_ids)}件 / 完了済み {len(done)}件 / 残り {len(todo)}件")

    for i, page_id in enumerate(todo, 1):
        patch_page(page_id, properties)
        done.add(page_id)

        # 10件ごとに進捗を保存(途中で落ちてもここから再開できる)
        if i % 10 == 0:
            save_progress(done)
            print(f"  {i}/{len(todo)} 完了")

        # レート制限を守るため必ず待つ
        time.sleep(MIN_INTERVAL)

    save_progress(done)
    print(f"完了: {len(done)}件")

この実装で変わった点:

  • Retry-After ヘッダを尊重する — Notionが「何秒待て」と指示してくる場合はそれに従います。指数バックオフより確実です
  • 10件ごとに進捗を保存する — 途中で落ちても、再実行すれば続きから再開します
  • 429以外の4xxは即エラーにする — 権限エラーなどを無駄にリトライすると、レート制限を悪化させるだけです

バックオフ戦略の使い分け

リトライの待ち時間設計は、状況によって向き不向きがあります。

戦略 待ち時間 向いている状況 弱点
固定間隔 常に0.34秒 通常の逐次処理 一時的なスパイクに弱い
指数バックオフ 1→2→4→8秒 サーバー側の一時障害 待ちすぎて処理が遅くなる
Retry-After 尊重 サーバー指定 レート制限(429) ヘッダが無い場合は使えない
トークンバケット 毎秒3個補充 大量の並列処理 実装が複雑

私の結論: 単発のバッチ処理なら「Retry-After 尊重 + 固定間隔」の組み合わせが最も実装が簡単で壊れにくいです。トークンバケットは、同時並列で叩く必要が出てから考えれば十分でした。

私が実際に踏んだ失敗パターン

失敗1: time.sleep() をループの先頭に入れた

# これは間違い: 初回リクエストの前に待つ意味がない
for page_id in page_ids:
    time.sleep(0.34)
    requests.patch(...)

1件目から待たされる無駄が生じる上、リトライ時の待機を別途書く必要があることに気づいていませんでした。待機は「リクエスト後」に置くのが正しい位置です。

失敗2: 429を「リトライすれば直る」とだけ考えた

レート制限に当たり続ける状態は、自分の設計がレートを超えているサインです。リトライで誤魔化すと、処理時間だけが伸びて最後に失敗します。まず MIN_INTERVAL を見直すべきでした。

失敗3: notion-client SDKのリトライに頼りすぎた

公式SDKにもリトライはありますが、進捗の永続化はしてくれません。途中で落ちたときの再開は自前で設計する必要があります。

まとめ

Notion APIでの一括更新は、次の3点を押さえれば壊れません。

  1. 平均3リクエスト/秒を守るtime.sleep(0.34) をリクエストのに置く
  2. 429は Retry-After を尊重してリトライ — 指数バックオフはヘッダが無いときの保険
  3. 進捗を永続化する — 10件ごとにファイルへ保存し、再実行で続きから再開

次に試すなら、並列化です。ただしNotion APIのレート制限はアカウント単位なので、並列数を増やすほど429に当たりやすくなります。まずは逐次処理で安定させ、本当に速度が必要になってからトークンバケットを検討するのが順序として正しいと感じています。

読者特典(無料)

本記事のような実装パターンを横断的にまとめたチートシートと、Obsidian・Notionのテンプレート集を無料配布しています。8冊の内容から「何をどのツールでやるか」の判断チャートも含みます。

🎁 読者特典を受け取る(無料・メール登録)

📗 Notion完全活用ガイド — すべてを一つにまとめる知的生産術(Kindle・読み放題対象)

著者: 葉山悠希 — 書籍シリーズは Zenn / Amazon で公開中

営業×AI実践シリーズ 全3作

Kindle Unlimited 読み放題対象 — 追加料金なしで読めます

← 記事一覧へ

← 自動化を2週間で自分仕様に直す — その場で直す…断られた翌朝、AIに議事録を読ませた話… →