Next.js server actionでerrorを握りつぶし、保留失敗を保留成功と誤通知した
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
server action の DB 更新が `error` を捨てて `{data}` の有無だけで成否を判定すると、一過性障害で更新に失敗した場合でも、顧客向け画面と社内 Slack 通知がそれぞれ異なる理由を勝手に断定し、互いに食い違ったまま表示され続ける。
結論
Next.js の server action で Supabase の DB 更新結果から error を捨て、{data} の有無だけで成否を判定していました。 その結果、DB の一過性障害で保留 UPDATE が失敗しても、顧客向け画面は「保留しています」と表示し続け、社内 Slack には「既に署名済のため保留していません」という逆方向の誤った断定が飛ぶ状態になっていました。2026-09-16 のデプロイ前レビューで発見し、同日中に修正しています。本番でこの経路が実行された記録はありません。
症状
修正前のコードは、署名保留の UPDATE をこう受けていました。
const { data: heldRow } = await admin
.from("contracts")
.update({ review_status: "レビュー中" })
.eq("id", link.contract_id)
.or("application_status.is.null,application_status.neq.署名済")
.select("id")
.maybeSingle();
const held = !!heldRow;
error は分割代入の対象にすら入っておらず、そのまま捨てられています。held は heldRow が truthy かどうかだけで決まるため、「更新対象の行が無かった(=既に署名済)」と「更新自体が失敗した(=DB障害)」が同じ false に潰れて いました。
この held は2箇所で使われます。顧客向けページでは、
{!isSigned && (
<>
<br />
ご返信まで、このページでのご署名は保留しています。
</>
)}
isSigned(署名済かどうか)だけを根拠に保留中の告知を出しており、実際に保留 UPDATE が成功したかどうかは見ていません。社内向け Slack 通知では、
(held
? "⏸ *署名を保留しました。* 運営が承認するまでお客様は署名できません(覚書で合意 → 承認)\n"
: "※ この契約は既に署名済のため、保留していません(成立後のご相談)\n")
held が偽なら無条件に「既に署名済のため保留していません」と断定していました。DB 障害で heldRow が取れなかった場合も、この文言がそのまま送られます。
原因
このページは「規約修正や特約を希望する相手からの事前相談」を受け付けており、保留に成功すれば署名前に運営の確認を挟める、という機能です。error を握りつぶしたことで、この機能が防ぎたかった事故そのものが起きる経路が生まれていました。
- 顧客画面は
isSignedだけを見て「保留しています」と表示し続け、署名ボタンは生きたまま - 社内 Slack は
heldが偽であることの理由を「既に署名済」と決め打ちして通知し、運営は「署名済ならOK」と読んで動かない
DB の一過性障害という片方の失敗が、顧客側には「安全に保留された」、社内側には「対応不要」という両方とも誤った安心に変換されていました。supabase-js は通信やクエリの失敗時も throw せず {data: null, error} を返す設計のため、error を明示的に読まない限りこの種の失敗は静かに握りつぶされます。
直す
error を受け取り、held にならなかった理由を「署名済(正常)」と「失敗(要対応)」に分けました。
const { data: heldRow, error: holdError } = await admin
.from("contracts")
.update({ review_status: "レビュー中" })
.eq("id", link.contract_id)
.or("application_status.is.null,application_status.neq.署名済")
.select("id")
.maybeSingle();
if (holdError) console.error("[p/token] signing hold failed:", holdError);
const held = !!heldRow && !holdError;
const holdMiss: "signed" | "error" | null = held ? null : holdError ? "error" : "signed";
Slack 通知は holdMiss で3値に分岐するようにしました。
held
? "⏸ *署名を保留しました。* 運営が承認するまでお客様は署名できません(覚書で合意 → 承認)\n"
: holdMiss === "error"
? "🔴 *署名の保留に失敗しました。* お客様はこのまま署名できる状態です。至急 review_status を「レビュー中」にしてください\n"
: "※ この契約は既に署名済のため、保留していません(成立後のご相談)\n"
顧客向けの表示も、isSigned ではなく実際に保留が立っているかどうか(isReviewing)だけを根拠にするよう変更しています。
{isReviewing && (
<>
<br />
ご返信まで、このページでのご署名は保留しています。
</>
)}
あわせて監査ログにも hold_miss_reason を残し、signing_held が偽だった理由をログ単体から辿れるようにしています。
再発防止
この記事の根拠には、error の握りつぶしを機械的に検知する仕組みを追加したという記述はありません。修正は今回の1箇所を直接直しただけで、同じパターンの UPDATE が他にもあるかどうかまでは根拠に含まれていません。
構造として見えるのは、{data, error} を返す形の API は、error を分割代入に含めないだけで簡単に fail-open になるということです。今回は heldRow の有無という「結果があったかどうか」だけを見ており、「結果が無かった理由」を一切区別していませんでした。しかもその判定結果を、性質の異なる2つの宛先(顧客向け表示と社内向け通知)にそのまま流用していたため、1箇所の握りつぶしが2箇所で別々の誤った断定に化けていました。
よくある質問
Q1この不具合は本番で実際に起きたのですか?
起きていません。2026-09-16のデプロイ前レビューで発見され、同日のうちに修正されています。修正前のコードが本番で実行された記録はありません。
Q2fail-openとは具体的に何を指しますか?
エラーが起きたことを無視して、処理が成功したかのように後続処理を続けてしまう設計のことです。今回はDB更新のerrorを受け取らず、heldRowという結果の有無だけで成否を判定していたため、DBの一過性障害が『既に署名済だから保留していない』という別の理由にすり替わっていました。
Q3顧客画面と社内Slackで、具体的に何が食い違っていたのですか?
修正前は、保留UPDATEが失敗した場合でも顧客向け画面は『保留しています』と表示し続け、署名ボタンも押せる状態のままでした。一方で社内Slackには『既に署名済のため保留していません』と断定的に通知され、運営側は対応不要と読んで動かない状態になっていました。
Q4直し方はどこを変えたのですか?
DB更新のerrorを読み取り、held(成功)/holdMiss="error"(失敗=要対応)/holdMiss="signed"(既に署名済で正常)の3つを区別するようにしました。失敗時はSlack文言を「🔴 署名の保留に失敗しました」に切り替え、監査ログにもhold_miss_reasonを残しています。あわせて顧客向けの保留表示も、実際に保留が立っているか(isReviewing)だけを根拠にするよう変更しました。
確認した環境
- next 16.2.7(kimiteras-portal)
- Supabase admin client 経由の UPDATE(supabase-js は失敗時も throw せず {data, error} を返す)
- 2026-09-16 デプロイ前レビューで発見・同日修正(本番未実行)
この記事の根拠
- TypeScriptファイル 298〜305行目コミット 0142fcc
- TypeScriptファイル 478〜489行目コミット 0142fcc
- TypeScriptファイル 299〜352行目コミット 3e95468
- TypeScriptファイル 480〜491行目コミット 3e95468
- JSONファイル 20〜20行目コミット 62a03f7
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。