Rebounder Tech Blog

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

一部の設定にしか無い機能へのリンクを出しっぱなしにして502になった

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

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

結論

PDF生成ルートは非対応の設定に対して `ok: true` で『対象外』を返していたが、呼び出し元は `pdf` フィールドが無いことだけを見て異常と判定し、502を返していた。一部にしか無い機能へのリンクを無条件で出すと、失敗ではなく設計どおりの『対象外』が502として表面化する。

結論

ある機能が一部の設定にしか実装されていないとき、その機能へのリンクを全員へ無条件で表示すると、バックエンドが「対象外です」を正しく返していても、呼び出し元の判定次第でユーザーには502として表面化します。 今回は申込書ページのPDF控えリンクが、特定の書式(テナント種別の一つ)にしか対応していないPDF生成ルートへ、対応・非対応を問わず全ての利用者に出ていました。非対応側でリンクを踏むと、生成処理自体はエラーを起こしていないのに502が返っていました。

症状

申込書ページ(トークン付きの共有リンクで開く画面)には、控えをPDFで保存できるリンクが常に表示されていました。ほとんどのユーザーはこれで問題なくPDFを保存できていましたが、一部の設定(テナント種別が異なる契約)でこのリンクを踏んだユーザーだけ、PDFが開けず502が返るという報告がありました。

原因

PDFはサーバー側のルートハンドラ(/p/[token]/pdf)が生成しています。このハンドラは内部で帳票の共通生成処理を dryRun モードで呼び出し、その戻り値を見て応答を組み立てます。

// src/app/p/[token]/pdf/route.ts:113-119
const res = await archiveContractDocument({
  contractId: link.contract_id as string,
  kind,
  dryRun: true,
});
if (!res.ok || !res.pdf)
  return new Response("PDFを生成できませんでした。", { status: 502 });

問題は、この共通生成処理(archiveContractDocument)が申込書(kind === "application")を扱う際、特定のテナント種別以外はPDFを作らずに正常終了する設計になっていたことです。

// src/lib/document-archive.ts:366-370
// 申込書はキミテラス広告書式。非 kimiteras はスキップ(誤書式を保管しない)。
if (contract.business_unit !== "kimiteras") {
  return { ok: true, documentId: null, skipped: true };
}

申込書のPDFは特定の書式(business_unit === "kimiteras")向けにしか実装されておらず、それ以外のテナント種別で誤った書式のPDFを保管しないよう、意図的に「何もせず終了する」設計です。戻り値は ok: true で、エラーではありません。

ところが呼び出し元のルートハンドラは res.ok だけでなく res.pdf の有無も見ており、skippedpdf フィールドを持たないこのケースは !res.pdf の条件に引っかかって「生成できませんでした」=502として扱われていました。「対象外」と「失敗」を、呼び出し元が区別していなかったわけです。

さらに、この機能を利用するかどうかを決めるべきリンク自体が、対応・非対応を問わず全員に表示されていました。

{/* 修正前: business_unit を見ずに常に表示 */}
<p className="mt-6 text-center">
  <a href={`/p/${token}/pdf`} className="...">
    この申込書の控えをPDFで保存する
  </a>
</p>

「対象外を正しく返す」設計と「対象外を502として扱う」呼び出し元、そして「対象外の利用者にもリンクを見せる」画面という3つが揃って、非対応の設定のユーザーだけが踏んだ瞬間に502へたどり着く経路になっていました。

直し方

リンクの表示自体を、PDFが実際に生成できる設定に限定しました。

// src/app/p/[token]/page.tsx:703-714
{/* W-4: 申込書の控えをサーバー生成 PDF でそのまま保存できる導線。
    PDF はキミテラス書式のみ生成できる(archive 側で非 kimiteras は skip)ため出し分ける。 */}
{contract.business_unit === "kimiteras" && (
  <p className="mt-6 text-center">
    <a
      href={`/p/${token}/pdf`}
      className="text-xs text-gray-500 underline hover:text-product"
    >
      この申込書の控えをPDFで保存する
    </a>
  </p>
)}

生成ルート側の「対象外は ok: true で返す」設計自体は変えていません。対応していない機能への導線を根元で見せなければ、res.pdf の有無で対象外を誤判定する経路にユーザーが到達すること自体が無くなるためです。

再発防止

このコミットは同じ日に他にも複数の是正(更新レールの期間フォールバック、通知経路の限定など)をまとめて含んでおり、いずれも「バックエンド側は設計どおり動いているが、フロント側の表示条件がそれに追いついていない」系統の指摘でした。

一般化すると、ある機能が一部の条件でしか実装されていないとき、その条件はバックエンドの分岐だけでなく、機能への入口(リンクやボタン)の表示条件としても同じ場所に書く必要があるということです。バックエンドが「対象外」を丁寧に返していても、入口を絞っていなければ、対象外のユーザーがその入口を踏むところまでは防げません。今回のように「対象外」を呼び出し元がエラーとして扱う実装が途中にあると、その踏み外しはユーザーに見える形のエラーとして出ます。

よくある質問

Q1PDF生成そのものにバグがあったのですか?

いいえ。PDF生成ルートは非対応の設定に対して意図どおり `{ ok: true, pdf: undefined }` 相当の『対象外』を返しており、エラーは起きていません。問題は呼び出し元のルートハンドラが `pdf` フィールドの有無だけで成功・失敗を判定していたことと、そもそもリンク自体が非対応の設定にも表示されていたことです。

Q2リンクを条件分岐で隠す以外に直しようはなかったのですか?

PDF生成側で『対象外』と『失敗』を別のレスポンス形にする直し方もありえますが、今回はリンクの表示自体を対応している設定に限定しました。対応していない機能への導線をそもそも見せない方が、ユーザーが踏んで502に当たる余地を残さずに済むためです。

Q3この手のバグはどこを疑えば見つけやすいですか?

機能の一部だけが特定の条件(テナント種別やプランなど)でしか実装されていない箇所を洗い出し、その機能へのリンクやボタンが条件分岐なしに全員へ出ていないかを確認することです。バックエンドが『対象外』を正しく返していても、フロント側の表示条件が追いついていなければ、その差分がユーザーへのエラーとして出ます。

確認した環境

  • Next.js App Router(Route Handler + Server Component)
  • 2026-07-23 に発生と修正(追加 6c37ad6 → 出し分け e5fe39e)

この記事の根拠

  • TypeScriptファイル 559〜563行目コミット 6c37ad6
  • TypeScriptファイル 559〜565行目コミット e5fe39e
  • TypeScriptファイル 366〜370行目コミット a18878c
  • TypeScriptファイル 113〜119行目コミット 848c665

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