Spring Boot × React でハマった落とし穴と対策まとめ
簡単に
現象
ResponseStatusException(HttpStatus.UNAUTHORIZED, "メッセージ")のメッセージがフロントに届かない
原因(2 段階)
-
サーバー側
Spring Bootのデフォルト設定ではResponseStatusExceptionのreasonがJSONに含まれない -
フロント側
ProblemDetailのContent-Type: application/problem+jsonをapplication/json決め打ちの判定が拾えず、JSONが文字列として扱われる
対策(両方必要)
-
バックエンド:
spring.mvc.problemdetails.enabled=trueを有効化してProblemDetailを返す -
フロント: Content-Type 判定を
includes('json')に緩める
環境
- バックエンド: Spring Boot + Spring Security
- フロント: React + Vite
- fetchベースの自作 HTTP ラッパ
- 認証方式: セッション (
JSESSIONID)
起きた問題
ログイン API はバックエンドで明示的にメッセージを指定しているのに、
throw new ResponseStatusException(
HttpStatus.UNAUTHORIZED,
"ユーザー名またはパスワードが正しくありません"
);
フロントでは 汎用エラー しか表示されない。
const serverMsg = extractServerMessage(payload)
throw new HttpError(serverMsg ?? `POST ${path} failed`, res.status,
Networkタブを確認すると、
{
"timestamp": "...",
"status": 401,
"error": "Unauthorized",
"path": "/api/auth/login"
}
message が無い。
原因1: Spring Bootがreasonを返していない
Spring Boot 2.3 以降、セキュリティ強化のため
server.error.include-message=never
がデフォルト。
そのため、ResponseStatusException の reason はJSONに含まれない。
対策: ProblemDetail を有効化
application.properties に追加:
spring.mvc.problemdetails.enabled=true
すると ResponseStatusException は RFC 7807 形式の ProblemDetail で返るようになる。
{
"detail": "ユーザー名またはパスワードが正しくありません",
"instance": "/api/auth/login",
"status": 401,
"title": "Unauthorized"
}
detail にメッセージが入るので、フロントで拾えるはず...
と思いきや、まだ動かない。
原因2: Content-Type 判定が厳しすぎた
フロントの readPayload を確認。
async function readPayload(res: Response): Promise<unknown> {
const ct = res.headers.get('content-type') ?? ''
if (ct.includes('application/json')) return await res.json()
const text = await res.text()
return text.length ? text : null
}
しかし ProblemDetail の Content-Type は…
Content-Type: application/problem+json
| Content-Type | includes('application/json') |
|---|---|
application/json |
true ✓ |
application/problem+json |
false ✗ |
"application/problem+json" の中に "application/json" という連続部分は存在しないため、JSONとしてパースされず、ただの文字列として扱われる。
結果:
payload = '{"detail":"...",...}'
extractServerMessage はオブジェクトのみ対象なので null を返す。
結果として serverMsg ?? fallback の fallback が選ばれて、汎用エラーが表示されていた。
function extractServerMessage(payload: unknown): string | null {
if (payload && typeof payload === 'object') {
const obj = payload as Record<string, unknown>
if (typeof obj.detail === 'string') return obj.detail // ProblemDetail
if (typeof obj.message === 'string') return obj.message // 旧 Boot エラー形式
}
return null
}
対策: Content-Type 判定を緩める
- if (ct.includes('application/json')) return await res.json()
+ if (ct.includes('json')) return await res.json()
+json 系( application/problem+json など)も拾えるようになる。
最終的な対策まとめ
バックエンド (application.properties)
spring.mvc.problemdetails.enabled=true
フロント (fetch ラッパ)
async function readPayload(res: Response): Promise<unknown> {
const ct = res.headers.get('content-type') ?? ''
if (ct.includes('json')) return await res.json() // ★ ここ
const text = await res.text()
return text.length ? text : null
}
これでフロントに 「ユーザー名またはパスワードが正しくありません」 が正しく表示される。
なぜ気づきにくいのか
- Devtools のResponse プレビューは Content-Type に関係なく JSON を整形表示する
→「ちゃんと JSON が返ってる」と錯覚しやすい -
res.json()はContent-Typeを見ない
→自前で Content-Type 判定を入れると、落とし穴になりやすい -
extractServerMessageのガードが堅牢で
→「文字列で来ている」という異常が表に出ない(静かにnullになる)
学び・教訓
1. Spring Boot の ResponseStatusException はデフォルトで reason が返らない
-
server.error.include-message=alwaysで雑に出す -
spring.mvc.problemdetails.enabled=trueで ProblemDetail として明示的に返す - もしくは
@RestControllerAdviceで個別にハンドリングする
2. ProblemDetail は application/problem+json で返る
- フロントの Content-Type 判定を
application/json決め打ちにしない -
includes('json')や+json系を許容する正規表現などで緩める
3. fetch ラッパの設計ポイント
- 成功時だけでなく エラー時も JSON をパースできる ようにしておく
- 4xx/5xx で Content-Type が変わる API は珍しくない