Linking Everyone to a Tenant-Only Feature Gave 502
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
The PDF route returned ok: true with not applicable for unsupported configurations, and the caller judged the missing pdf field as a fault and returned 502.
The short version
When a feature is implemented for only some configurations, showing a link to it to everyone unconditionally surfaces to the user as a 502 — depending on the caller’s judgement — even while the backend is correctly returning “not applicable”. Here, the application page’s PDF copy link was shown to every user, supported or not, pointing at a PDF generation route implemented for one document format (one of the tenant types) only. Pressing the link on the unsupported side returned a 502 although the generation itself had raised no error.
What it looks like
The application page (a screen opened through a tokenised share link) always displayed a link to save a copy as PDF. Most users saved their PDF with no trouble, but reports came in that users on certain configurations (contracts of a different tenant type) who pressed the link could not open the PDF and got a 502.
Why
The PDF is generated by a server-side route handler (/p/[token]/pdf). Internally it calls the shared document generation routine in dryRun mode and builds its response from the return value.
// 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 });
The problem is that this shared routine (archiveContractDocument), when handling an application form (kind === "application"), is designed to finish normally without producing a PDF for anything but a specific tenant type.
// src/lib/document-archive.ts:366-370
// The application form uses the kimiteras ad format. Non-kimiteras is skipped
// (do not archive the wrong format).
if (contract.business_unit !== "kimiteras") {
return { ok: true, documentId: null, skipped: true };
}
The application-form PDF is implemented only for a specific format (business_unit === "kimiteras"), and it deliberately “finishes doing nothing” so that a PDF in the wrong format is not archived for other tenant types. The return value is ok: true — not an error.
But the calling route handler checked not only res.ok but the presence of res.pdf, and this skipped case, which carries no pdf field, fell into the !res.pdf condition and was treated as “could not generate” — a 502. The caller was not distinguishing “not applicable” from “failed”.
On top of that, the link that ought to decide whether to use the feature at all was shown to everyone, supported or not.
{/* before: always shown, without looking at business_unit */}
<p className="mt-6 text-center">
<a href={`/p/${token}/pdf`} className="...">
この申込書の控えをPDFで保存する
</a>
</p>
A design that “returns not applicable correctly”, a caller that “treats not applicable as a 502”, and a screen that “shows the link to users it does not apply to” — all three together made a path where users on unsupported configurations reached a 502 the instant they pressed it.
Fixing it
We limited the link’s display itself to configurations where a PDF can actually be generated.
// src/app/p/[token]/page.tsx:703-714
{/* W-4: the way to save an application copy as a server-generated PDF.
Only the kimiteras format can be generated (archive skips non-kimiteras), so show it conditionally. */}
{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>
)}
The generation route’s design of “return not applicable as ok: true” is unchanged. Not showing the way into an unsupported feature at the root means users never reach the path where the presence of res.pdf misjudges not applicable in the first place.
Preventing a repeat
This commit bundled several other corrections made the same day (a period fallback on the renewal rail, narrowing a notification path), all findings of the same family: “the backend works as designed, and the frontend’s display condition has not kept up with it”.
Generalised: when a feature is implemented only under some conditions, that condition has to be written not only as a backend branch but in the same place as the display condition for the entry point — the link or button — into the feature. However carefully the backend returns “not applicable”, failing to narrow the entry point does not stop a user it does not apply to from pressing it. And when, as here, something along the way treats “not applicable” as an error, that misstep surfaces as an error the user can see.
よくある質問
Q1Was there a bug in the PDF generation itself?
No. The PDF route returned the equivalent of { ok: true, pdf: undefined } for unsupported configurations, as intended. The problem was that the calling route handler judged success only by the presence of the pdf field, and that the link was shown on unsupported configurations too.
Q2Was hiding the link behind a condition the only fix?
Making PDF generation return distinct response shapes for not applicable and failed is also possible, but here we limited the link's display to supported configurations. Not showing a way into an unsupported feature leaves no room for a user to hit the 502.
Q3Where should you look to find this class of bug?
Enumerate the places where only part of a feature is implemented under a specific condition (tenant type, plan) and check whether links to it are shown to everyone. Even when the backend correctly returns not applicable, the gap surfaces as an error if the display condition has not kept up.
確認した環境
- Next.js App Router (Route Handler + Server Component)
- Occurred and fixed on 2026-07-23 (added 6c37ad6 → conditional display e5fe39e)
この記事の根拠
- TypeScriptファイル 559〜563行目コミット 6c37ad6
- TypeScriptファイル 559〜565行目コミット e5fe39e
- TypeScriptファイル 366〜370行目コミット a18878c
- TypeScriptファイル 113〜119行目コミット 848c665
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。