X APIが403を返したときの切り分け — 予算加算の先走りと診断ヘッダー欠落を防ぐ設計

  • #API
  • #Python
  • #トラブルシューティング
  • #運用設計
  • #課金管理
X APIが403を返したときの切り分け — 予算加算の先走りと診断ヘッダー欠落を防ぐ設計

今回やったこと

外部SNS(X / 旧Twitter)への自動投稿スクリプトにおいて、HTTP 403 Forbidden が発生した際に「失敗したリクエストまで予算を消費したと記録されてしまう問題」と「エラー本文だけでは原因が切り分けられない問題」の2点を改修した。

API呼び出し前の予算チェックとAPI受理後の確定処理を分離し、さらに HTTPError 発生時にレスポンスヘッダーからレート制限やアクセスレベルの診断情報を抽出してログに残す設計へと更新した。

従量課金や利用枠の上限が存在する外部APIと連携する自動化パイプラインにおいて、障害時のデータ不整合と原因調査の停滞を防ぐ実装パターンを記録する。

発生した2つの課題

自前の自動配信システムから外部プラットフォームへ記事要約を投稿するスクリプト(x_api_post.py)を運用していた際、特定のリクエストで HTTP 403 エラーが返り、投稿が失敗する事象が発生した。

この障害への対応において、以下の2つの設計上の欠陥が浮き彫りになった。

課題1: 失敗リクエストによるローカル予算台帳のズレ

スクリプト側では、API利用料の使いすぎを防ぐためにローカルのJSON台帳(usage.json)で累計コストを見積もり、上限額を超えないよう制御していた。

しかし、従来の実装では HTTP POST リクエストを送信する「前」に予算加算関数を呼んでいた。

# 修正前の処理フロー(先走り加算)
check_and_reserve_budget(budget_usd) # ここでローカル台帳に +$0.01 してしまう
try:
    response = send_post_request(body)
except HTTPError:
    # リクエストは失敗したが、台帳は既に加算済み
    raise

APIの公式仕様上、HTTP 403 などで受理されなかったリクエストは課金対象外(課金カウントされない)である。しかしスクリプト側は「送信を試みた」時点で予算を消費済みとして計上していたため、エラーが連続するとローカル台帳の見積もり額だけが先行して跳ね上がり、実際にはAPI側の利用枠やデポジットが余っているにもかかわらず「予算上限に達しました」としてスクリプトが自己停止してしまう不整合が生じた。

課題2: レスポンス本文だけでは403の理由が特定できない

HTTP 403 Forbidden が返された際、例外ハンドラが取得していたのはレスポンスの本文(Body)だけだった。

{"title": "Forbidden", "type": "about:blank", "status": 403, "detail": "You are not allowed to create a post with these credentials."}

このエラーメッセージだけでは、以下のどれが真の原因なのかを客観的に判定できない。

  1. OAuth 権限の不足: 取得したAPIキーが Read 権限のみで、Write 権限が付与されていない。
  2. アカウント側の制限: アカウント自体が一時的な書き込み制限やロックを受けている。
  3. 時間あたりの制限超過: 24時間あたりのユーザー投稿枠(User Limit)を使い切っている。
  4. アプリ単位のレート制限: アプリケーション全体のクォータ(App Limit)が枯渇している。

実際に調査した際、管理画面でキーを再生成すべきか、アカウントの警告を確認すべきか、あるいは単に枠のリセットを待つべきかが即座に判断できず、原因特定に余分な時間を要した。

解決策の実装

これらの問題を解消するため、スクリプトの予算管理フローとエラーハンドリングを全面的に見直した。

1. 予算チェックと加算のライフサイクル分離

予算管理を「事前確認(check_budget)」と「事後確定(reserve_request)」の2段階に明確に分割した。

def check_budget(budget_usd: float) -> None:
    """投稿を投げる前に上限を確認する。加算はしない。"""
    usage = load_usage()
    next_cost = float(usage.get("estimated_cost_usd", 0.0)) + PRICE_PER_REQUEST_USD
    if next_cost > budget_usd + 1e-9:
        raise RuntimeError(f"予算上限に達しました: 次回見積 ${next_cost:.2f} > ${budget_usd:.2f}")


def reserve_request(budget_usd: float) -> dict:
    """APIが投稿を受理した後にだけ呼ぶ。失敗リクエストは公式仕様上、課金対象外。"""
    usage = load_usage()
    next_cost = float(usage.get("estimated_cost_usd", 0.0)) + PRICE_PER_REQUEST_USD
    usage["attempted_requests"] = int(usage.get("attempted_requests", 0)) + 1
    usage["estimated_cost_usd"] = round(next_cost, 4)
    usage.setdefault("events", []).append({"at": now_iso(), "estimated_cost_usd": PRICE_PER_REQUEST_USD})
    save_usage(usage)
    return usage

呼び出し側のメイン関数では、リクエスト送信前に check_budget() で上限到達の有無だけを検査し、リクエストが成功して有効な JSON レスポンスが返ってきた直後に reserve_request() を実行する順序に改めた。

# 改善後の実行順序
check_budget(budget_usd)              # 1. 上限超過の事前チェック(台帳は変更しない)
identity = verify_account(...)
media_id = upload_media(...)

try:
    with urllib.request.urlopen(req) as response:
        payload = json.load(response) # 2. リクエスト送信とレスポンス受信
except urllib.error.HTTPError as exc:
    # 失敗した場合は台帳を加算せずに例外を投げる
    ...

usage = reserve_request(budget_usd)   # 3. 成功が確定した段階で台帳を加算

これにより、通信エラーや認証エラー、HTTP 403 などの障害が発生しても、ローカル台帳の実績値が汚染されることがなくなった。

2. 診断ヘッダーを例外メッセージへ自動収集

HTTPError 発生時に、サーバーから返された HTTP レスポンスヘッダーの中から、調査に不可欠なプレフィックスを持つヘッダー群をフィルタリングして例外メッセージに結合する仕組みを導入した。

except urllib.error.HTTPError as exc:
    detail = exc.read().decode("utf-8", errors="replace")[:1000]
    
    # 403の切り分けにヘッダーが必須。
    # x-access-level が read-write なら権限ではなくアカウント側の問題、
    # x-user-limit-24hour-* が 0 なら枠切れと判定できる。
    hints = {
        k: v for k, v in exc.headers.items()
        if k.lower().startswith(("x-access-level", "x-rate-limit", "x-user-limit", "x-app-limit"))
    }
    raise RuntimeError(f"X API HTTP {exc.code}: {detail} / headers={json.dumps(hints)}") from exc

監視対象とした主要ヘッダーとその切り分け基準は以下の通りである。

収集対象ヘッダー意味403発生時の判断基準
x-access-levelトークンのアクセス権限read-only なら設定ミス(キー再発行)。read-write なら権限以外の問題
x-user-limit-24hour-remainingユーザー単位の24時間残枠0 ならアカウントレベルの投稿上限到達(翌日の枠回復待ち)
x-app-limit-24hour-remainingアプリ全体の24時間残枠0 ならアプリ全体の制限(クォータ拡張または他スクリプトの抑制)
x-rate-limit-remaining15分窓等のレート制限残枠0 なら短時間のバーストによる一時的ブロック(待機後に再試行)

実際にこの改修を入れた直後のテスト調査において、該当リクエストの x-access-levelread-write と明示されていることがヘッダーから即座に確認でき、トークンの権限設定に疑念を持って不要な再発行を繰り返す迷走を完全に回避できた。

運用してみてわかったこと

外部APIとの連携コードを書く際、正常系の処理に集中するあまり、エラーハンドリングが「例外をキャッチして本文をダンプするだけ」になりがちである。

しかし、以下の2点については設計段階から明確に意識しておく必要がある。

  1. 台帳加算のトランザクション境界: リクエストの試行と結果の確定は異なる。公式仕様で課金されないエラーリクエストを先走って計上してしまうと、自動化パイプラインが自己矛盾で停止する原因となる。
  2. ヘッダーは本文以上に雄弁なエラー情報を持つ: モダンなWeb APIにおいて、認可情報やクォータ残数は本文ではなくHTTPヘッダーに格納されるケースが多い。例外ログにヘッダーの診断スナップショットを含めておくことで、外部ダッシュボードにログインして確認する手間を省き、エラーログの1行だけで次の一手を決定できる。

更新履歴

  • 2026-09-05: 初版起稿。X API 403調査における予算確定分離と診断ヘッダー保存設計を記録。