0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Notionのプロパティ名を1つ変えたら夜間バッチが400で全滅した — スキーマ変更に強い3つの保険と完全実装

0
Posted at

NotionのUIでデータベースのプロパティ名を「ステータス」から「進行状況」に変えた翌朝、380ページを更新する夜間バッチのログがHTTP 400で埋まっていました。Notion APIはプロパティを「名前」で指定するため、UIでの1回のリネームがAPI側の全書き込みを無効化します。さらに私はtry/exceptで400を握りつぶしていたので、週次レポートで「3日間、1ページも更新が進んでいない」ことに気づくまで事故に気づけませんでした。この記事では、同じ事故を「バッチの起動時に止める」3つの保険を、コピペで動く完全実装とともに示します。

何が起きたか: UIの1操作がAPIの全指定を無効化する

Notion APIでページを更新するリクエストは、プロパティを「名前」をキーにして組みます。

{
  "properties": {
    "進行状況": { "status": { "name": "処理済み" } },
    "最終更新日": { "date": { "start": "2026-10-22T06:00:00+09:00" } }
  }
}

このキーは「リクエスト時点でそのデータベースに実在する名前」でなければなりません。UIでプロパティ名を変更すると古い名前はスキーマから消えるため、古い名前を送り続けたバッチは全リクエストで400 validation_errorを受け取りました。怖いのは次の3点です。

特徴 何が起きるか
名前はUIで誰でも、いつでも変えられる API利用者に通知は一切来ない(私の場合はチームメンバーの画面整理)
400はページ単位でなく全リクエストに出る 1回のリネームでバッチ380/380ページが全滅
レスポンスのJSONは初見では読みにくい 400がスキーマ起因だと気づくにはメッセージとのにらめっこが必要

私の事故の発端は、チームメンバーが一覧を見やすくするために「ステータス」を「進行状況」へ変えただけでした。悪意もミスもない、Notionの通常操作です。つまりこれは「誰かがミスをした事故」ではなく「UIとAPIで名前の寿命が違う構造事故」であり、書き込み側が自衛するしかありません。

失敗パターン: try/exceptで握りつぶした400が、事故を3日隠した

最初の失敗は、バッチの更新部にあったこの3行です。

try:
    update_page(page_id, values)
except Exception:
    pass  # 1件失敗してもバッチは続ける

連続処理を止めないための「やさしさ」が、全滅を無音に変えました。400は「今すぐ直せ」の意味なのにログには1行も残らず、翌朝の確認では「バッチは正常終了」とだけ出ていました。実際には3日分、全ページの更新が失敗し続けていました。復旧そのものは失敗分の再実行で済みましたが、原因の切り分けに数時間、週次レポートの訂正にさらに時間がかかりました。

教訓は2つです。**400はリトライや握りつぶしの対象ではなく「設計契約違反」**であり、原因はページの内容ではなくスキーマにあること。待機してよいのはレート制限の429だけです。

HTTP 意味 正しい扱い
400 validation_error スキーマがリクエストと一致しない 即停止、診断表示(リトライしない)
429 rate_limited 共有上限(3リクエスト/秒)の制限 1秒待って1回だけ再試行

3つの保険の完全実装: notion_batch_guard.py

やることはシンプルです。(1) バッチが書き込む名前を起動時にスキーマへ照合し、(2) 書き込みのキーを名前ではなくスキーマから引いたプロパティIDに変換し、(3) 400が来たら「死亡した名前」と現行の一覧を並べて即停止します。

#!/usr/bin/env python3
# notion_batch_guard.py - Notionバッチがプロパティ名変更の400で全滅するのを止める3つの保険
#   保険1: 起動時スキーマ検証(バッチが書く名前がすべて実在するか)
#   保険2: プロパティIDベースの書き込み(UIでのリネームに強い)
#   保険3: 400を握りつぶさない(死亡した名前と現行一覧を出して即停止)
# 使い方:
#   export NOTION_TOKEN="secret_xxx"
#   python3 notion_batch_guard.py <database_id> --dry-run   # 検証だけ実施(書き込みなし)
#   python3 notion_batch_guard.py <database_id>             # 検証後にバッチ実行
import json
import os
import sys
import time
import urllib.error
import urllib.request

API = "https://api.notion.com/v1"
NOTION_VERSION = "2022-06-01"
TOKEN = os.environ.get("NOTION_TOKEN", "")

# このバッチが書き込むプロパティ名の「契約」。ここにない名前は書かない。
REQUIRED = ["進行状況", "最終更新日"]


def call(method, path, payload=None):
    req = urllib.request.Request(
        API + path,
        data=json.dumps(payload).encode("utf-8") if payload else None,
        method=method,
        headers={
            "Authorization": "Bearer " + TOKEN,
            "Notion-Version": NOTION_VERSION,
            "Content-Type": "application/json",
        },
    )
    try:
        r = urllib.request.urlopen(req, timeout=30)
        raw = r.read().decode("utf-8")
        return r.status, json.loads(raw) if raw.strip() else {}
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", "ignore") or "{}"
        try:
            parsed = json.loads(body)
        except json.JSONDecodeError:
            parsed = {"message": body[:400]}
        return e.code, parsed


class Schema:
    """起動時に1回だけスキーマを取得し、名前とプロパティIDのマップを持つ。"""

    def __init__(self, database_id):
        status, db = call("GET", "/databases/" + database_id)
        if status != 200:
            raise SystemExit("STOP: スキーマ取得失敗 HTTP %s: %s"
                             % (status, str(db.get("message", ""))[:300]))
        self.by_name = {name: meta["id"] for name, meta in db["properties"].items()}

    def verify(self, required):
        missing = [n for n in required if n not in self.by_name]
        if missing:
            # 保険3: 「死亡した名前」と「現行の一覧」を並べて即停止
            raise SystemExit(
                "STOP: 実在しないプロパティに書き込もうとしています: %s\n"
                "  現在このデータベースに存在するプロパティ: %s\n"
                "  UIで名前が変わっていないか確認してください"
                % (missing, ", ".join(self.by_name))
            )

    def payload(self, values_by_name):
        """名前ベースの値を、IDをキーにしたpayloadへ変換する(保険2)。"""
        props = {}
        for name, value in values_by_name.items():
            if name not in self.by_name:
                raise SystemExit("STOP: プロパティ「%s」は存在しません。現行: %s"
                                 % (name, ", ".join(self.by_name)))
            props[self.by_name[name]] = value
        return {"properties": props}


def update_page(page_id, payload, retries=1):
    status, res = call("PATCH", "/pages/" + page_id, payload)
    if status == 429 and retries > 0:
        time.sleep(1.0)  # 429はレート制限。1秒待って1回だけ再試行
        return update_page(page_id, payload, retries - 1)
    if status != 200:
        # 保険3: 400はリトライしない。診断を出して止める
        raise SystemExit("STOP: HTTP %s page=%s\n  %s"
                         % (status, page_id, json.dumps(res, ensure_ascii=False)[:400]))
    return res


def main():
    if len(sys.argv) < 2 or not TOKEN:
        print("usage: NOTION_TOKEN=... python3 notion_batch_guard.py <database_id> [--dry-run]")
        return 1
    database_id = sys.argv[1]
    dry_run = "--dry-run" in sys.argv

    schema = Schema(database_id)
    print("スキーマ取得OK: %d プロパティ" % len(schema.by_name))

    # 保険1: 1ページも書く前に「契約」を検証
    schema.verify(REQUIRED)
    for name in REQUIRED:
        print("  保険2: %s -> id=%s" % (name, schema.by_name[name]))
    if dry_run:
        print("検証PASS(書き込みは行っていません)")
        return 0

    status, pages = call("POST", "/databases/%s/query" % database_id,
                         {"page_size": 20})
    if status != 200:
        raise SystemExit("STOP: query失敗 HTTP %s: %s" % (status, str(pages)[:300]))
    for page in pages["results"]:
        values = {
            "進行状況": {"status": {"name": "処理済み"}},
            "最終更新日": {"date": {"start": time.strftime("%Y-%m-%dT%H:%M:%S+09:00")}},
        }
        update_page(page["id"], schema.payload(values))
        print("updated:", page["id"])
        time.sleep(0.35)  # Notionの共有上限3リクエスト/秒を守る間隔
    print("done: %d 件" % len(pages["results"]))
    return 0


if __name__ == "__main__":
    sys.exit(main())
```## 保険の効き方: どこで止まり、何が出るか

**保険1(起動時検証)**: `REQUIRED` は「このバッチが書くプロパティ名の契約」です。`schema.verify(REQUIRED)` が起動時に照合するため、名前が消えていれば1ページ目を書く前に止まります。`--dry-run` は書き込みを伴わないので、UI変更が入った晩に念押しで回せます。

$ python3 notion_batch_guard.py --dry-run
スキーマ取得OK: 9 プロパティ
保険2: 進行状況 -> id=JfEw
保険2: 最終更新日 -> id=HxKd
検証PASS(書き込みは行っていません)


名前が死んでいた晩には、こう止まります。メンバーの変更作業をAPI側で初検知する形になり、「誰がいつ変えたか」の問い合わせが不要になります。

$ python3 notion_batch_guard.py --dry-run
スキーマ取得OK: 9 プロパティ
STOP: 実在しないプロパティに書き込もうとしています: ['ステータス']
現在このデータベースに存在するプロパティ: 進行状況, 担当者, タイトル, ...
UIで名前が変わっていないか確認してください


**保険2(IDベース書き込み)**: `Schema.payload()` は、名前をスキーマから引いたプロパティIDに変換してからpayloadを組みます。Notion APIはpropertiesのキーにIDを指定でき、IDはリネームしても不変です。つまり検証を通過した後に誰かが名前を変えても、書き込み自体は成功し続けます。クエリのfilterやsortでプロパティを指定する箇所も同名で壊れるので、フィルタを使うバッチでは同じマップからIDを渡してください。

**保険3(400の即時診断)**: `update_page` は400をリトライしません。`raise SystemExit` でバッチ全体を止め、レスポンスJSON(消えた名前を含むメッセージ)とpage_idを出力します。「1件だけ失敗して続く」のではなく「0件で即停止」が、夜間バッチの正しい失敗の形です。止まった夜に出力を読めば、対応はREQUIREDの2ワード変更で済みます。

## 比較: 3つの書き方で事故コストがどう変わるか

| 書き方 | 気づく時期 | 復旧コスト | リネーム耐性 |
|---|---|---|---|
| 名前直書き + except: pass(事故時の私) | 週次レポート(3日後) | 3日分の再実行と信頼回復 | ゼロ(全滅を無音化) |
| 名前直書き + 例外を投げる | 最初の400でバッチ停止 | メッセージとのにらめっこに数時間 | ゼロ(止まるが壊れる) |
| 保険1-3(本記事の実装) | 起動時(0ページを書く前にSTOP) | REQUIREDの2ワード更新のみ | あり(IDは不変) |

## まとめ

- NotionのUIでプロパティ名を変える操作は、APIの全書き込みを400で止める。通知は来ないので、書き込み側が自衛するしかない
- 400は握りつぶさず即停止。待機してよいのは429だけ
- **次のアクション**: Notion APIに書き込むコードがあるなら、今晩 `REQUIRED`(書き込む名前の契約)と `--dry-run` 検証を入れる。スキーマ取得は1回で済み、既存バッチへの追加は数行。チームで名前を変える運用があるなら、変更が入った晩にdry-runを回す1行を運用手順に足すこと

> 🎁 [読者特典を受け取る(無料・メール登録)](https://hayama.yuenreach.com/?utm_source=qiita&utm_medium=article&utm_campaign=reader_bonus&utm_content=notion_schema_drift_400)

### 関連記事(同テーマ)

- [Notion APIのレート制限で詰まった話 — 3リクエスト/秒を守る実装と、失敗した設計](https://qiita.com/yukihayama/items/b6fc82730f80bfdcac18)
- [Notionデータベースを極める — タスク管理からリレーションまで](https://qiita.com/yukihayama/items/7179ab121820e9bee709)

**参考書籍:** [Notion完全活用ガイド — すべてを一つにまとめる知的生産術](https://www.amazon.co.jp/dp/B0HDRYPGCM)(Kindle・Kindle Unlimited読み放題対象)(葉山悠希 著)

著者: 葉山悠希 — 書籍シリーズは [Zenn](https://zenn.dev/yukihayama) / [Amazon](https://www.amazon.co.jp/dp/B0HDGKSNY1) で公開中
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?