確認でカードを1枚消したら公開ページが1時間404に。Supabaseの一時的な504を捨てていたのが原因でした

自分の発信を1か所に集めるハブサイト「Hubpin」を、個人で作りました。
制作の経緯は「Hubpin 開発記録」に時系列でまとめています。
Claude Code を取り入れながら、設計から実装まで一人で進めています。

小さな修正を本番に出したあと、デモの動きを確かめていたときのことです。
確認のためにカードを1枚消した直後から、デモの公開ページが404になり、1時間戻りませんでした。
原因は、Supabase が一瞬だけ返したエラーを、ページ側のコードが捨てていたことでした。

Hubpin は Next.js(App Router)と Supabase で作っています。
同じ組み合わせで公開ページを作っていると、近い形が潜んでいるかもしれないので残しておきます。

目次

確認のためにカードを1枚消したら、公開ページが404になりました

その日は、デモ用データのリセット処理から余計なログ出力を外す PR(#60)をマージしました。
Hubpin のデモは、ログインするたびにカードが4枚に戻る作りです。

本番での確認は、ブラウザ操作ごと AI に任せていました。
4枚のままログインし直しても、リセットが本当に走ったかは見分けられません。
そこで AI がカードを1枚消して3枚にし、ログインし直して4枚に戻るのを確かめました。

確認そのものはうまくいきました。
ところが、その途中から /guest(デモアカウントの公開ページ)が404を返していました。
作者用の /matsutake は200のままです。

/guest は、Hubpin のトップにある「デモを見る」の行き先です。
GitHub の README からトップに来た人が、2クリックで踏む位置でした。

今は直さず、Issue を立てて原因から調べることにしました

AI からは、すぐ直すより先に原因を調べる案が出ました。
Vercel のログは長く残らないので、時刻や値はもう開発ログに写してある、とのことでした。

私もその案で進めることにして、Issue(#61)を立てました。
直し方は原因によって変わります。
推測で直して原因が別だったら、穴は塞がらないまま残ります。

分担は、ログを読んで切り分けるのが AI、直す手順書を書くのも AI、直すのは自分、という形を選びました。
手順書は、原因が分かってから書いてもらうことにしました。

ログを突き合わせると、2秒違いの作り直しが2回ありました

前提として、公開ページは ISR で配信しています。
ISR は、作ったページを一定時間保存しておき、期限が来たら作り直す仕組みです。
Hubpin では export const revalidate = 3600 と書いて、期限を1時間にしています。
カードを消したときは、その保存を捨てて作り直すようにしています。

AI に Vercel と Supabase のログを突き合わせてもらうと、こうなっていました。

カードを消した直後、ダッシュボードの「公開ページを見る」リンクの先読み(prefetch)が走っていました。
prefetch は、リンクが押される前に裏で行き先を読み込んでおく Next.js の動きです。
その先読みで、/guest の作り直しが2秒違いで2回起きていました。

時刻 作り直しの中身 結果
17:15:23 profiles → items と読んで 1.08秒 200
17:15:25 profiles で止まり、items に進まず 5.09秒 404 で上書き

最初の見立ては「先読みのときにユーザー名が渡っていないのでは」というものでした。
これは、2秒前に同じ条件の作り直しが成功していたことで外れました。

Supabase の API ログを見ると、2回目の profiles の読み取りだけが504を返していました。
前後の読み取りは200です。

GET /rest/v1/profiles?select=id,username,display_name,display_name_en,title&username=eq.guest
→ 504(x_sb_error_code: null)

なぜ2回作り直されたのか、なぜ Supabase が504を返したのかは、今も分かっていません。

504 が「ユーザーがいない」と同じ扱いになっていました

公開ページでプロフィールを読む getProfile は、もともとこう書いていました。

const getProfile = cache(async (username: string) => {
  const supabase = createPublicClient()
  const { data } = await supabase
    .from('profiles')
    .select('id, username, display_name, display_name_en, title')
    .eq('username', username)
    .maybeSingle()
  return data
})

supabase-js は、問い合わせが失敗しても throw しません。
失敗は error に入って返り、data は null になります。
このコードは error を受け取っていないので、504でも「行が無い」と同じ null を返します。

ページ側は null を見て notFound() を呼び、404を返します。
その404が、ISR の保存として1時間残りました。
数秒の障害が、保存の期限まで続く障害に変わっていたことになります。

実は、リポジトリの別のファイルにはコメントが残っていました。
「supabase-js はクエリのエラーで throw しない。error は必ず出す」という一文です。
その原則は、書いた場所以外では守られていませんでした。
公開ページが error を捨てている点も、以前の AI レビューで指摘を受けていました。
Issue #48 に残したまま、まだ手を付けていなかったところです。

放っておいた1時間で、期限切れ直後の1人目が見えました

直す前に、放っておいたらどう戻るかも見ておきました。
AI に2分おきに /guest の状態を記録してもらった結果です。

時刻 状態 x-vercel-cache
18:14:23 404 HIT
18:16:23 404 STALE
18:18:24 200 HIT

期限が切れたあとの最初のリクエストには、古い404がそのまま返っています(STALE)。
その裏で作り直しが走り、3秒後の18:16:26に200で保存されました。

404が配信されていたのは、17:15:30から18:16:26までの約61分です。
期限が切れても、最初に来た1人はまだ404を受け取ることになります。

「行が無い」と「読めなかった」を分けて直しました

直し方は、error があれば throw し、行が無いときだけ notFound() に進める形です。

const getProfile = cache(async (username: string) => {
  const supabase = createPublicClient()
  const { data, error } = await supabase
    .from('profiles')
    .select('id, username, display_name, display_name_en, title')
    .eq('username', username)
    .maybeSingle()
  // 「行が無い」と「読めなかった」を同じ null にしない
  if (error) throw new Error(`profiles の読み取りに失敗: ${error.message}`)
  return data
})

直す範囲は、AI に選択肢を出してもらって決めました。
選んだのは次の2つです。

  • Issue #48 のうち公開ページの分を先に1本目の PR にし、ダッシュボード側は2本目に分ける
  • カード(items)の読み取りに失敗したときも throw する

items をログに出すだけで済ませると、読めなかったときに「カード0枚のページ」が1時間残りえます。
それを避けたくて、throw のほうを選びました。

同じ理由で、ビルド時にユーザー名の一覧を作る generateStaticParams も throw にしています。
読めないまま空の配列を返すと、事前に作るページが0件のままビルドが通ってしまうためです。
CI のビルドでは /matsutake と /guest が事前に作られていて、普段は読めていることも確かめました。

<CardGrid items={items ?? []} /> の ?? [] も外しました。
throw のあとは items が null でないと TypeScript が分かります。
残しておくと「読めなかったら空で出す」という意図に読めてしまいます。

直ったかどうかは、テストの落ち方で見ました

手順書は、テストと差分ごと AI に書いてもらいました。
AI は作業用のコピーで実際に流して、修正前に出るエラーメッセージまで手順書に載せていました。

テストは3本です。

  • profiles が読めなかったら throw する(404にしない)
  • profiles に行が無ければ notFound になる
  • items が読めなかったら throw する(カード0枚のページにしない)

真ん中の1本は、修正の前後で結果が変わってはいけない側です。
throw に寄せすぎて、本当にいないユーザーまでエラーにしていないかを見ています。

テストの中で notFound を差し替えるときは、本物と同じく throw するようにしてあります。
throw しない偽物だと、本番とは違う流れを試すことになるためです。

翌朝、ブランチを切るところまでは自分で打ちました。
その先のテストを置いて流す操作と、差分を入れる操作は AI に任せています。
修正前に流すと、手順書に書いてあったとおりの理由で2本が落ちました。

AssertionError: expected [Function] to throw error including 'profiles の読み取りに失敗' but got 'NEXT_NOT_FOUND'
AssertionError: promise resolved "{ …(10) }" instead of rejecting
Tests  2 failed | 1 passed (3)

1つ目は、読めなかったときに404へ流れている、本番で起きたのと同じ流れです。
2つ目は、items が読めなくてもページを返してしまっている、という意味です。
修正後は3本とも通り、全体の55本も通りました。

push と PR の作成は内容を確認してから出してもらい、マージは自分でしました。

マージ後に /guest を開くと200でした。
ただ、これは新しいデプロイのビルドで作られたページ(PRERENDER)が返っているだけです。
期限が切れても、デプロイし直しても200には戻るので、修正が効いた証拠にはなりません。
効いたと言えるのは、修正前に期待した理由で落ちて、修正後に通ったことのほうでした。

テストは、赤くなったかどうかより、どのメッセージで落ちたかを読むようにしています。
以前、わざと壊したテストが別の理由で赤くなっていたことがあったからです。
そのときの話は「テストをわざと壊して赤くなったのに、何も検証できていなかった」に書きました。

まだ確かめられていないこと

いちばん気になっているのは、throw したときに直前の成功版が残るかどうかです。
Next.js 16.3 に同梱のドキュメントには、作り直しでエラーが出たら最後に成功した版を出し続ける、と書かれています。
ただ今回は、カードを消して保存を捨てた直後の作り直しでした。
そのとき残る版があるのかは分かりません。
わざと起こす手段がないので、次に Supabase が失敗したときのログで分かることになりそうです。

ダッシュボード側を直した2本目の PR は、修正前に落ちるテストを書いていません。
ダッシュボードは毎回その場で描画するので、こちらは throw せずログに残す形にしました。
throw するとヘッダーの表示ひとつのためにダッシュボード全体が開けなくなるからです。
失敗したときの経路は、まだ確かめていません。

1行の書き方で、数秒の障害が1時間になっていました

error を受け取らない1行は、普段は何も起きません。
今回も、確認のためにカードを消したタイミングと、Supabase の一時的な504が重なって、たまたま見つかりました。

ISR の上では、「0行」と「読めなかった」を同じ null にすると、数秒の障害が保存の期限まで伸びます。
読めなかったら throw して、notFound() は本当に無いときだけにする。
直ったかどうかは本番の200では判断できず、修正前のテストがどう落ちるかで見る。
この2つは、次に Supabase の読み取りを書くときにも見ていきたいと思います。

目次