Microsoft Graph 実務入門 #7 運用 — スロットリング、ページネーション、delta クエリ、変更通知

読了 7分

シリーズの最終回は運用です。第 4〜6 回で作った自動化は「動く」コードでした。今回はそれを「数か月回しても、静かにデータが欠けたり止まったりしない」コードに変える四つ、スロットリング、ページネーション、delta クエリ、変更通知を扱います。インフラの検索型の記事で繰り返した運用の感覚が、Graph という API の上で再現される回でもあります。

スロットリング — 429 はエラーではなく契約です #

Graph はサービスごとに呼び出し量の上限を持ち、超えると 429 Too Many Requests を返します。ここで重要なルールが三つです。

  • レスポンスの Retry-After ヘッダーが指示する秒数だけ待ってから再試行します。これが最も速く回復する道です。429 を無視した即時の再試行は使用量に数えられ続け、スロットリングを延長させます。
  • 上限は「全体で X 回」ではなく、サービス別・テナント別・アプリ別にばらばらです(Outlook 系、SharePoint 系、Teams 系が全部違います)。数字を覚えるより、「429 に遭遇したら退く」という動作をコードに入れるのが正解です。
  • 一部のリソースは Retry-After を返さない場合があるので、そのときは指数バックオフで代替します。

よい知らせは、SDK にこの動作がすでに入っていることです。msgraph-sdk の既定のミドルウェアが、429・5xx に対して Retry-After(なければ指数バックオフ)の再試行を処理します。だから実務の焦点は、再試行の実装よりそもそもスロットリングに当たりにくい設計に移ります。

  • $select でレスポンスを減らします(第 1 回から繰り返したあの習慣です)。
  • ポーリングを delta クエリや変更通知に替えます(下で扱います)。
  • 大量の巡回は同時実行数を制限します。asyncio で数百リクエストを一度に投げるのは 429 を量産する行為です。asyncio.Semaphore で同時 4〜8 程度に束ねるのが無難な出発点です。

ページネーション — nextLink をたどらないとデータが欠けます #

一覧の API は結果を一度に全部くれません。1 ページ(既定で数十〜数百件)とともに次のページの URL である @odata.nextLink が来て、これをたどらないと残りは静かに消えます。「ユーザー数がなぜ 100 人しか出ないのか?」の正体は大半がこれです。

全ページ巡回 — 再利用ヘルパー
async def all_pages(client, first_response):
    """どんな一覧レスポンスでも全ページを巡回するジェネレータ"""
    page = first_response
    while page:
        for item in page.value or []:
            yield item
        if not page.odata_next_link:
            break
        page = await client.users.with_url(page.odata_next_link).get()

# 使用例
result = await client.users.get()
async for user in all_pages(client, result):
    print(user.display_name)

with_url() は、nextLink のようにすでに完成した URL で次のリクエストを送る SDK の通路です。nextLink には元のクエリ($select など)が保存されているので、そのままたどるだけで済みます。一覧を扱うすべての自動化にこのヘルパー一つを置いて強制的に使うことが、データ欠落の事故を構造的に防ぐ方法です。

delta クエリ — 「変わったものだけ」を状態として管理する #

「毎時間メールボックス全体を読み直して新着を探す」ポーリングは遅く、スロットリングを呼び、大半が無駄です(変化はまれだからです)。Graph の答えが delta クエリです。最初の呼び出しで全体の状態とともに @odata.deltaLink(しおり)を受け取って保存しておき、次からは deltaLink を呼べばその間に変わったものだけが来ます。

delta クエリ — 変更分だけ同期
async def sync_users(client, saved_delta_link: str | None):
    if saved_delta_link:
        page = await client.users.with_url(saved_delta_link).get()
    else:
        page = await client.users.delta.get()   # 初回の全体同期

    changes = []
    while page:
        changes.extend(page.value or [])
        if page.odata_next_link:                 # 変更分もページネーションされます
            page = await client.users.with_url(page.odata_next_link).get()
        else:
            new_delta_link = page.odata_delta_link
            break

    # changes の処理: 削除は項目の @removed の印で来ます
    return changes, new_delta_link   # 新しいしおりを保存場所へ

運用のルールは三つです。deltaLink は状態なのでファイルでも DB でも永続化し、削除された項目は @removed の注釈で来るので必ず処理し、deltaLink が古すぎて期限切れというエラー(410 Gone)を受けたら全体同期からやり直します。対応リソースは users、groups、メール(messages)、予定(calendarView)、driveItem、Teams のメッセージ(chatMessage)など、同期の需要が大きい領域です。「定期的に回るバッチ + delta」の組み合わせはポーリングに比べて呼び出し量を桁違いに減らしてくれて、SharePoint 側では delta リクエストのスロットリングのコスト自体が低く扱われることさえあります。

変更通知 — プッシュしてもらう側に切り替える #

バッチの間隔すら長いなら(新着メールに数秒で反応する必要があるなら)方向を逆にします。変更通知(サブスクリプション・Webhook)は、Graph が自分の HTTPS エンドポイントへ変更をプッシュしてくれる方式です。

サブスクリプションの作成と検証ハンドシェイク (FastAPI)
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, Request, Response
from msgraph.generated.models.subscription import Subscription

app = FastAPI()

@app.post("/graph/notify")
async def notify(request: Request):
    # ① 作成時の検証: validationToken を 10 秒以内に text/plain で返す
    token = request.query_params.get("validationToken")
    if token:
        return Response(content=token, media_type="text/plain")

    # ② 実際の通知: clientState を照合し、重い処理はせずキューに入れて即 202
    payload = await request.json()
    for note in payload.get("value", []):
        if note.get("clientState") != EXPECTED_STATE:
            continue
        enqueue(note["resource"])    # 詳細の照会はワーカーが別途
    return Response(status_code=202)

async def subscribe(client):
    sub = Subscription(
        change_type="created",
        notification_url="https://automation.contoso.com/graph/notify",
        resource="users/{id}/mailFolders('inbox')/messages",
        expiration_date_time=datetime.now(timezone.utc) + timedelta(days=2),
        client_state=EXPECTED_STATE,   # 偽の通知を見分けるための秘密の値
    )
    return await client.subscriptions.post(sub)

運用で重要な性質があります。

  • サブスクリプションは期限切れになります。 最大の寿命がリソースごとに違うので(メール・予定は 7 日未満、driveItem は 30 日未満、Teams のメッセージは条件により 1 時間の単位など)、更新(PATCH)をスケジュールに入れるところまでが実装です。更新が途切れると通知も静かに途切れます。
  • 通知には基本的に「何かが変わった」という事実とリソースのパスだけが来ます。内容は受けた側が照会し直すのが基本形で、その照会に delta を組み合わせれば(通知はトリガー、delta で整合の同期)、通知の欠落にも強い構造になります。
  • Teams のリソースのように lifecycleNotificationUrl(サブスクリプションが除去されたり再認証が必要になったりしたときの別の通知チャネル)が必須になる場合があり、必須でなくても付けておくと回復力の面で有利です。
  • エンドポイントは公開の HTTPS である必要があるので、社内ネットワークしかない環境なら Azure Functions のような薄い受信部を前に立てる構成が一般的です。

ポーリング → バッチ+delta → 通知+delta と進むほど、リアルタイム性が上がる代わりに運用の部品(エンドポイント、更新のスケジュール、状態の保存)が増えます。要求される反応速度に合う最も単純な段階を選ぶのが正しく、大半の社内自動化はバッチ+delta で終わります。

最後の道具 — JSON バッチング #

複数の独立したリクエストを一つの HTTP 呼び出しに束ねる $batch(最大 20 個)も知っておく価値があります。「ユーザー 20 人のプロフィールをそれぞれ照会」のようなファンアウトを往復 1 回に減らします。ただしバッチングは往復を減らすだけで、スロットリングの集計はリクエスト数の基準で行われるので、ネットワークの記事の往復の掛け算の問題への処方箋であって、429 への処方箋ではないという区別だけ正確にしておきます。

まとめ — シリーズを閉じながら #

  • 429 は Retry-After を尊重すれば済みます(SDK が既定で処理)。実務の焦点は $select、同時実行の制限、ポーリングの置き換えという「当たりにくい設計」です。
  • 一覧は nextLink を最後までたどって初めて全部です。全ページ巡回のヘルパーを作って強制するのが、欠落事故の構造的な予防です。
  • 繰り返しの同期は delta クエリに替えます。deltaLink は永続の状態、@removed の処理、410 なら全体の再同期という三つのルールが運用の全部です。
  • リアルタイムが必要なら変更通知を載せますが、検証ハンドシェイク・clientState の照合・期限の更新スケジュールまでが一セットです。通知はトリガーに使い、整合は delta で合わせると欠落に強くなります。
  • $batch は往復を減らす道具であって、スロットリングを避ける道具ではありません。

シリーズ全体を一文にまとめるとこうなります。アプリ登録と権限の型(第 2 回)を正確に決め、SDK の骨格(第 3 回)の上にシナリオ(第 4〜6 回)を載せ、運用の四つ(第 7 回)を備えれば、Microsoft 365 の上のどんな社内自動化も同じ方法で作れます。 次の拡張の候補としては、Entra ID の管理の自動化(入退社の処理)、Excel API を使ったレポート生成、Power Automate との役割分担のようなテーマが自然につながります。

X