Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

supabase-jsのtry/catchは死んでいる。version凍結の根拠をerrorごと捨てていた

公開 読了時間 約6分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

supabase-jsは通信/PostgREST失敗でもthrowせずdata:null,errorを返すため、dataだけ見るtry/catchのcatch節は発火せず、一過性の読取失敗と行が無いことを区別できない。

結論

supabase-js のクエリメソッドは通信/PostgREST 失敗でも throw しない。 契約書テンプレートの現行版解決層は、この戻り値の error を見ずに data の有無だけで分岐しており、一過性の読取失敗を「まだ発行していない(行が無い)」と区別できず、version 凍結の根拠として使うと同意していない古い版を凍結しうる状態だった。直し方は、読取を専用関数へ切り出して error を明示的に検査し、失敗時は null(凍結しない)を返す fail-closed にすること。

症状になりうる状態

対象は kimiteras-portal の契約書テンプレート解決層(contract-template-store.ts)。広告主規約はコード同梱の JSON(v1・ベースライン・不変)が初期状態で、運営が画面から改定を発行すると DB の contract_template_versions に新版(v2 以降)が積まれる。「DB にあればそれ、無ければベースライン」という切替を、本文を返す getContractTemplate と、版番号・自動更新条項の有無などのメタを返す getContractMeta の2つが担っていた。

修正前の getContractMeta はこう書かれていた。

export async function getContractMeta(
  kind: string
): Promise<ContractMeta | null> {
  try {
    const admin = createSupabaseAdminClient();
    const { data } = await admin
      .from("contract_template_versions")
      .select(SELECT)
      .eq("kind", kind)
      .eq("is_current", true)
      .maybeSingle();
    if (data) return metaOf(data as Row);
  } catch (e) {
    console.error("[contract-template] current meta read failed:", e);
  }
  return getContractMetaBaseline(kind);
}

maybeSingle() の戻り値から data しか受け取っておらず、error は分割代入すらされていない。読取が失敗しても datanull になるだけで例外は投げられないため、datanull になる理由が「まだ版を発行していない(行が無い)」のか「一過性の読取失敗」なのかを区別せず、どちらも同じ return getContractMetaBaseline(kind)(ベースライン v1 のメタ)に落ちていた。

契約の署名フローでは freezeSignedTermsSourceVersion がこの getContractMeta の戻り値で signed_terms_source_version を確定し、以後 applyAutoRenewFromSignedTerms がその版の has_auto_renew_clauseauto_renew を立てるかを判定する。ベースライン v1 には自動更新条項があり、運営が改定した現行版(例えば v3)には無い、という状態がありうる。読取が一過性に失敗するたびに、実際に顧客が読んで同意した版ではなく v1 が凍結先として使われ、契約上の根拠なく自動更新が立ちうる経路になっていた。

原因

supabase-js のクエリメソッドは Promise を reject しない。ネットワークエラーも PostgREST のエラー応答も、同じ {data, error} という戻り値の形で表現される。呼び出し側が data だけを見て error を見なければ、失敗は「データが無かった」という正常系の一種として静かに扱われる。try/catch は JavaScript の例外機構であり、投げられない失敗を捕まえることはできない。catch 節が書かれていること自体が、この関数が失敗を処理しているという誤った安心感を生んでいた。

直し方

現行版の読取を readCurrentRow という専用関数へ切り出し、error を明示的に検査して {row, failed} を返すようにした。

async function readCurrentRow(
  kind: string
): Promise<{ row: Row | null; failed: boolean }> {
  try {
    const admin = createSupabaseAdminClient();
    const { data, error } = await admin
      .from("contract_template_versions")
      .select(SELECT)
      .eq("kind", kind)
      .order("version", { ascending: false })
      .limit(1)
      .maybeSingle();
    if (error) {
      console.error("[contract-template] current read failed:", error.message);
      return { row: null, failed: true };
    }
    return { row: (data as Row | null) ?? null, failed: false };
  } catch (e) {
    console.error("[contract-template] current read threw:", e);
    return { row: null, failed: true };
  }
}

getContractMeta はこの failed フラグで分岐する。

export async function getContractMeta(
  kind: string
): Promise<ContractMeta | null> {
  const { row, failed } = await readCurrentRow(kind);
  if (row) return metaOf(row);
  if (failed) return null;
  return getContractMetaBaseline(kind);
}

読取に失敗した(failed=true)場合は null を返し、version 凍結そのものをしない fail-closed にした。呼び出し元の freezeSignedTermsSourceVersionmetanull なら何もせず戻る作りなので、この変更だけで「一過性の失敗のたびに間違った版が凍結される」経路が閉じる。行が存在しない(未発行)場合と読取が失敗した場合を区別できるようになって、初めて「ベースラインへ落とすのは本当に未発行のときだけ」という意図どおりの分岐になった。

一方、本文を返す getContractTemplate のほうは、読取に失敗しても引き続きベースラインへ退避する。「本文が出せないと申込・契約の画面自体が止まる」という別の trade-off を優先しており、失敗時に null を返す getContractMeta とは意図的に非対称になっている。

教訓

この関数はコミット時点で try/catch を書いていた。書いた本人には失敗を処理しているつもりがあったはずで、catch 節のログ出力まで用意されている。だが supabase-js は失敗を例外ではなく戻り値で表現するため、data だけを分割代入した時点で error は静かに捨てられ、catch 節は永久に発火しないデッドコードになっていた。この関数が「version 凍結の根拠」という課金に直結する用途に使われていたことが、この見落としを軽くない欠陥にしていた。

よくある質問

Q1なぜtry/catchでは守れないのですか?

supabase-jsのクエリメソッドはPromiseをrejectしない設計で、通信断やPostgRESTのエラーも例外を投げずに{data:null,error}という戻り値で表現する。dataだけを見てerrorを見なければ、catch節はそもそも発火せず、失敗は握りつぶされたまま処理が先に進む。

Q2具体的に何が誤動作していたのですか?

契約書テンプレートの現行版を読むgetContractMetaは、読取に失敗してもerrorを見ずにベースラインv1のメタ情報へフォールバックしていた。署名時にこの戻り値でsigned_terms_source_versionを凍結する処理があり、一過性の失敗のたびに実際の現行版(例:v3)ではなくv1が凍結されうる状態だった。

Q3v1として凍結されると何が起きますか?

v1は自動更新条項ありのベースラインで、v3は自動更新条項なしの現行版だった。v3に同意した契約がv1として凍結されると、その版のフラグに基づいてauto_renewが立ち、契約上の同意が無い年次自動更新課金の経路になりうる。

Q4本番で実際に誤課金は発生したのですか?

根拠のコミットメッセージはレビューで発見し修正したことを記録しているだけで、本番で実際にv1として凍結され誤課金が発生したという記録は無い。

Q5この不具合の直し方は何ですか?

現行版の読取をreadCurrentRowという専用関数に切り出し、errorを明示的に検査して{row,failed}を返すようにした。getContractMetaは読取に失敗したときfailedがtrueならnullを返して版凍結そのものをしないfail-closedにし、成功時のみベースラインへフォールバックするよう分岐を変えた。

確認した環境

  • Next.js 16.2.7 / @supabase/supabase-js ^2.106.2 / @supabase/ssr ^0.10.3
  • 2026-07-24 のレビュー指摘対応で発見・同日中に修正(本番での誤課金の記録は無し)

この記事の根拠

  • TypeScriptファイル 68〜85行目コミット 51bbdae
  • TypeScriptファイル 38〜65行目コミット 0a88871
  • TypeScriptファイル 86〜98行目コミット 0a88871

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。