マーケ自動化は「1週間後」に静かに止まる — OAuth トークン失効を前提にした設計と切り分け
連携直後は動くのに、約1週間後に全件失敗する——マーケ自動化が典型的に止まる原因は OAuth トークンの失効です。Google の Testing 状態では refresh token が7日で切れるという落とし穴から、「資格情報は必ず失効する」前提の設計チェックリストと、堂々巡りを断つデバッグの切り分けまで一次経験で示します。
by Shin
昨日まで動いていた自動化が、今朝から全件失敗している——何が起きたのでしょうか。まず疑うべきはコードではありません。資格情報が寿命で死んだ可能性が最も高い。しかもこれは運が悪かったのではなく、再現性のある「典型死」で、設計で防げます。本記事は、GA4・カレンダー・CRM のような外部連携をもつマーケ自動化がたどる典型的な死に方のメカニズムと、「資格情報は必ず失効する」を前提にした設計チェックリスト、そして障害時に堂々巡りしないためのデバッグの切り分けを、一次経験(実際に約1週間後の全滅を踏みました)から書きます。
自動化の「作り方」は 実践ガイド に、失敗を検知する fail loud の作法は GA4 週次レポートの記事 に書きました。本記事はその続きで、構築後の時限爆弾——認証の寿命に特化します。
「1週間後に止まる」メカニズム
代表例を解剖します。Google カレンダー連携の個人開発で、OAuth でユーザー連携し、保存した refresh token で定期同期する自動化を作りました。連携直後は快調で、十数件の同期がすべて成功。そして約1週間後、全件が一律に失敗し始めました。token endpoint が返していたのはこれです。
{ "error": "invalid_grant", "error_description": "Bad Request" }原因はコードのどこにもありませんでした。Google Cloud の OAuth 同意画面(現在の UI では「対象」/ Audience)の公開ステータスが Testing のままだったのです。Google の公式ドキュメントは明記しています——外部ユーザー向けの同意画面が Testing 状態のプロジェクトには、7日で失効する refresh token が発行される、と(機微でないスコープのみの場合等の例外を除く)。開発中は毎日触っていて7日を超えないので気づかず、「完成」して放置した瞬間にカウントダウンが始まる。「連携直後は動くのに約1週間後に死ぬ」という時間差は、この仕様から機械的に導かれる帰結でした。
この止まり方が厄介なのは、3つの性質が重なるからです。
- 時間差がある。 壊した変更の直後ではなく、何も変えていない7日後に死ぬ。「直近のデプロイ」を疑う通常のデバッグ習慣が空振りします。
- 全件同時に止まる。 資格情報はすべてのリクエストの共通前提なので、失効した瞬間に成功率が100%から0%になります。徐々に劣化しないため、前兆もありません。
- 気づきにくい。 マーケ自動化のバッチは夜中に走ります。通知が fire-and-forget で握り潰されていれば(あるいは「0件処理・正常終了」で完走していれば)、気づくのは数日〜数週間後、「そういえば最近レポートが来ていない」と思ったときです。
対策は2段あります。この代表例に限れば、恒久対策は同意画面を In production に公開することです(Testing の7日制限が外れます)。しかしそれは Google のこの仕様に対するパッチにすぎません。本質的な対策は、前提を反転させることです。
資格情報は必ず失効する — 前提の反転
refresh token は、Testing の7日以外にも失効します。Google の場合、公式に列挙されている失効条件だけで、ユーザーによる取消・6ヶ月間未使用・Gmail スコープを含むトークンでのパスワード変更・発行数上限の超過などがあります。API キーは失効しない代わりに漏洩時のローテーションで無効になり、サービスアカウント鍵も組織ポリシーやローテーション運用で入れ替わります。つまりどの方式を選んでも、「一度繋いだら永続」は存在しません。
| 資格情報 | 典型的な寿命 | 主な失効・無効化の条件 |
|---|---|---|
| access token | 約1時間 | 短命が仕様。refresh token で都度取り直す前提 |
| refresh token(Testing) | 7日 | 同意画面の公開ステータスが Testing |
| refresh token(Production) | 無期限(ただし条件つき) | 取消・6ヶ月未使用・パスワード変更(Gmail スコープ)・上限超過 |
| API キー | 無期限 | ローテーション・漏洩時の無効化 |
| SA 鍵 | 無期限 | 鍵ローテーション・組織ポリシー・委任設定の変更 |
だから設計の前提を反転させます。失効は異常系ではなく、いつか必ず来る定常イベントです。「失効したらどうするか」を障害対応ではなく仕様として最初から書いておく——次の4点です。
設計チェックリスト — 最初から組む4点
① 失効を「状態」として持つ(needs_reauth フラグ)
認証エラーを検知したら、連携レコードにフラグを立てて永続化します。エラーログに1行流すだけでは、流れて消えて終わりです。状態として持てば、UI にもレポートにも「この連携は要再認証」と出せます。
def mark_needs_reauth(con, connection_id: int, detail: str) -> None:
con.execute(
"update connections set needs_reauth = 1, "
"last_error_at = datetime('now'), last_error = ? where id = ?",
(detail, connection_id),
)
def clear_reauth(con, connection_id: int) -> None:
# 再認証・同期成功で必ず下ろす(下ろし忘れると「直ったのに警告が出続ける」)
con.execute(
"update connections set needs_reauth = 0, last_error = null where id = ?",
(connection_id,),
)② 再認証の導線を最初から作る
失効からの復旧には、必ず人間の操作(再ログイン・再同意)が挟まります。そのとき人間に何をしてもらうかを、失効が起きる前に決めて実装しておきます。最低限は、設定画面に接続状態(正常/要再認証)を表示し、needs_reauth のときに「再連携」ボタンと理由を出すこと。できれば本人への通知(メール・Slack 等)まで。最大の失敗は「気づけない」ことなので、導線の良し悪しがそのまま停止時間の長さになります。
③ 認証失敗とデータ失敗を区別する
バッチで全件を一律 failed に丸めると、原因調査が毎回ゼロからになります。認証エラー(401/403・invalid_grant 系)は「資格情報の問題」で、リトライしても直らず、対処は再認証。個別の 400 は「そのデータの問題」で、該当件だけスキップして続行できる。5xx は一時障害で、リトライ対象。扱いも通知先も違うので、分類してから記録します。
AUTH_ERROR_CODES = {"invalid_grant", "invalid_client", "unauthorized_client"}
def classify_failure(status: int, body: dict, url: str) -> Failure:
"""auth(再認証が必要) / data(該当件のみスキップ) / transient(リトライ) に3分類する。"""
error_code = body.get("error") if isinstance(body.get("error"), str) else (
(body.get("error") or {}).get("status", "")
)
# token endpoint で落ちた時点で認証系(API 本体に到達していない)
if "/token" in url and status >= 400:
return Failure("auth", f"token endpoint {status}: {error_code}")
if status in (401, 403) or error_code in AUTH_ERROR_CODES:
return Failure("auth", f"{status}: {error_code}")
if 400 <= status < 500:
return Failure("data", f"{status}: {error_code}")
return Failure("transient", f"{status}: {error_code}")error の取り出しが二形態あるのは、Google の場合 token endpoint は {"error": "invalid_grant", ...}(文字列)、API 本体は {"error": {"status": "PERMISSION_DENIED", ...}}(オブジェクト)と形が違うためです。この分類器は手元で両形態のレスポンス例に対して動作検証済みです。
④ バッチは fail loud — 認証失敗で即座に非0で終了する
分類の結果が auth なら、その場でジョブ全体を非0で終了させます。認証が死んでいる状態で残りを回しても全件失敗するだけで、最悪なのは「0件処理・正常終了(exit 0)」で完走して緑のままになることです。
failure = classify_failure(status, body, url)
if failure.kind == "auth":
mark_needs_reauth(con, connection_id, failure.detail)
print(f"[fatal] credential expired — re-auth required: {failure.detail}", file=sys.stderr)
sys.exit(2) # データ失敗(1)と exit code を分けると、通知の振り分けが楽になる非0で終了しさえすれば、GitHub Actions でも cron でも標準の失敗通知に乗ります。ここの詳細——「200 だが空」問題やログ・リトライの3点セット——は GA4 週次レポートの記事 に書いたとおりで、本記事の①〜③はその3点セットの上流(そもそも何が失敗したのかの分類)にあたります。
デバッグの切り分け — 堂々巡りを断つ順序
設計で防いでいても、初見の失効障害は必ず来ます。そのとき堂々巡り(ログを1行足して再デプロイして再実行して……の無限ループ)に入らないための順序が3つあります。
- どのエンドポイントが落ちたかを最初に見る。 API 本体(
/v1beta/...や/calendar/v3/...)ではなく token endpoint(oauth2.googleapis.com/token)が落ちているなら、原因は認証であってリクエスト内容ではありません。失敗した URL の確認だけで、切り分けの大半が終わります。 - 機械可読の
errorを読む。error_descriptionは当てにならない。 冒頭の実例がまさにそうで、error_descriptionは "Bad Request" という無意味な文言でした。本質はerror: "invalid_grant"の方です。エラー抽出のコードも、description ではなくerror(API 本体ならerror.status)を優先させます。人間向けの説明文はバージョンや言語で揺れますが、機械可読コードは仕様です。 - 失敗の分布を見る。「全件が一律に失敗」は環境・認証要因のサイン。 データ起因なら成功と失敗が混在するのが普通です。全滅なら資格情報・スコープ・設定をまず疑い、個別データの中身を掘るのは後回しにします。逆に一部だけ失敗なら、その件のデータを見ます。
この切り分けは、Claude Code に障害ログを渡して調べさせるときの指示にもそのまま使えます。順序を指定しないと、目についたスタックトレースの深掘りから始めて堂々巡りを再演しがちです。私は次の型で渡しています。
このバッチが今朝から全件失敗している。ログを添付する。次の順で切り分けて:
1. 失敗しているリクエストの URL を列挙し、token endpoint か API 本体かを分類する
2. レスポンス body の機械可読 error コード(error / error.status)を抽出する。
error_description は参考程度に
3. 失敗が全件一律か一部かを確認する
4. ここまでの結果から「認証・環境要因」か「データ要因」かの仮説を立て、
確認コマンドを提案する。コードの修正はまだしないポイントは最後の1行です。切り分けが終わる前に修正を書かせない——原因が同意画面の設定(コード外)にあるとき、コードをいくら「修正」しても直らないからです。
サービスアカウントという選択肢
ここまでの前提を、構造ごと減らせる場面があります。ユーザー本人の同意が本質的に不要な自動化——自分自身・自社のデータを定期取得するバッチ——なら、ユーザー同意フロー(3-legged OAuth)ではなくサービスアカウント(2-legged)が使えます。SA は同意画面を通らないため、Testing の7日失効も、ユーザー操作による取消も、6ヶ月未使用の失効も、構造的に存在しません。GA4 の定期レポートを SA で組んだのが GA4 週次レポートの記事、ドメイン全体委任と組み合わせてフォーム通知メールを送っているのが 問い合わせフォームの記事 です。個人・小規模チームのマーケ自動化は、実はかなりの割合がこちらで済みます。
まとめ — 「動いた」は完了条件ではない
連携直後の「動いた」は、7日後・6ヶ月後に動いている保証を何も含みません。完了条件を「いま動いた」から「失効しても気づけて、復旧手順が決まっている」に置き直すのが、この記事の要点のすべてです。
- OAuth 同意画面の公開ステータスを確認した(Testing のまま「完成」にしていない)
- 使っている資格情報それぞれの失効条件を一度は公式ドキュメントで確認した
- 失効を
needs_reauthのような状態として持ち、成功時に下ろしている - 再認証の導線(表示・通知・人間がやる操作)が失効前から存在する
- 認証失敗とデータ失敗と一時障害を分類して記録している
- 認証失敗でバッチが即座に非0で終了する(0件完走しない)
- 障害時の切り分け順序(endpoint → 機械可読 error → 失敗の分布)がチームの型になっている
- ユーザー同意が不要な自動化はサービスアカウントを検討した
一人のマーケターが自動化を「作る」ことと「運用し続ける」ことの間には、この記事ぶんの距離があります。それでも一人で持ちきれる範囲であることは、対になる なぜ今、マーケチーム1人でも「実装」で戦えるのか に書いたとおりです。作る側の全体設計は 実践ガイド、問い合わせ配管側の監視は マーケ運用の自動化に n8n は必要か ― API × Claude Code で組み直す をどうぞ。