タイトルはふざけましたが中身は真面目です。
「エラー文言は BE と FE のどちらが持つべきか」を考えていて、
「BE が日本語のエラー文を返して FE がそのまま表示するのは、セキュリティ的によくない」
という意見を聞きました。
言いたいことは分かる。でも理由を突き詰めると、少しズレている気がしたので整理します。
結論から言うと、この問いは 「誰が持つか」ではなく「出る値をどこで固定するか」 の問題でした。
そして固定するには層を積む必要がありました。
まず、この 1 行が何を出すか
FastAPI でよく見る形です。
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
「例外の内容を返してあげたほうが親切だよね」という気持ちで書かれるやつです。
この str(e) が実際に何を返すか、手元で確かめてみます。
import sqlalchemy.exc
err = sqlalchemy.exc.ProgrammingError(
"SELECT teams.id, teams.name FROM teams "
"WHERE teams.name ILIKE %(name_1)s LIMIT %(param_1)s OFFSET %(param_2)s",
{"name_1": "%ユーザーが入力した検索語%", "param_1": 100, "param_2": -100},
Exception("(psycopg2.errors.InvalidRowCountInResultOffsetClause) OFFSET must not be negative"),
)
print(str(err))
出力です。
(builtins.Exception) (psycopg2.errors.InvalidRowCountInResultOffsetClause) OFFSET must not be negative
[SQL: SELECT teams.id, teams.name FROM teams WHERE teams.name ILIKE %(name_1)s LIMIT %(param_1)s OFFSET %(param_2)s]
[parameters: {'name_1': '%ユーザーが入力した検索語%', 'param_1': 100, 'param_2': -100}]
(Background on this error at: https://sqlalche.me/e/20/f405)
これがそのまま HTTP レスポンスに載ります。
- SQL 全文(テーブル名・カラム名つき)
- bind パラメータ
- 利用者が入力した文字列
最後のが特にまずくて、検索 API なら 他人の検索語が返る形になりえます。
StatementError.__str__ は SQL 文と bind 値を連結する実装で、
hide_parameters を指定しなければ既定は False です。つまり 意図せずこうなります。
ここで大事なこと
この漏れ方、英語でも起きます。 エラーコード方式にしても
{"code": "UNKNOWN", "message": str(e)} と書けば同じです。
つまり論点は
- ❌ 日本語か英語か
- ❌ 文言かエラーコードか
- ✅ その文字列がどう作られたか
「BE が文言を持つと危ない」ではなく 「BE がハンドリングを漏らすと危ない」 でした。
混ざっている 4 つを分ける
「エラーメッセージ」と一言で呼んでいるものには、性質の違う 4 つが入っています。
| 例 | 読み手で変わる? | |
|---|---|---|
| 意味 | 「最後の管理者は削除できない」 | 変わらない |
| 文言 | 最後の管理者は削除できません |
変わらない(後述) |
| 助言 | 先に別の管理者を追加してください |
変わる |
| 実行時の値 |
上限は5人 の「5」 |
変わらない(ドメインの値) |
分けると、置き場所がほぼ自動的に決まります。
意味は 1 つに定まる
DELETE /teams/{id}/members/{userId} が「最後の管理者だから外せない」と判断したとき、
その意味は 1 つです。誰が見ても同じ。 ドメインロジックが決めているので当然です。
これは BE のものです。 ここに異論は無いと思います。
文言も 1 つに定まる ── ただし条件がある
「読み手によって正しい文言が変わるのでは?」と思うかもしれません。
実はそう見えるだけで、変わっているのは別の部分です。
「最後の管理者は削除できません。先に別の管理者を追加してください。」
└─ 意味 ─────────────────┘ └─ 助言 ──────────────┘
たとえばこの API を「管理者」と「サブ管理者」の 2 ロールが叩けるとして、
サブ管理者に管理者を追加する権限が無かったら、後半は
「できないことをやれ」と言っていることになります。
でも前半は誰に対しても正しい。
なぜ助言だけが変わるのか。 助言は「あなたは次にこれができる」という主張で、
相手の権限・画面・状況に依存するからです。意味は事実の記述なので依存しません。
エラーは「何が起きたか」だけを言う。「どうすればいいか」は画面が言う。
これを守れば、文言は 1 つに定まります。 つまり BE が持てる。
リテラルにすれば安全か? いいえ
「じゃあ全部ベタ書きの固定文言にすれば安全だね」と思いますが、まだ穴があります。
{
"error_code": "RESOURCE_NOT_FOUND",
"message": "User with id 123 not found",
"details": { "resource_type": "User", "resource_id": "123" }
}
str(e) は使っていません。全部こちらで組み立てた文字列です。
それでも、ID を総当たりすれば「どの ID のユーザーが存在するか」が全部分かります。
いわゆる存在オラクルです。ログイン画面で
「そのメールアドレスは登録されていません」と出すのが避けられるのと同じ理屈ですね。
なので、招待 API ならこうします。
# ★ どれが無いかを区別しない ★
# 「チームが無い」「ユーザーが無い」を分けると、
# その ID が存在するかを外から確かめられる(存在オラクル)。
(InviteTargetNotFoundError, 404, "対象が見つかりません"),
この曖昧さは仕様です。 親切に書き分けてはいけない。
そして これは BE にしか判断できません。 FE に「もっと分かりやすく」と任せると壊れます。
でも「上限は5人です」って出したい
固定文言だけだと、こう書けません。
「チームの人数が上限に達しています」 ← これは書ける
「チームの人数上限は5人です」 ← 5 が実行時の値
ここで一度、**「5 は誰のものか」**を考えます。
上限 5 はドメインのルールです。BE が持っています。
なら BE が文まで作ればいいはずです。FE に 5 を渡して組み立てさせる必然性がない。
よくある案:code + params
{ "code": "MEMBER_LIMIT_EXCEEDED", "params": { "limit": 5 } }
FE が code から文言を選び、params を埋める。責務が綺麗に見えます。
でも、よく見てください。
BE が組み立て → {"detail": "チームの人数上限は5人です"}
params で渡す → {"params": {"limit": 5}}
どちらも 5 が応答に載っています。 params にしても、実行時の値が出ることは変わりません。
場所が変わるだけです。
つまり params は安全装置ではありません。
本当に守りたいものは何か
考え直すと、守りたいのはこれです。
応答に出る値の「種類」を、事前に列挙して固定したい
f"…{user.email}…" が危ないのは、そこに何でも書けるからです。
{limit} も {email} も同じ構文で書けて、書いた瞬間に応答に出ます。
なら、テンプレートと、埋めてよい値を分けて宣言すればいい。
# error_mapper.py
from typing import NewType
from fastapi import HTTPException, status
Limit = NewType("Limit", int)
class DomainError(Exception): ...
class MemberLimitExceededError(DomainError):
def __init__(self, limit: Limit) -> None: # ← 型で縛る(後述)
self.limit = limit
class InviteTargetNotFoundError(DomainError): ...
# 例外クラス -> (status, 文言テンプレート, 埋めてよい属性名)
#
# ★ テンプレートはリテラル ★ AST で検査できる
# ★ 埋める値は「属性名」で宣言する ★ 何が応答に出るか一覧で分かる
_RULES: tuple[tuple[type[Exception], int, str, tuple[str, ...]], ...] = (
(MemberLimitExceededError, status.HTTP_409_CONFLICT,
"チームの人数上限は{limit}人です", ("limit",)),
(InviteTargetNotFoundError, status.HTTP_404_NOT_FOUND,
"対象が見つかりません", ()),
)
def to_http_exception(error: Exception) -> HTTPException:
for error_type, status_code, template, allowed in _RULES:
if isinstance(error, error_type):
values = {name: getattr(error, name) for name in allowed}
return HTTPException(status_code=status_code,
detail=template.format(**values))
return HTTPException(status_code=500, detail="Internal server error")
動かすとこうなります。
MemberLimitExceededError 409 チームの人数上限は5人です
InviteTargetNotFoundError 404 対象が見つかりません
RuntimeError 500 Internal server error
f-string との決定的な差は、「何を出すか」が宣言になっていることです。
| f-string | テンプレート + 宣言 | |
|---|---|---|
| 何を埋められる? | 何でも |
("limit",) に書いたものだけ |
| 一覧できる? | できない | できる |
| 機械で検査できる? | しにくい | できる(次章) |
| BE が文言を作る? | ✅ | ✅ |
検証するコード
テンプレートがリテラルで、埋める値が宣言どおりかを AST で確かめます。
# check_tpl.py
import ast
import re
import sys
TARGET = "_RULES"
def _is_target(node: ast.stmt) -> bool:
"""`_RULES = (...)` と `_RULES: T = (...)` の両方を拾う。"""
if isinstance(node, ast.Assign):
return any(getattr(t, "id", "") == TARGET for t in node.targets)
if isinstance(node, ast.AnnAssign):
return getattr(node.target, "id", "") == TARGET
return False
def check(path: str) -> list[str]:
tree = ast.parse(open(path, encoding="utf-8").read())
problems: list[str] = []
checked = 0
for node in ast.walk(tree):
if not _is_target(node) or node.value is None:
continue
for entry in node.value.elts:
checked += 1
tpl, allowed = entry.elts[2], entry.elts[3]
ln = tpl.lineno
# ① テンプレートはリテラルか
if not (isinstance(tpl, ast.Constant) and isinstance(tpl.value, str)):
problems.append(f"{path}:{ln} テンプレートがリテラルでない")
continue
# ② 宣言はリテラルのタプルか
if not (isinstance(allowed, ast.Tuple)
and all(isinstance(e, ast.Constant) for e in allowed.elts)):
problems.append(f"{path}:{ln} 埋める値の宣言がリテラルでない")
continue
# ③ テンプレートの穴と宣言が一致するか
holes = set(re.findall(r"\{(\w+)\}", tpl.value))
declared = {e.value for e in allowed.elts}
if holes != declared:
problems.append(
f"{path}:{ln} 穴と宣言が不一致 穴={sorted(holes)} 宣言={sorted(declared)}")
# ★ 0 件を検査したのに緑、を防ぐ ★
if checked == 0:
raise SystemExit(f"❌ {path} に {TARGET} が見つかりません。検査が空振りしています")
print(f"({checked} 件を検査しました)")
return problems
if __name__ == "__main__":
found = check(sys.argv[1])
if found:
print("❌ 問題あり")
for p in found:
print(f" {p}")
sys.exit(1)
print("✅ テンプレートも宣言もリテラル。穴と宣言も一致")
検査を書いたら、必ず壊して確かめる
検査は「書いた」だけでは効きません。わざと壊して落ちるかを見る必要があります。
=== ① 正常 ===
(2 件を検査しました)
✅ テンプレートも宣言もリテラル。穴と宣言も一致
終了 0
=== ② テンプレートを f-string にした ===
❌ テンプレートがリテラルでない
終了 1
=== ③ 宣言していない穴 {email} を足した ===
❌ 穴と宣言が不一致 穴=['email', 'limit'] 宣言=['limit']
終了 1
=== ④ 宣言を変数にした ===
❌ 埋める値の宣言がリテラルでない
終了 1
③ が効くのが肝です。 うっかりメールアドレスを出そうとした瞬間に CI が落ちます。
AST の検査には落とし穴があります
上のコードで _is_target() をわざわざ関数に切り出しているのには理由があります。
_RULES: tuple[...] = (...)
これは ast.Assign ではなく ast.AnnAssign(型注釈つき代入) です。
ast.Assign だけを見ていると、型注釈を付けた瞬間に検査が何も見なくなります。
しかも エラーは出ません。何も検査せず緑になります。
checked == 0 で落としているのも同じ理由です。
「検査が空振りしていないこと」を検査する、ここまでやって初めて安心できます。
それでも残る穴を、型で塞ぐ
「テンプレート + 宣言」でも、まだ抜けます。
MemberLimitExceededError("user@example.com")
detail = チームの人数上限はuser@example.com人です
AST 検査は通ります。CI も緑。それでもメールアドレスが出ました。
("limit",) は「limit という名前の属性を埋める」としか言っておらず、
そこに何が入るかは縛っていません。
そこで型注釈です。
def __init__(self, limit: int) -> None: ...
mypy を通すとこうなります。
error: Argument 1 to "MemberLimitExceededError" has incompatible type "str"; expected "int"
漏れて困るものの多くは文字列(メールアドレス・氏名・トークン・str(e))なので、
これだけでかなり塞がります。
型が合う機密は残る
MemberLimitExceededError(user.id) # int なので通る
ここは NewType で潰せます。
Limit = NewType("Limit", int)
UserId = NewType("UserId", int)
def __init__(self, limit: Limit) -> None: ...
error: Argument 1 ... has incompatible type "int"; expected "Limit" ← 素の int も拒否
error: Argument 1 ... has incompatible type "UserId"; expected "Limit" ← 別の意味の int も拒否
実行時のコストはありません。NewType は静的にしか存在せず、
Limit(5) は int の 5 そのものです(200 万回で 0.06 秒の差=1 回 33 ナノ秒)。
安全性は「層」でしか守れない
ここまでを一般化すると、こうなります。
| 層 | 何を縛るか | 手段 |
|---|---|---|
| ① 構造 | 何を出す「枠」があるか | テンプレートをリテラルに固定 |
| ② 名前 | その枠に入れてよい値はどれか | 埋める値を宣言する |
| ③ 型 | その値がどんな種類か | 型注釈 + 静的検査 |
| ④ 意味 | その型が何を表すか | NewType |
どれか 1 つでは守れません。
① だけ → 枠は固定。でも何でも埋められる
①② → 埋める名前は決まった。でも中身は自由
①②③ → 型が合えば通る(int の機密が残る)
①②③④ → 意味の合う値しか渡せない
そして、層より先に塞ぐべきものがある
層をいくら積んでも、その仕組みを通らない経路があれば無意味です。
raise HTTPException(404, detail=f"team {team_id} not found") # 表を通らない
抽象化するとこうなります。
仕組みを作ったら、「その仕組みを通らずに同じことができる経路」を必ず塞ぐ
| 仕組み | 迂回路 |
|---|---|
| エラー変換表 | ハンドラで直接 raise |
| ORM のエスケープ | 生 SQL |
| 認可ミドルウェア | 別の入口 |
| lint ルール | # noqa |
迂回路を塞ぐほうが、層を 1 つ増やすより効くことが多いです。
層は「正しい道を安全にする」だけで、道は他にもあるからです。
AI 時代に、この話はどう変わるか
「NewType まで敷くのはコストが高い」というのが従来の評価でした。
でも、そのコストの内訳を見ると、AI が書くようになった今、大半が消えます。
| コスト | いま |
|---|---|
| 型を書く | 消える(生成させればいい) |
| 呼び出し側に通す | 消える(機械的な作業) |
| 型を変えたときの追従 | ほぼ消える |
| 境界での変換を足す | 消える |
| 人が読むときの認知負荷 | 残る |
| チームが規律を守る | 残る |
つまり ④ の層が「贅沢品」から「既定にしてよいもの」へ動いたわけです。
むしろ型の「価値」が上がっている
従来、型はこう見られていました。
型 = 人間のためのドキュメント + コンパイラへのヒント
コードを機械が書くようになると、こうなります。
型 = コード生成器に対する、機械検証可能な仕様
生成器は自信を持って間違えます。 書く速度が上がったぶん、間違いも速く量産されます。
だから 「機械が止められる形」で仕様を書く価値が上がる。
型注釈も、NewType も、AST 検査も、全部その「機械が止められる形」です。
ただし、新しい抜け道が増える
型を書くのが速くなったということは、型を黙らせるのも速いということです。
raise MemberLimitExceededError(uid) # type: ignore[arg-type]
「怒られたので # type: ignore を付けました」は、生成器がやりがちな解決です。
人間なら「なぜ怒っているのか」を一度考えますが、
生成器はエラーを消すこと自体を目的にしがちです。
なので層がもう 1 段要ります。
⑤ 抜け道を数える
type: ignore / cast / noqa の総数をラチェット(増えたら落とす)
抜け道をゼロにはできないので、増えないことを機械で守る。
結局どこに書くか
ここまでの決まりごとを、どこに置くかという話です。
AI コーディングの文脈だと、選択肢が 3 つあります。
| 置き場所 | 効き方 | 限界 |
|---|---|---|
Skills / Rules(.claude/rules/ など) |
意図が伝わる | 読まれる保証がない。強制力ゼロ |
| Hooks(編集時に検査) | 書く瞬間に止まる | AI の編集経路だけ。 人が直接書いたコードは素通り |
| CI / テスト | すべての経路を止める | 唯一、迂回できない |
この 3 つは代替ではなく、積み重ねです。
- Skills だけ → 守られないことがある
- Hooks だけ → エディタで直接書かれたら素通り
- CI だけ → 気づくのが遅い(書いた瞬間に教えてほしい)
そして、3 つとも壊れます
ここが最後の要点です。
- ルールは読まれないことがある
- hook は設定ミスで静かに死ぬことがある(設定してあるのに一致していない、など)
-
検査は「何も検査せず緑」になることがある(この記事の
ast.AnnAssignがそれ)
だから、もう 1 段。
★ 「効いていること」自体を検査する ★
検査を壊してみて、落ちるか(陰性対照)
検査対象が 0 件になっていないか
hook が実際に発火するか
仕組みは「作った」だけでは効きません。「効いていること」を別に確かめる必要があります。
まとめ
「エラーメッセージ」を 4 つに分けると、置き場所が決まります。
| 読み手で変わる? | 誰が持つ | |
|---|---|---|
| 意味 | 変わらない | BE |
| 文言 | 変わらない(助言を混ぜなければ) | BE |
| 助言 | 変わる | FE(BE の文言に混ぜない) |
| 実行時の値 | 変わらない(ドメインの値) | BE(テンプレートに埋める) |
そのうえで
- 「BE が日本語を返すと危ない」は、正確には 「BE が例外の中身を返すと危ない」
- エラーコードにしても隠れません。 レスポンスは見えるので
-
paramsも安全装置ではありません。 実行時の値が応答に載るのは同じ - 効くのは 「応答に出る値の種類を、事前に列挙して固定する」
- リテラルにしても存在オラクルは残る。「どれが無いか区別しない」は BE の判断
- 固定は ① 構造 → ② 名前 → ③ 型 → ④ 意味 の順に積む
- 層より先に、迂回路を塞ぐ
- AI 時代は ④ まで安い。 ただし ⑤ 抜け道を数えるが新しく要る
- 置き場所は Skills → Hooks → CI の積み重ね。3 つとも壊れる前提で
-
code + paramsを FE へ渡すのは i18n のときだけでいい
30 分しか寝ていなくても大丈夫なのは、「CI が起きているから」ではなく
「CI が起きていることを確かめてあるから」 です。