前提: id のポーリングは「確実に動く手段」だったはず

以前、XserverへのSSHをやめてCloudflare Workersへ移行したで書いたとおり、デプロイ完了の検知には wrangler deployments status --json が返す deployment id を使っていました。

Deploy Hook を叩く前に id を記録し(baseline)、叩いた後に同じコマンドをポーリングして id が変わるのを待つ。レスポンス形状の勘違いや権限テンプレートの罠を踏んだ末にたどり着いた、実測で確認済みの方法でした。当時はこれを「確実に動く手段」と書きました。

id が変わったら、そのままX告知を投稿する処理を組みました。 検知できたのだから公開は終わっている、という前提です。

症状: url_status: 404 を出力しながら投稿していた

ある日の自動投稿でOGPカードが表示されませんでした。実行ログを見ると、原因は一目瞭然でした。

url_status: 404
⚠ URL状態が200ではありません(status=404)。CDN/Cloudflareのbot遮断の可能性もあるため、
このまま投稿を続行します。
[OK] 投稿しました tweet_id=...

404だと分かっていながら投稿していました。 手動実行(事前にページを目で確認してから流した回)では url_status: 200 になっていたので、自動実行だけがこの状態でした。

真因: id の変化は「アクティブになった」だけだった

実行時刻を並べると、原因は3秒に収まっていました。

時刻(UTC)出来事
22:02:00新しい deployment id を検知
22:02:03X へ投稿

deployment id が変わることと、そのデプロイがエッジまで反映されていることは別でした。id はコントロールプレーン側の状態で、変わった瞬間には世界中のエッジには届いていません。3秒という短い間隔がそれを可視化しました。

もし反映に数秒かかっただけなら、次に投稿を試したときには直っていたはずです。ところが直りませんでした。

なぜ後から200になっても直らないのか

Xはカードを投稿された瞬間にクロールします。そのとき404を受け取ると、その結果をキャッシュします。ページが数秒後に200になっても、Xは再クロールしに来ません。カードなしの投稿は、その投稿である限り直らない状態で固定されました。

もう1つ、これを見逃した理由があります。404を警告として扱っていたことです。

if status != 200:
    print(f"  ⚠ URL状態が200ではありません(status={status})。CDN/Cloudflareの"
          f"bot遮断の可能性もあるため、このまま投稿を続行します。")

このガードは元々、別の問題への対処でした。CloudflareがGitHub Actions runnerのIPをbot遮断して403を返すことがあり、runnerから見た非200が必ずしも「未公開」を意味しないため、ハード失敗にしていませんでした。

404と403を同じ「非200」として一括りにしていたのが穴でした。 403はbot遮断の疑いで警告に留めるべきですが、404は「そのページがまだ存在しない」という別種の情報です。区別せずに投稿を続けたので、本当に未公開のときも通り抜けていました。

是正: 実URLが200になるまで待ち、404は投稿を止める

2点直しました。

1. 実URLのポーリングを追加する。 deployment idではなく、記事の実URLそのものが200を返すまで待つようにしました。

def wait_until_published(url, attempts=10, interval=15):
    for i in range(1, attempts + 1):
        status = check_url_status(url)
        if status == 200:
            return status
        print(f"  公開待ち {i}/{attempts}: status={status}")
        if i < attempts:
            time.sleep(interval)
    return status

id の変化を「たぶん終わっている」の代理指標として使うのをやめ、確認したいことをそのまま確認する形にしました。回数と間隔は環境変数で調整できるようにしてあります。

2. 404/410はハード失敗にする。 403だけを警告のまま残しました。

if status in (404, 410):
    sys.exit(f"[FATAL] 記事URLが未公開です(status={status}): {url}")
if status != 200:
    print(f"  ⚠ URL状態が200ではありません(status={status})。"
          f"CDN/Cloudflareのbot遮断の可能性もあるため、このまま投稿を続行します。")

「非200」は1種類ではありませんでした。 「まだ存在しない」と「存在するが見えないふりをされている」を同じ扱いにしていたことが、そもそもの設計ミスでした。

ワークフロー側にも一呼吸置きました。id を検知した直後に20秒待ってから次の処理へ進みます。実URLのポーリングと合わせて二重の安全弁にしています。

テストで一度、空振りした

是正した後、これで直ったことを確認しようとして、最初に用意した検証は空振りでした。既に投稿済みの記事のURLで試したので、投稿処理そのものに到達する前に「投稿済みなのでスキップ」という別の分岐に入り、404のハード失敗を一度も通っていませんでした。

未投稿の記事のURLに変えてやり直すと、狙いどおり「公開待ち」のログを出しながら404を検知し、投稿せずに止まりました。「テストが通った」を見た瞬間、それが本当に狙った経路を通ったのかを確認する必要があります。 通った理由が別の分岐だったら、直したはずの穴はまだそこにあります。

教訓: 検知に使う指標と、確認したい状態を混同しない

deployment idのポーリングは、それ自体は正しく動いていました。「デプロイが受理されたか」を知るには十分な指標でした。

問題は、知りたかったことが違った点です。知りたかったのは「利用者から見てそのページが200を返すか」で、id はその代理にすぎませんでした。代理指標と実指標の間には、今回のように数秒でも埋まらない溝ができることがあります。

「確実に動く手段」という評価も、それ自体は間違っていませんでした。id のポーリングは確実に id の変化を検知します。確実だったのは検知であって、検知した先に起きていることの保証ではありませんでした。

追記(2026-08-17): 代理指標そのものを撤去しました

この記事を書いたあと、id のポーリングは二重の安全弁として残していました。それを撤去しました。きっかけは別の障害です。

2026-08-14 から3日間、この自動告知が毎日失敗していました。エラーはこれです。

A request to the Cloudflare API
(/accounts/<account_id>/workers/scripts/<script>/deployments) failed.
  Authentication error [code: 10000]

wrangler がJSONを返さないので、その出力を受け取る後続処理が例外で落ち、X告知もIndexNow通知も丸ごと止まっていました。サイトの配信もビルドも正常で、止まっていたのは告知だけです。

切り分けると、トークンは失効していませんでした。

呼び出し結果
GET /user/tokens/verify200 / status: active
GET /accounts200 / 0件
GET /accounts/{id}403 / 9109 Unauthorized to access requested resource

トークンは有効なまま、到達できるリソースがゼロになっていました。 失効なら分かりやすいのですが、active のまま権限だけが消えると「認証は通っているのに何も読めない」という状態になります。

ここで、この記事の結論をそのまま適用しました。id のポーリングは代理指標です。本当に確認したいのは実URLの200で、それは既に wait_until_published() が見ています。代理指標を残すために資格情報を1つ持ち続け、その資格情報が腐ると告知が丸ごと止まる——確認したい状態を直接見られるなら、代理指標は残す価値より壊れる余地の方が大きいという判断です。

やったことは3つです。

  • wrangler deployments status のポーリングを削除し、Deploy Hook を叩いたら直ちに告知処理へ進む
  • 実URLの待ち時間を150秒から10分へ延長する(ビルド所要は実測76秒。従来はポーリングがこの時間を吸収していました)
  • Deploy Hook のレスポンスが "success":true でなければ即座に失敗させる(起動できていないなら、いくら待っても公開されないため)

これで、この経路からCloudflareのAPIトークンが不要になりました。

失ったものもあります。**新記事が無い日はビルド失敗を検知できなくなりました。**告知処理は「本日公開の記事なし」で即終了し、URLを待たないためです。ここは告知の正しさとは別の関心事(外形監視の領分)だと整理しました。

まとめ

  • deployment idの変化は「デプロイが受理された」ことしか示しません。エッジへの反映完了は別の事実で、数秒のずれが実害になります
  • Xはカードを投稿時点でクロールし、404を受け取るとキャッシュします。後からページが直っても、その投稿のカードは直りません
  • 「非200」を一括りにしないでください。403(bot遮断の疑い)と404(未公開)は別の意味を持ちます
  • 代理指標(id の変化)で確認した気になっていないか、本当に確認したい状態(実URLの200)を直接見ているかを分けて考えてください
  • 修正の検証では、テストが通った理由が狙った経路かどうかを確認してください。私は一度、別の分岐で空振りしました
  • (追記)実指標を直接見られるようになったら、代理指標は残さず消す方が安全です。残せば資格情報を持ち続けることになり、その資格情報が腐ったときに機能ごと止まります