- Spring
Spring Boot 例外処理|ステータスコードの返し分けと503実例
結論
500は想定外の失敗だけ。入力や業務の失敗は4xx、一時的に使えないなら503を返します。503には再試行の目安(Retry-After)を付けます。
この記事でわかること
- 状況→コード→書き方の早見表7行と判断表12行
- 400〜503の9種をcurlで返した応答の実物
- 503+Retry-Afterの返し方3通りと待つコード
例外が起きたら、「呼び出し側が直せる失敗か」「待てば直る失敗か」の2つで返すコードが決まります。どちらでもないものだけが 500 です。
flowchart TD
A[例外が起きた] --> B{呼び出し側が<br>直せる?}
B -- はい --> C[4xx<br>400・404・409]
B -- いいえ --> D{待てば<br>直る?}
D -- はい --> E[503<br>Retry-After]
D -- いいえ --> F[500<br>詳細はログ]
状況ごとの返すコードと、Spring での書き方です。
| 状況 | 返すコード | Springでの書き方 |
|---|---|---|
| 入力チェックに失敗 | 400 | @Valid+handleMethodArgumentNotValid |
| データが見つからない | 404 | 独自例外+@ExceptionHandler |
| 未ログイン | 401 | Security の AuthenticationEntryPoint |
| 権限がない | 403 | Security の AccessDeniedHandler |
| 二重登録・状態の食い違い | 409 | 独自例外+@ExceptionHandler |
| 一時的に使えない | 503+Retry-After | ResponseEntity.status(503).header(...) |
| それ以外(想定外) | 500 | @ExceptionHandler(Exception.class) |
確認環境:Spring Boot 4.1.1(Spring Framework 7.0.9・Spring Security 7.1.1・Apache Tomcat 11.0.24・Jackson 3.1.5・Hibernate Validator 9.1.3.Final は Boot の依存管理の版)。これらを GitHub のタグのソースからビルドし、OpenJDK 21.0.10(Linux)でこの記事のアプリを起動して、curl の応答・ログ・RestClient の再試行の出力を取りました。MockMvc のテストは JUnit 6.0.3 で実行し、503 を受けたブラウザの動きは Chromium 141 で確かめています。HTTP の仕様は RFC 9110・RFC 6585・RFC 9457、Spring の動きは Spring Framework 7.0.9・Spring Security 7.1.1 のリファレンスとソース、検索への影響は Google 検索セントラルで確かめました(確認日 2026年10月17日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Spring Boot 例外処理|ステータスコードの返し分けと503実例」
業務の失敗まで全部500にしない理由
独自の例外(RuntimeException など)を投げっぱなしにすると、Spring Boot は 500(Internal Server Error)で返します。業務の失敗まで 500 にすると、困ることが3つあります。
1呼び出し側が直せない
500 は「サーバー側の問題」という意味です。画面やクライアントは、入力を直せばよいのか、待てばよいのかを判断できません。400 と errors 配列があれば、どの項目を直すかを表示できます。
2監視が鈍る
在庫不足や入力ミスで 500 が出ると、本当の障害(バグ・DB 停止)と区別がつきません。5xx の割合で警報を出す監視が、毎日鳴るか、しきい値を上げて本当の障害を見逃します。
3再試行してよいかが分からない
4xx は同じリクエストを送り直しても結果は変わりません。503 は待てば通る見込みがあります。500 だけでは、クライアントやリトライ機能が「送り直すべきか」を決められません。
返し分ける前と後で、同じ失敗がどう見えるかを並べます(後の値は、この記事のアプリで実際に返したものです)。
画面は該当の入力欄にメッセージを出せる。サーバーの 5xx の数は増えない
クライアントは code で分岐し、数量を減らす案内を出せる
クライアントは3秒待って送り直し、3回目で成功した(後述の RestClient の出力)
本当の障害だけが 500 になり、errorId でログのスタックトレースをたどれる
返すコードの意味そのもの(401 と 403 の違いなど)は、関連記事「401と403の違い|HTTPステータスコードの使い分け判断フロー」で扱っています。エラー本文の JSON 設計は、関連記事「APIエラーレスポンス設計|RFC 9457準拠のJSON例と実装コード」で扱っています。
コピペ用グローバルハンドラー(@RestControllerAdvice+ProblemDetail)
この記事で実際に動かした完成形です。Spring Boot 4.1.1 で動かしました。使っている API は Spring Framework 6.2(Spring Boot 3.4)以降にあるものです(ソースの @since で確認。版の違いはコードの後の表にあります)。
3つの書き方の違い:Controller内・@ControllerAdvice・@RestControllerAdvice
| 書き方 | 効く範囲 | レスポンスの書き方 | 向いている用途 |
|---|---|---|---|
Controller 内の @ExceptionHandler |
その Controller だけ | @RestController ならそのまま JSON |
その Controller 固有の例外 |
@ControllerAdvice |
全 Controller(絞り込み可) | ResponseEntity・ProblemDetail 以外はビュー名 |
画面(HTML)を返すアプリ |
@RestControllerAdvice |
全 Controller(絞り込み可) | すべての戻り値が本文になる | REST API(基本はこれ) |
@RestControllerAdvice は @ControllerAdvice と @ResponseBody を合わせたもの です。@ControllerAdvice でも、戻り値が ResponseEntity や ProblemDetail なら本文として書き出されます。
Controller の中だけで処理したい例外があるときは、その Controller に @ExceptionHandler を書きます。
@RestController
@RequestMapping("/api/csv")
public class CsvController {
// この Controller で起きた CsvExportException だけを処理する
@ExceptionHandler(CsvExportException.class)
public ProblemDetail handleCsv(CsvExportException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY,
"CSV の出力条件に誤りがあります");
}
}
Controller では try-catch を書かずに例外を投げ、@RestControllerAdvice の共通ハンドラーで受け止める 形にすると、Controller は正常系の処理だけに集中できます。
独自例外(404・409・503)
package com.example.order.exception;
/** 対象のデータが見つからない(404) */
public class ResourceNotFoundException extends RuntimeException {
private final String code;
public ResourceNotFoundException(String code, String message) {
super(message);
this.code = code;
}
public String getCode() { return code; }
}
package com.example.order.exception;
/** 業務ルールに反する操作(409)。例:在庫不足、確定済みの注文の変更 */
public class BusinessRuleException extends RuntimeException {
private final String code;
public BusinessRuleException(String code, String message) {
super(message);
this.code = code;
}
public String getCode() { return code; }
}
package com.example.order.exception;
import java.time.Duration;
/** 一時的に処理できない(503)。retryAfter は「何秒後にまた試せばよいか」の目安 */
public class TemporarilyUnavailableException extends RuntimeException {
private final String code;
private final Duration retryAfter;
public TemporarilyUnavailableException(String code, String message, Duration retryAfter, Throwable cause) {
super(message, cause);
this.code = code;
this.retryAfter = retryAfter;
}
public String getCode() { return code; }
public Duration getRetryAfter() { return retryAfter; }
}
メッセージ(detail)は利用者に見せてよい文だけにし、機械が判定するための code を別に持たせます。 例外の階層は「404 系」「409 系」「503 系」のようにステータス単位でまとめると、ハンドラーが増えすぎません。
GlobalExceptionHandler
package com.example.order.web;
import com.example.order.exception.BusinessRuleException;
import com.example.order.exception.ResourceNotFoundException;
import com.example.order.exception.TemporarilyUnavailableException;
import jakarta.servlet.http.HttpServletRequest;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.CannotGetJdbcConnectionException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private static final String TYPE_BASE = "https://example.com/problems/";
// 1. 業務の 404(データが無い)
@ExceptionHandler(ResourceNotFoundException.class)
public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
log.info("not found: code={} message={}", ex.getCode(), ex.getMessage());
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setType(URI.create(TYPE_BASE + "resource-not-found"));
pd.setTitle("Resource Not Found");
pd.setProperty("code", ex.getCode());
return pd; // instance は未設定ならリクエストのパスが入る
}
// 2. 業務ルール違反の 409
@ExceptionHandler(BusinessRuleException.class)
public ProblemDetail handleBusinessRule(BusinessRuleException ex) {
log.info("business rule: code={} message={}", ex.getCode(), ex.getMessage());
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
pd.setType(URI.create(TYPE_BASE + "business-rule"));
pd.setTitle("Business Rule Violation");
pd.setProperty("code", ex.getCode());
return pd;
}
// 3. 一時的に処理できない 503:Retry-After(秒数)を付ける
@ExceptionHandler(TemporarilyUnavailableException.class)
public ResponseEntity<ProblemDetail> handleUnavailable(TemporarilyUnavailableException ex) {
log.warn("temporarily unavailable: code={} retryAfter={}s", ex.getCode(), ex.getRetryAfter().toSeconds());
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE, ex.getMessage());
pd.setType(URI.create(TYPE_BASE + "temporarily-unavailable"));
pd.setTitle("Service Unavailable");
pd.setProperty("code", ex.getCode());
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.getRetryAfter().toSeconds()))
.body(pd);
}
// 4. DB の接続を取れない(接続プール枯渇・DB 再起動中など)も 503 にする
@ExceptionHandler(CannotGetJdbcConnectionException.class)
public ResponseEntity<ProblemDetail> handleNoConnection(CannotGetJdbcConnectionException ex) {
log.error("cannot get jdbc connection", ex);
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE,
"混み合っています。少し時間をおいて再度お試しください。");
pd.setType(URI.create(TYPE_BASE + "temporarily-unavailable"));
pd.setTitle("Service Unavailable");
pd.setProperty("code", "DB_BUSY");
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.header(HttpHeaders.RETRY_AFTER, "5")
.body(pd);
}
// 5. Spring Security の例外は横取りせず、Security 側(403/401 の処理)へ戻す
@ExceptionHandler({AccessDeniedException.class, AuthenticationException.class})
public void rethrowSecurity(RuntimeException ex) {
throw ex;
}
// 6. @Valid @RequestBody の検証エラー:項目ごとのエラーを errors に入れる
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
ProblemDetail pd = ex.getBody();
pd.setType(URI.create(TYPE_BASE + "validation-error"));
pd.setDetail("入力内容に誤りがあります");
List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> Map.of("field", fe.getField(),
"message", String.valueOf(fe.getDefaultMessage())))
.toList();
pd.setProperty("errors", errors);
return handleExceptionInternal(ex, pd, headers, status, request);
}
// 7. 引数に直接付けた制約(@RequestParam @Min(1) など)の検証エラー
@Override
protected ResponseEntity<Object> handleHandlerMethodValidationException(
HandlerMethodValidationException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
ProblemDetail pd = ex.getBody();
pd.setType(URI.create(TYPE_BASE + "validation-error"));
pd.setDetail("入力内容に誤りがあります");
List<Map<String, String>> errors = ex.getParameterValidationResults().stream()
.flatMap(result -> result.getResolvableErrors().stream().map(err -> Map.of(
"field", (err instanceof FieldError fe) ? fe.getField()
: String.valueOf(result.getMethodParameter().getParameterName()),
"message", String.valueOf(err.getDefaultMessage()))))
.toList();
pd.setProperty("errors", errors);
return handleExceptionInternal(ex, pd, headers, status, request);
}
// 8. Spring 標準例外の共通出口:4xx は WARN で1行だけ記録(スタックトレースなし)
@Override
protected ResponseEntity<Object> handleExceptionInternal(
Exception ex, Object body, HttpHeaders headers,
HttpStatusCode statusCode, WebRequest request) {
if (statusCode.is4xxClientError()) {
log.warn("client error: status={} exception={} message={}",
statusCode.value(), ex.getClass().getSimpleName(), ex.getMessage());
}
return super.handleExceptionInternal(ex, body, headers, statusCode, request);
}
// 9. 想定外の例外:500。詳細はログにだけ出し、レスポンスには errorId だけを返す
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
String errorId = UUID.randomUUID().toString();
log.error("unexpected error: errorId={} method={} uri={}",
errorId, request.getMethod(), request.getRequestURI(), ex);
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
"サーバーでエラーが発生しました。時間をおいて再度お試しください。");
pd.setType(URI.create(TYPE_BASE + "internal-error"));
pd.setTitle("Internal Server Error");
pd.setProperty("errorId", errorId);
return pd;
}
}
コードのポイントを、公式リファレンスの記述と対応させます。
| 行 | 書いていること | 根拠(公式) |
|---|---|---|
extends ResponseEntityExceptionHandler |
Spring MVC 標準例外を ProblemDetail で返す | 標準例外と ErrorResponseException を処理する基底クラス |
戻り値 ProblemDetail |
status がレスポンスのステータスになる |
status プロパティがステータスを決める |
戻り値 ResponseEntity<ProblemDetail> |
ヘッダー(Retry-After)を足せる | 本文は同じく application/problem+json |
instance を設定しない |
現在の URL パスが自動で入る | 未設定なら現在の URL パスから設定 |
pd.setProperty("code", ...) |
JSON のトップレベルに code が出る |
properties の Map はトップレベルに展開 |
| 5番の再スロー | Security の例外を残りの処理へ戻す | 再スローした例外は残りの解決の流れに進む |
@ExceptionHandler(Exception.class) |
標準例外は横取りしない | 同じクラスでは型の距離が近いハンドラーが選ばれる |
版の違いは次のとおりです。
| 使っている版 | 変えるところ |
|---|---|
| Spring Boot 4.x(Framework 7.x) | このまま |
| Spring Boot 3.4〜3.5(Framework 6.2) | このまま |
| Spring Boot 3.2〜3.3(Framework 6.1) | 7番の getParameterValidationResults() を getAllValidationResults() にする |
| Spring Boot 3.0〜3.1(Framework 6.0) | 7番のメソッドを消す(HandlerMethodValidationException は 6.1 から) |
| Spring Security を使わない | 5番のメソッドと import を消す |
| DB(spring-jdbc)を使わない | 4番のメソッドと import を消す |
依存は spring-boot-starter-webmvc(Boot 3 では spring-boot-starter-web)と spring-boot-starter-validation です。401・403 も同じ形にするなら spring-boot-starter-security も足します。プロジェクトの作り方と起動の手順は、関連記事「Spring Bootとは|入門・Initializrの使い方とREST API作成」で扱っています。
動作確認に使った Controller
各ステータスを起こすための Controller です。DB の接続プール枯渇は、DataSourceUtils が接続を取れないときに投げるのと同じ例外(CannotGetJdbcConnectionException、原因は HikariCP の SQLTransientConnectionException)を直接投げて再現しています。
package com.example.order.web;
import com.example.order.exception.BusinessRuleException;
import com.example.order.exception.MaintenanceException;
import com.example.order.exception.ResourceNotFoundException;
import com.example.order.exception.TemporarilyUnavailableException;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import java.sql.SQLTransientConnectionException;
import java.time.Duration;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.CannotGetJdbcConnectionException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api")
public class OrderController {
public record OrderRequest(@NotBlank String itemId, @Min(1) int quantity) {}
public record Order(long id, String itemId, int quantity) {}
private final AtomicInteger reportCalls = new AtomicInteger();
@GetMapping("/orders/{id}")
public Order get(@PathVariable long id) {
if (id == 1) return new Order(1, "A-100", 2);
throw new ResourceNotFoundException("ORDER_NOT_FOUND", "注文が見つかりません: id=" + id);
}
@GetMapping("/orders")
public List<Order> list(@RequestParam @Min(1) int page) {
return List.of(new Order(1, "A-100", 2));
}
@PostMapping("/orders")
@ResponseStatus(HttpStatus.CREATED)
public Order create(@Valid @RequestBody OrderRequest req) {
if (req.quantity() > 10) {
throw new BusinessRuleException("STOCK_SHORTAGE", "在庫が足りません: itemId=" + req.itemId() + " 在庫=10");
}
return new Order(2, req.itemId(), req.quantity());
}
@GetMapping("/items/{id}")
public Map<String, Object> item(@PathVariable long id) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "商品が見つかりません: id=" + id);
}
@GetMapping("/boom")
public String boom() {
throw new IllegalStateException("DB接続先 jdbc:postgresql://db.internal:5432/app に接続できません");
}
// 外部の集計 API が止まっている想定:最初の2回は 503、3回目で成功
@GetMapping("/reports/daily")
public Map<String, Object> report() {
int n = reportCalls.incrementAndGet();
if (n <= 2) {
throw new TemporarilyUnavailableException("REPORT_API_DOWN",
"集計サーバーが応答しません。時間をおいて再度お試しください。", Duration.ofSeconds(3), null);
}
return Map.of("date", "2026-10-16", "orders", 128);
}
// DB の接続プールが枯渇した想定(DataSourceUtils が投げるのと同じ例外を直接投げる)
@GetMapping("/stock")
public Map<String, Object> stock() {
throw new CannotGetJdbcConnectionException("Failed to obtain JDBC Connection",
new SQLTransientConnectionException(
"HikariPool-1 - Connection is not available, request timed out after 30000ms"));
}
// 計画メンテナンス中の想定:Retry-After に終了予定時刻を入れる
@GetMapping("/payments")
public Map<String, Object> payments() {
throw new MaintenanceException(ZonedDateTime.of(2026, 10, 18, 6, 0, 0, 0, ZoneId.of("Asia/Tokyo")));
}
@GetMapping("/admin/stats")
public Map<String, Object> stats() {
return Map.of("orders", 42);
}
// メソッドセキュリティ:ADMIN だけが返金できる
@PostMapping("/orders/{id}/refund")
@PreAuthorize("hasRole('ADMIN')")
public Map<String, Object> refund(@PathVariable long id) {
return Map.of("id", id, "refunded", true);
}
}
MaintenanceException と Security の設定は、後の「503とRetry-After」「401と403」の節に全文を載せています。
実際に返したステータスコード9種(curlの応答)
上のアプリを Spring Boot 4.1.1 で起動し、curl で失敗するリクエストを送りました。返ったステータスと、どこが返したかの一覧です。
| 送ったリクエスト | 返ったコード | 返した場所 |
|---|---|---|
| 数量 0・品番が空で POST | 400 | 6番(handleMethodArgumentNotValid) |
?page=0(@Min(1) 違反) |
400 | 7番(handleHandlerMethodValidationException) |
| 壊れた JSON を POST | 400 | 継承元(既定の detail) |
/api/orders/abc(数値でない ID) |
400 | 継承元(型変換エラー) |
| 認証なしで管理 API | 401 | Security(AuthenticationEntryPoint) |
| 一般ユーザーで管理 API | 403 | Security(AccessDeniedHandler) |
| 無い注文 ID | 404 | 1番(独自例外) |
| 何も無い URL | 404 | 継承元(NoResourceFoundException) |
ResponseStatusException を投げる |
404 | 継承元(ProblemDetail になる) |
| GET だけの URL に DELETE | 405 | 継承元(Allow ヘッダー付き) |
| 在庫より多い数量 | 409 | 2番(業務ルール) |
| フォーム形式で POST | 415 | 継承元(Accept ヘッダー付き) |
| 想定外の例外 | 500 | 9番(固定文と errorId) |
| 外部 API の停止 | 503 | 3番(Retry-After: 3) |
| DB の接続を取れない | 503 | 4番(Retry-After: 5) |
| メンテナンス中 | 503 | ErrorResponseException(HTTP-date) |
以下は curl の出力そのものです。Spring Security が付ける X-Content-Type-Options などの共通ヘッダーと Date・Transfer-Encoding は省いています。
400:@Valid @RequestBody の検証エラー
curl -i -X POST http://localhost:25020/api/orders \
-H 'Content-Type: application/json' -H 'Accept-Language: ja' \
-d '{"itemId":"","quantity":0}'
HTTP/1.1 400
Content-Type: application/problem+json
Connection: close
{"detail":"入力内容に誤りがあります","instance":"/api/orders","status":400,"title":"Bad Request","type":"https://example.com/problems/validation-error","errors":[{"field":"quantity","message":"1 以上の値にしてください"},{"field":"itemId","message":"空白は許可されていません"}]}
メッセージは Hibernate Validator の既定の日本語メッセージです。言語はリクエストの Accept-Language で決まり、ヘッダーを付けずに送ると must be greater than or equal to 1・must not be blank の英語になりました。errors の順番は送るたびに変わることがあるので、クライアントは field で照合します。
400:引数の制約エラー(HandlerMethodValidationException)
curl -i -H 'Accept-Language: ja' "http://localhost:25020/api/orders?page=0"
HTTP/1.1 400
Content-Type: application/problem+json
Connection: close
{"detail":"入力内容に誤りがあります","instance":"/api/orders","status":400,"title":"Bad Request","type":"https://example.com/problems/validation-error","errors":[{"field":"page","message":"1 以上の値にしてください"}]}
Spring Framework 6.1 以降、Controller の引数に直接付けた制約はこの例外になります。getAllErrors() だけで field を作ると、FieldError ではないため空文字になりました。上の完成形のように getParameterValidationResults() から引数名を取ると page が入ります。
400:JSONが壊れている・型が合わない
curl -i -X POST http://localhost:25020/api/orders \
-H 'Content-Type: application/json' -d '{"itemId":"A-100","quantity":}'
curl -i http://localhost:25020/api/orders/abc
HTTP/1.1 400
Content-Type: application/problem+json
Connection: close
{"detail":"Failed to read request","instance":"/api/orders","status":400,"title":"Bad Request"}
HTTP/1.1 400
Content-Type: application/problem+json
Connection: close
{"detail":"Failed to convert 'id' with value: 'abc'","instance":"/api/orders/abc","status":400,"title":"Bad Request"}
Jackson のエラーメッセージ(どの文字で失敗したか)はレスポンスに出ず、detail は固定文になりました。 原因は8番の WARN ログに1行で残ります(後述の「ログ出力」)。type が無いのは、未設定の type を Spring が出力しないためで、RFC 9457 では type が無い場合は about:blank とみなします。
404:独自例外・存在しないURL・ResponseStatusException
HTTP/1.1 404
Content-Type: application/problem+json
{"detail":"注文が見つかりません: id=999","instance":"/api/orders/999","status":404,"title":"Resource Not Found","type":"https://example.com/problems/resource-not-found","code":"ORDER_NOT_FOUND"}
HTTP/1.1 404
Content-Type: application/problem+json
{"detail":"No static resource api/orderz.","instance":"/api/orderz","status":404,"title":"Not Found"}
HTTP/1.1 404
Content-Type: application/problem+json
{"detail":"商品が見つかりません: id=7","instance":"/api/items/7","status":404,"title":"Not Found"}
上から GET /api/orders/999、GET /api/orderz、GET /api/items/7 の応答です。throw new ResponseStatusException(HttpStatus.NOT_FOUND, "...") は、ResponseEntityExceptionHandler を継承した Advice があると ProblemDetail になり、理由の文字列が detail に入りました。継承した Advice が無いと Spring Boot の /error の JSON になり、理由の文字列は既定では返りません。
405・409・415
HTTP/1.1 405
Allow: GET
Content-Type: application/problem+json
{"detail":"Method 'DELETE' is not supported.","instance":"/api/orders/1","status":405,"title":"Method Not Allowed"}
HTTP/1.1 409
Content-Type: application/problem+json
{"detail":"在庫が足りません: itemId=A-100 在庫=10","instance":"/api/orders","status":409,"title":"Business Rule Violation","type":"https://example.com/problems/business-rule","code":"STOCK_SHORTAGE"}
HTTP/1.1 415
Accept: application/json, application/*+json
Content-Type: application/problem+json
{"detail":"Content-Type 'application/x-www-form-urlencoded;charset=UTF-8' is not supported.","instance":"/api/orders","status":415,"title":"Unsupported Media Type"}
405 には使えるメソッドを示す Allow、415 には受け付ける形式を示す Accept のヘッダーを、ResponseEntityExceptionHandler が自動で付けました。
500:想定外の例外
HTTP/1.1 500
Content-Type: application/problem+json
Connection: close
{"detail":"サーバーでエラーが発生しました。時間をおいて再度お試しください。","instance":"/api/boom","status":500,"title":"Internal Server Error","type":"https://example.com/problems/internal-error","errorId":"e1e4b31d-120d-4026-b672-61c1a1430fab"}
例外のメッセージに入っていた接続先(jdbc:postgresql://db.internal:5432/app)は、レスポンスに出ていません。同じ errorId がログにも出るので、問い合わせを受けたら errorId でログを検索すれば、スタックトレースまでたどれます。
JSON の項目が detail・instance・status・title・type のアルファベット順に並んでいるのは、Jackson 3 の既定(SORT_PROPERTIES_ALPHABETICALLY が有効)によるものです。code・errors などの独自プロパティはその後に並びました。
401と403:Spring Securityを入れたときに誰が返すか
401(未ログイン)と 403(権限なし)は、@RestControllerAdvice ではなく Spring Security のフィルターが返します。 Spring Security の公式リファレンスによると、ExceptionTranslationFilter が AuthenticationException と AccessDeniedException を HTTP の応答に変えます。未ログインなら AuthenticationEntryPoint、ログイン済みで権限が足りなければ AccessDeniedHandler が呼ばれます。
flowchart TD
R[リクエスト] --> F[ExceptionTranslationFilter<br>Security のフィルター]
F -- 通過 --> M[Controller]
M -- 業務の例外 --> A[RestControllerAdvice<br>400・404・409・503]
F -- 未ログイン --> E[AuthenticationEntryPoint<br>401]
F -- 権限なし --> H[AccessDeniedHandler<br>403]
URL の認可(authorizeHttpRequests)で拒否されたときは、リクエストが Controller に届く前に処理が終わります。そのため Advice の @ExceptionHandler は呼ばれません。
何も設定しないときの401・403
Security を入れて httpBasic だけを設定した状態で、管理 API を呼びました。
HTTP/1.1 401
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Type: application/json
{"timestamp":"2026-10-17T03:27:31.112Z","status":401,"error":"Unauthorized","path":"/api/admin/stats"}
HTTP/1.1 403
Content-Type: application/json
{"timestamp":"2026-10-17T03:27:31.347Z","status":403,"error":"Forbidden","path":"/api/admin/stats"}
Spring Boot の /error の JSON(timestamp・error・path)で、ほかのエラーの ProblemDetail(application/problem+json)と形がそろいません。
ProblemDetailの形にそろえる書き方
AuthenticationEntryPoint と AccessDeniedHandler を1つのクラスで実装し、ProblemDetail を書き出します。Spring Boot が用意する JsonMapper には ProblemDetail 用の設定(独自プロパティをトップレベルに出す mixin)が入っているので、それを使います。
package com.example.order.security;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import tools.jackson.databind.json.JsonMapper;
/** Spring Security が返す 401・403 を、Advice と同じ ProblemDetail の形にそろえる */
@Component
public class ProblemDetailSecurityHandler implements AuthenticationEntryPoint, AccessDeniedHandler {
private final JsonMapper jsonMapper; // Spring Boot が用意する JsonMapper(ProblemDetail 用の設定入り)
public ProblemDetailSecurityHandler(JsonMapper jsonMapper) {
this.jsonMapper = jsonMapper;
}
// 未ログイン(認証情報が無い・間違っている)→ 401
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException ex) throws IOException {
response.setHeader("WWW-Authenticate", "Basic realm=\"api\"");
write(request, response, HttpStatus.UNAUTHORIZED, "unauthorized", "ログインが必要です");
}
// ログイン済みだが権限が足りない → 403
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException ex) throws IOException {
write(request, response, HttpStatus.FORBIDDEN, "forbidden", "この操作を行う権限がありません");
}
private void write(HttpServletRequest request, HttpServletResponse response,
HttpStatus status, String type, String detail) throws IOException {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(status, detail);
pd.setType(URI.create("https://example.com/problems/" + type));
pd.setInstance(URI.create(request.getRequestURI()));
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
jsonMapper.writeValue(response.getOutputStream(), pd);
}
}
package com.example.order.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().permitAll())
.httpBasic(basic -> basic.authenticationEntryPoint(problemHandler))
.exceptionHandling(ex -> ex
.authenticationEntryPoint(problemHandler) // 401
.accessDeniedHandler(problemHandler)) // 403
.csrf(csrf -> csrf.disable()) // トークン認証の API の例。画面のあるアプリでは無効にしない
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
// 動作確認用のユーザー(本番ではパスワードをハッシュ化して保存する)
@Bean
UserDetailsService users() {
return new InMemoryUserDetailsManager(
User.withUsername("user").password("{noop}userpass").roles("USER").build(),
User.withUsername("admin").password("{noop}adminpass").roles("ADMIN").build());
}
}
httpBasic の中にも同じ authenticationEntryPoint を渡しています。パスワードが間違っているときの 401 は Basic 認証のフィルター(BasicAuthenticationFilter)が返すためです。exceptionHandling にだけ渡した状態で -u user:wrong を送ると、認証なしの 401 は ProblemDetail になった一方で、間違ったパスワードの 401 は /error の JSON(WWW-Authenticate: Basic realm="Realm")に戻りました。Spring Boot 3.x(Jackson 2)では、tools.jackson.databind.json.JsonMapper の代わりに com.fasterxml.jackson.databind.ObjectMapper を受け取ります。
設定後の応答です。
curl -i http://localhost:25020/api/admin/stats
curl -i -u user:userpass http://localhost:25020/api/admin/stats
HTTP/1.1 401
WWW-Authenticate: Basic realm="api"
Content-Type: application/problem+json;charset=UTF-8
Content-Length: 157
{"detail":"ログインが必要です","instance":"/api/admin/stats","status":401,"title":"Unauthorized","type":"https://example.com/problems/unauthorized"}
HTTP/1.1 403
Content-Type: application/problem+json;charset=UTF-8
Content-Length: 169
{"detail":"この操作を行う権限がありません","instance":"/api/admin/stats","status":403,"title":"Forbidden","type":"https://example.com/problems/forbidden"}
間違ったパスワード(-u user:wrong)でも同じ 401 の ProblemDetail が返り、admin では 200({"orders":42})が返りました。Security の設定の全体像は、関連記事「Spring Security入門|SecurityFilterChainとDB認証」で扱っています。
よくあるつまずき:@PreAuthorize の拒否が500になる
メソッドセキュリティ(@PreAuthorize)の拒否は、Controller のメソッドを呼ぶところで例外になります。そのため Advice の @ExceptionHandler(Exception.class) に先に捕まります。完成形から5番(再スロー)を外して、一般ユーザーで返金 API を呼んだ結果です。
HTTP/1.1 500
Content-Type: application/problem+json
Connection: close
{"detail":"サーバーでエラーが発生しました。時間をおいて再度お試しください。","instance":"/api/orders/1/refund","status":500,"title":"Internal Server Error","type":"https://example.com/problems/internal-error","errorId":"2e93a911-6b00-4739-9506-42d24ea89a71"}
ログには org.springframework.security.authorization.AuthorizationDeniedException: Access Denied が ERROR で出ていました。5番のように AccessDeniedException・AuthenticationException を受けて再スローすると、Spring Security の ExceptionTranslationFilter まで戻り、403 になりました。 Spring Framework の公式リファレンスにも、@ExceptionHandler から元の例外を再スローすると、そのハンドラーが一致しなかったものとして残りの処理に進むと書かれています。
HTTP/1.1 403
Content-Type: application/problem+json;charset=UTF-8
Content-Length: 173
{"detail":"この操作を行う権限がありません","instance":"/api/orders/1/refund","status":403,"title":"Forbidden","type":"https://example.com/problems/forbidden"}
認証せずに同じ API を呼ぶと、同じ流れで 401 の ProblemDetail が返りました。
503とRetry-After:一時的に使えないときの返し方
503の意味と500・504との違い
RFC 9110(15.6.4)は、503 Service Unavailable を「一時的な過負荷か計画メンテナンスのため、サーバーが今はリクエストを処理できない。少し待てば解消する見込みが高い」状態と定めています。サーバーは Retry-After ヘッダーで、待つ時間の目安を伝えてよい(MAY)とされています。
| コード | 意味 | 待てば直るか | 例 |
|---|---|---|---|
| 500 | 想定外の失敗 | 分からない(バグなら直らない) | NullPointerException |
| 503 | 一時的に処理できない | 直る見込みがある | メンテナンス、DB 接続の枯渇 |
| 504 | 上流のサーバーの応答が間に合わない | 状況による | ゲートウェイの読み取りタイムアウト |
| 429 | 呼び出しが多すぎる | 間隔をあければ通る | レート制限 |
504 は nginx などのゲートウェイが返すコードで、アプリ自身が返すものではありません。504 の原因と直し方は、関連記事「504 Gateway Time-outの原因と直し方|502・503との違いと実例」で扱っています。
Retry-Afterの2つの書き方(RFC 9110 10.2.3)
RFC 9110 の 10.2.3 では、Retry-After の値は HTTP-date か、秒数(delay-seconds) のどちらかです。
Retry-After = HTTP-date / delay-seconds
delay-seconds = 1*DIGIT
| 書き方 | 例 | 向いている場面 |
|---|---|---|
| 秒数(0 以上の整数) | Retry-After: 120 |
混雑・接続の枯渇・外部 API の一時停止 |
| HTTP-date | Retry-After: Fri, 31 Dec 1999 23:59:59 GMT |
終了時刻が決まっている計画メンテナンス |
HTTP-date は Sun, 06 Nov 1994 08:49:37 GMT の形(IMF-fixdate)で、時刻は UTC(GMT)で書きます。RFC 9110 は、送る側はこの形で生成すること(MUST)と定めています。日本時間の 6:00 は前日の 21:00 GMT です。
どんなときに付けるか
| 場面 | 返すコード | Retry-After |
|---|---|---|
| 計画メンテナンス中 | 503 | 終了予定の HTTP-date |
| DB の接続プールが枯渇(取得のタイムアウト) | 503 | 数秒(例:5) |
| 依存する外部 API が停止・応答しない | 503 | 外部の復旧見込みに合わせた秒数 |
| アプリが過負荷で受け付けを絞っている | 503 | 数秒〜数十秒 |
| 利用者ごとの回数制限を超えた | 429 | 制限が戻るまでの秒数 |
503 は「サーバー全体(または機能全体)が今は使えない」、429 は「この呼び出し元が多すぎる」です。 RFC 6585 の 429 Too Many Requests も、Retry-After で待つ時間を伝えてよい(MAY)としています。レート制限で 503 を返すと、ほかの利用者も含めて「サービスが止まっている」と受け取られます。
Springでの返し方1:ResponseEntityでヘッダーを付ける
完成形の3番です。業務例外に待ち時間(Duration)を持たせ、ResponseEntity.status(503).header(HttpHeaders.RETRY_AFTER, "3") で返します。
curl -i http://localhost:25020/api/reports/daily
HTTP/1.1 503
Retry-After: 3
Content-Type: application/problem+json
Connection: close
{"detail":"集計サーバーが応答しません。時間をおいて再度お試しください。","instance":"/api/reports/daily","status":503,"title":"Service Unavailable","type":"https://example.com/problems/temporarily-unavailable","code":"REPORT_API_DOWN"}
Springでの返し方2:ErrorResponseExceptionを継承する
ハンドラーを書かずに、例外を投げるだけで 503+Retry-After にする方法です。ErrorResponseException は自分のヘッダーを持てるので、コンストラクタで Retry-After を入れます。HttpHeaders.setZonedDateTime は日時を HTTP-date の形(GMT)にして入れます。
package com.example.order.exception;
import java.net.URI;
import java.time.ZonedDateTime;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.ErrorResponseException;
/** 計画メンテナンス中(503)。Retry-After に終了予定時刻(HTTP-date)を入れる */
public class MaintenanceException extends ErrorResponseException {
public MaintenanceException(ZonedDateTime endsAt) {
super(HttpStatus.SERVICE_UNAVAILABLE, problem(endsAt), null);
// HTTP-date(IMF-fixdate)形式で入る:例 Sat, 17 Oct 2026 06:00:00 GMT
getHeaders().setZonedDateTime(HttpHeaders.RETRY_AFTER, endsAt);
}
private static ProblemDetail problem(ZonedDateTime endsAt) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE,
"メンテナンス中です。終了予定:" + endsAt.toLocalDateTime());
pd.setType(URI.create("https://example.com/problems/maintenance"));
pd.setTitle("Service Unavailable");
pd.setProperty("code", "MAINTENANCE");
return pd;
}
}
日本時間 2026年10月18日 6:00 を渡した応答です。Retry-After は GMT に直した 21:00:00 GMT(前日)になりました。
HTTP/1.1 503
Retry-After: Sat, 17 Oct 2026 21:00:00 GMT
Content-Type: application/problem+json
Connection: close
{"detail":"メンテナンス中です。終了予定:2026-10-18T06:00","instance":"/api/payments","status":503,"title":"Service Unavailable","type":"https://example.com/problems/maintenance","code":"MAINTENANCE"}
ResponseEntityExceptionHandler を継承した Advice があれば、ErrorResponseException の本文とヘッダーはそのまま応答に使われます。ResponseStatusException でも 503 は返せますが、ヘッダーを付けるには getHeaders() を上書きする必要があり、上の書き方のほうが短くなります。
Springでの返し方3:DBの接続取得の失敗を503に変える
DataSourceUtils は、接続を取れないと CannotGetJdbcConnectionException(メッセージは Failed to obtain JDBC Connection)を投げます。HikariCP の接続プールが枯渇したときは、その原因(cause)が SQLTransientConnectionException です。完成形の4番で、この例外を 503+Retry-After: 5 に変えています。
HTTP/1.1 503
Retry-After: 5
Content-Type: application/problem+json
Connection: close
{"detail":"混み合っています。少し時間をおいて再度お試しください。","instance":"/api/stock","status":503,"title":"Service Unavailable","type":"https://example.com/problems/temporarily-unavailable","code":"DB_BUSY"}
ログには ERROR とスタックトレース、Caused by: java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms が残りました。503 にしても、接続の枯渇は調べるべき障害なので ERROR で記録します。SQL の文法エラーなど待っても直らない DB の例外まで 503 にしないよう、例外の型で絞ります。
受け取る側:RestClient・WebClientはRetry-Afterを自動では見ない
Spring Framework 7.0.9(spring-web・spring-webflux・spring-test)と Spring Boot 4.1.1 のクライアント関連モジュールのソースで Retry-After を扱っているのは、ヘッダー名の定数(HttpHeaders.RETRY_AFTER)と、テスト用の MockRestResponseCreators.withTooManyRequests(int) だけでした。RestClient・WebClient には Retry-After を読んで待つ処理は入っていません。7.0 から入った @Retryable・RetryTemplate も、公式リファレンスの設定項目は回数・間隔・倍率・ゆらぎ(jitter)で、Retry-After の記述はありません。
RestClient で 503 を受けたときの、既定の動きです。
org.springframework.web.client.HttpServerErrorException$ServiceUnavailable: 503 Service Unavailable: "{"detail":"集計サーバーが応答しません。時間をおいて再度お試しください。","instance":"/api/reports/daily","status":503,"title":"Service Unavailable","type":"https://example.com/problems/temporarily-unavailable","code":"REPORT_API_DOWN"}"
Retry-After = 3
例外が1回投げられて終わりです。待って送り直すには、自分でヘッダーを読みます。手順は次の4つです。
503 と 429 だけを対象にする
Retry-After を秒数か HTTP-date として読む
待つ時間に上限を設ける
回数に上限を設ける
package com.example.order.client;
import java.time.Duration;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import org.springframework.http.HttpHeaders;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestClient;
public class RetryAfterClient {
private static final int MAX_ATTEMPTS = 4; // 最初の1回+再試行3回
private static final Duration MAX_WAIT = Duration.ofSeconds(60); // 待つ上限
public static void main(String[] args) throws InterruptedException {
RestClient client = RestClient.create("http://localhost:" + args[0]);
long start = System.nanoTime();
String body = getWithRetry(client, "/api/reports/daily", start);
System.out.printf("%5.1fs result: %s%n", (System.nanoTime() - start) / 1e9, body);
}
static String getWithRetry(RestClient client, String uri, long start) throws InterruptedException {
for (int attempt = 1; ; attempt++) {
try {
return client.get().uri(uri).retrieve().body(String.class);
} catch (HttpServerErrorException.ServiceUnavailable | HttpClientErrorException.TooManyRequests e) {
if (attempt >= MAX_ATTEMPTS) throw e;
Duration wait = retryAfter(e.getResponseHeaders(), Duration.ofSeconds(1L << attempt));
System.out.printf("%5.1fs attempt %d: %d, Retry-After=%s -> wait %ds%n",
(System.nanoTime() - start) / 1e9, attempt, e.getStatusCode().value(),
e.getResponseHeaders().getFirst(HttpHeaders.RETRY_AFTER), wait.toSeconds());
Thread.sleep(wait.toMillis());
}
}
}
/** Retry-After の「秒数」と「HTTP-date」の両方を読む。無い・読めないときは fallback */
static Duration retryAfter(HttpHeaders headers, Duration fallback) {
String value = (headers == null) ? null : headers.getFirst(HttpHeaders.RETRY_AFTER);
if (value == null || value.isBlank()) return fallback;
Duration wait;
if (value.chars().allMatch(Character::isDigit)) {
wait = Duration.ofSeconds(Long.parseLong(value)); // 例:Retry-After: 120
} else {
try {
Instant at = ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant();
wait = Duration.between(Instant.now(), at); // 例:Retry-After: Sat, 17 Oct 2026 21:00:00 GMT
} catch (DateTimeParseException ex) {
return fallback;
}
}
if (wait.isNegative()) return Duration.ZERO;
return wait.compareTo(MAX_WAIT) > 0 ? MAX_WAIT : wait;
}
}
最初の2回は 503 を返し、3回目で成功する API(上の /api/reports/daily)に向けて実行した出力です。
0.2s attempt 1: 503, Retry-After=3 -> wait 3s
3.3s attempt 2: 503, Retry-After=3 -> wait 3s
6.3s result: {"orders":128,"date":"2026-10-16"}
Retry-After どおり3秒ずつ待ち、6.3秒で成功しました。retryAfter メソッドにいろいろな値を渡した結果です(実行時刻は 03:23:37 GMT)。
| Retry-After の値 | 待つ時間 | 理由 |
|---|---|---|
120 |
60秒 | 上限(60秒)で切った |
Sat, 17 Oct 2026 21:00:00 GMT |
60秒 | 約17時間先なので上限で切った |
Sat, 17 Oct 2026 03:23:56 GMT |
18.7秒 | 今との差 |
abc |
2秒 | 読めないので fallback |
| ヘッダーなし | 2秒 | fallback |
WebClient でも同じく、onStatus などで応答のヘッダーから Retry-After を取り出し、上の retryAfter と同じ考え方で待つ時間を決めます。
ブラウザとGooglebotの扱い
ブラウザは 503 の本文を表示するだけでした。Retry-After: 2 を付けた 503 のページを Chromium 141(ヘッドレス)で開いて8秒待っても、サーバーに届いたページのリクエストは最初の1回だけで、自動の再読み込みはありませんでした。MDN も、Retry-After への対応はクライアント・サーバーとも「まだばらつきがある」と書いています。画面向けのメンテナンスページには、終了予定の時刻を本文に書きます。
Google のクローラーについて、Google の公式ドキュメントには次のように書かれています。
| 書かれていること | 出典 |
|---|---|
| 5xx と 429 を受けると、クロールを一時的に遅くする | HTTP ステータス コードが Google のクローラーに及ぼす影響 |
| 503・429 に Retry-After を付けて、いつ再試行できるかを示せる(秒数か UTC の日時) | Google のクロール頻度を下げる |
| 1〜2日より長く続けるのは勧めない。同じ URL で数日続くと、インデックスから外れることがある | Google のクロール頻度を下げる |
| 1〜2日だけサイトを止めるなら、503 の案内ページを返し、Retry-After に目安の日時か時間を入れる | ウェブサイトを一時停止または無効化する |
| サーバーエラーを返し続ける URL はインデックスから削除される(すでにある URL もいずれ外れる) | HTTP ステータス コードが Google のクローラーに及ぼす影響 |
| robots.txt に 503 を返すと、すべてのクロールが止まる | ウェブサイトを一時停止または無効化する |
付けすぎの注意:長い503は検索に影響する
- 503 は「数時間〜1〜2日」の一時的な状態に使います。 何日も続く閉鎖は、Google のドキュメントのとおり、200 の案内ページに切り替えます。
- Retry-After を毎回「0」や極端に短い値にすると、クライアントの再試行が集中して、復旧中のサーバーにさらに負荷がかかります。
- 入力ミスや権限不足に 503 を返すと、クライアントが送り直しを続けます。待っても直らない失敗には 4xx を返します。
- 静的ファイルや robots.txt まで 503 にしないよう、メンテナンスの対象(API のパスなど)を絞ります。
迷ったときの判断表:400・405・415・422・429・503の使い分け
どのコードにするか迷う場面を、条件ごとにまとめます。Spring が自動で返すものは「Spring 標準」と書いています。
| 状況 | 条件 | 返すコード | Springでの出どころ |
|---|---|---|---|
| JSON が壊れている・型が違う | 本文として読めない | 400 | Spring 標準 |
| 必須項目が空・範囲外 | 形は正しいが値が不正 | 400(422 を選ぶ API もある) | @Valid の検証エラー |
| 値は正しいが業務上できない | 例:確定済みの注文を変更 | 409(または 422) | 独自例外 |
| 同じキーで二重登録 | 一意制約に違反 | 409 | 独自例外 |
| URL はあるがメソッドが違う | GET だけの所へ DELETE | 405+Allow | Spring 標準 |
| 本文の形式が違う | JSON の所へフォーム | 415+Accept | Spring 標準 |
| 返せる形式が無い | Accept: text/csv だけ |
406 | Spring 標準 |
| ログインしていない | 認証情報が無い・誤り | 401 | Spring Security |
| ログイン済みで権限が無い | ロールが足りない | 403 | Spring Security |
| 呼び出しが多すぎる | 利用者ごとの回数制限 | 429+Retry-After | 独自例外 |
| 一時的に使えない | メンテ・接続枯渇・外部停止 | 503+Retry-After | 独自例外・ErrorResponseException |
| 上記のどれでもない | バグ・想定外 | 500 | @ExceptionHandler(Exception.class) |
422 Unprocessable Content を使うかは API ごとに決めて、全体でそろえます。 Spring の標準は検証エラーを 400 で返すので、この記事の完成形も 400 にしています。
どのハンドラーが選ばれるか:優先順位のルール
例外が起きたとき、Spring MVC は次の順でハンドラーを探します。公式リファレンス(Controller Advice、Exceptions の章)に書かれているルールです。
| 順位 | ルール | 例 |
|---|---|---|
| 1 | Controller 内の @ExceptionHandler が、Advice より先 に使われる |
Controller 内に用意があれば、Advice 側は使われない |
| 2 | 複数の Advice があるときは @Order の値が小さい Advice から 探す |
@Order(1) の Advice が @Order(10) より先 |
| 3 | 同じクラスの中では 投げられた例外に「型の距離」がいちばん近いハンドラー | ResourceNotFoundException 用と Exception 用があれば前者 |
| 4 | 投げられた例外そのものとの一致が、原因(cause)との一致より優先(同じクラス内) | IllegalStateException(cause=IOException) は前者用が優先 |
| 5 | 優先度の高い Advice での cause 一致は、低い Advice でのどの一致より優先 | 優先度の高い Advice に IOException 用があればそちら |
| 6 | どれにも一致しなければ、@ResponseStatus → Spring 標準 → Spring Boot の /error |
下の図 |
flowchart TD
E[Controller で例外] --> R1{ExceptionHandlerExceptionResolver<br>Controller内 → Advice の順に探す}
R1 -- 見つかった --> OK1[そのハンドラーの戻り値を返す]
R1 -- 無い --> R2{ResponseStatusExceptionResolver<br>ResponseStatus 注釈など}
R2 -- 該当 --> ERR[sendError → Spring Boot の /error]
R2 -- 無い --> R3{DefaultHandlerExceptionResolver<br>Spring MVC 標準例外}
R3 -- 該当 --> ERR
R3 -- 無い --> ERR2[サーブレットコンテナへ → /error で 500]
最初に @ExceptionHandler が探されるため、@ExceptionHandler(Exception.class) を置くと、@ResponseStatus を付けた例外や @PreAuthorize の拒否もそこで受け止められます。 直し方は「@ResponseStatus との使い分け」の節と、前の「401と403」の節にあります。
Adviceの対象を絞る・順番を決める
// 管理画面用の Controller だけに効く Advice。他の Advice より先に評価する
@Order(1)
@RestControllerAdvice(basePackages = "com.example.admin")
public class AdminExceptionHandler { ... }
// それ以外すべてに効く Advice
@Order(10)
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { ... }
| 絞り込みの属性 | 対象 |
|---|---|
basePackages / value / basePackageClasses |
指定パッケージ配下の Controller |
assignableTypes |
指定クラス(またはそのサブクラス・実装)の Controller |
annotations |
指定アノテーションが付いた Controller(例:RestController.class) |
公式リファレンスには、これらの絞り込みは実行時に評価されるため、多用すると性能に影響しうる と書かれています。Advice は「全体用1つ+必要な場合だけ専用」くらいに保つと見通しがよくなります。
ProblemDetailで返す:2つの方法と選び方
ProblemDetail は RFC 9457(Problem Details for HTTP APIs)の5つの標準メンバー(type・title・status・detail・instance)と独自プロパティを持つクラスです。Spring Framework の公式リファレンスによると、Jackson の変換では application/problem+json が優先して選ばれます。
方法1:プロパティで有効にする
# application.properties
spring.mvc.problemdetails.enabled=true
Spring Boot 4.1.1 のソースでは、この設定で ProblemDetailsExceptionHandler(ResponseEntityExceptionHandler の子クラス)が次の条件で登録されます。
| 条件 | 意味 |
|---|---|
spring.mvc.problemdetails.enabled=true |
プロパティが true のときだけ |
@ConditionalOnMissingBean(ResponseEntityExceptionHandler.class) |
自分で継承した Bean があれば登録されない |
@Order(0) |
自動登録のハンドラーの優先順位は 0 |
方法2:ResponseEntityExceptionHandlerを継承する
@RestControllerAdvice を付けたクラスで extends ResponseEntityExceptionHandler と書きます。公式リファレンスによると、Spring MVC の標準例外すべてと ErrorResponseException を処理し、RFC 9457 形式の本文で返す基底クラスです。
| 分類 | 処理される例外 |
|---|---|
| リクエストの形 | HttpRequestMethodNotSupportedException、HttpMediaTypeNotSupportedException、HttpMediaTypeNotAcceptableException、HttpMessageNotReadableException |
| パラメーター | MissingPathVariableException、MissingServletRequestParameterException、MissingServletRequestPartException、ServletRequestBindingException、TypeMismatchException、ConversionNotSupportedException |
| 入力チェック | MethodArgumentNotValidException、HandlerMethodValidationException、MethodValidationException |
| 見つからない | NoHandlerFoundException、NoResourceFoundException |
| その他 | ErrorResponseException(ResponseStatusException を含む)、MaxUploadSizeExceededException、AsyncRequestTimeoutException、HttpMessageNotWritableException、AsyncRequestNotUsableException |
どちらを選ぶか
| 状況 | 選ぶ方法 |
|---|---|
| 標準例外を ProblemDetail にできればよく、独自の加工はしない | 方法1(プロパティ) |
入力エラーに errors 配列を足す、ログを出す、503 を返すなど調整したい |
方法2(継承) |
| 独自例外だけ別クラスで処理したい(方法1と併用) | 独自の Advice に @Order(-1) など 0 より小さい値を付ける |
方法2を選んだ場合、プロパティは要りません(自分の Bean があると自動登録されないため)。両方書いても、使われるのは自分の継承クラスです。
既定の文言を日本語にする(MessageSource)
ResponseEntityExceptionHandler は MessageSource から次のキーで文言を探し、ProblemDetail を書き換えます(公式リファレンスによる)。
| 項目 | キー |
|---|---|
type |
problemDetail.type.{例外の完全修飾クラス名} |
title |
problemDetail.title.{例外の完全修飾クラス名} |
detail |
problemDetail.{例外の完全修飾クラス名} |
# src/main/resources/messages.properties
problemDetail.title.org.springframework.http.converter.HttpMessageNotReadableException=リクエストの形式エラー
problemDetail.org.springframework.http.converter.HttpMessageNotReadableException=リクエストの JSON を読み取れませんでした
problemDetail.org.springframework.web.servlet.resource.NoResourceFoundException=指定された URL は存在しません: {0}
NoResourceFoundException の {0} にはリクエストのパスが入ります(公式リファレンスのメッセージ引数の表による)。
ログ出力と情報漏えい防止
エラーの詳細はサーバーのログに、利用者には「何が起きて、どうすればよいか」だけを返す のが基本です。
実際に出たログ
上の curl を送ったときのアプリのログです(時刻・スレッド名は省き、長い行は途中で切っています)。
WARN c.e.order.web.GlobalExceptionHandler : client error: status=400 exception=MethodArgumentNotValidException message=Validation failed for argument [0] in public …
WARN c.e.order.web.GlobalExceptionHandler : client error: status=400 exception=HandlerMethodValidationException message=400 BAD_REQUEST "Validation failure"
WARN c.e.order.web.GlobalExceptionHandler : client error: status=400 exception=HttpMessageNotReadableException message=JSON parse error: Unexpected character ('}' (code 125)): expected a value
WARN c.e.order.web.GlobalExceptionHandler : client error: status=400 exception=MethodArgumentTypeMismatchException message=Method parameter 'id': Failed to convert value of type 'java.lang.String' to required type 'long'; For input string: "abc"
INFO c.e.order.web.GlobalExceptionHandler : not found: code=ORDER_NOT_FOUND message=注文が見つかりません: id=999
WARN c.e.order.web.GlobalExceptionHandler : client error: status=404 exception=NoResourceFoundException message=No static resource api/orderz for request '/api/orderz'.
WARN c.e.order.web.GlobalExceptionHandler : client error: status=404 exception=ResponseStatusException message=404 NOT_FOUND "商品が見つかりません: id=7"
WARN o.s.web.servlet.PageNotFound : Request method 'DELETE' is not supported
WARN c.e.order.web.GlobalExceptionHandler : client error: status=405 exception=HttpRequestMethodNotSupportedException message=Request method 'DELETE' is not supported
INFO c.e.order.web.GlobalExceptionHandler : business rule: code=STOCK_SHORTAGE message=在庫が足りません: itemId=A-100 在庫=10
WARN c.e.order.web.GlobalExceptionHandler : client error: status=415 exception=HttpMediaTypeNotSupportedException message=Content-Type 'application/x-www-form-urlencoded;charset=UTF-8' is not supported
ERROR c.e.order.web.GlobalExceptionHandler : unexpected error: errorId=e1e4b31d-120d-4026-b672-61c1a1430fab method=GET uri=/api/boom
java.lang.IllegalStateException: DB接続先 jdbc:postgresql://db.internal:5432/app に接続できません
at com.example.order.web.OrderController.boom(OrderController.java:59) ~[classes/:na]
WARN c.e.order.web.GlobalExceptionHandler : temporarily unavailable: code=REPORT_API_DOWN retryAfter=3s
ERROR c.e.order.web.GlobalExceptionHandler : cannot get jdbc connection
org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection
レスポンスでは固定文だった壊れた JSON の詳細(Unexpected character ('}' …))や、500 の接続先は、ログにだけ出ています。401・403 は Security のフィルターが返すため、この Advice のログには出ません。記録したい場合は ProblemDetailSecurityHandler の中でログを出します。
レスポンスに含めない情報
| 含めない情報 | 理由 | 代わりにすること |
|---|---|---|
| スタックトレース | クラス名・ライブラリの版・処理の流れが分かる | ログにだけ出し、errorId を返す |
ex.getMessage()(想定外の例外) |
SQL 文、テーブル名、ファイルパス、接続先が入ることがある | 固定文にする |
| 例外のクラス名 | 内部構造が分かる | code など自分で決めた値にする |
| 入力値そのもの(パスワード、トークンなど) | 画面やログ収集基盤に残る | 項目名だけを返す |
ex.getMessage() を detail に使うのは、自分で定義した独自例外で、メッセージを利用者向けに書いていると分かっている場合だけ にします。完成形では、独自例外(404・409・503)だけ ex.getMessage() を返し、Exception 用では固定文にしています。
Spring Boot の /error の既定値
@ExceptionHandler で受け止めなかった例外は、Spring Boot の /error(BasicErrorController)が JSON を返します。Spring Boot 4.1.1 のソースでは、この設定は spring.web.error の下にあり、include-message・include-stacktrace・include-binding-errors の既定値はどれも never です(Spring Boot 3.x では server.error の下)。既定ではメッセージやスタックトレースは返りません。 開発時に always に変えた設定は、プロファイルごとの設定ファイル(application-dev.properties など)に分けておくと、本番に持ち込まずに済みます。
ステータスごとのログレベル
| 種類 | 例 | ログレベル | スタックトレース |
|---|---|---|---|
| 利用者の入力が原因(400・404・405・415) | 検証エラー、JSON の形式、存在しない URL | WARN または INFO | 出さない(1行) |
| 業務ルールによる拒否(409) | 在庫不足 | INFO | 出さない |
| 一時的に使えない(503) | 外部 API の停止、接続の枯渇 | WARN または ERROR | 枯渇など調べるものは出す |
| サーバー側の想定外(500) | NullPointerException | ERROR | 出す |
@ExceptionHandler で処理した例外は、Spring が自動ではログに出しません。 ハンドラーの中で自分でログを出さないと、500 が返っているのにログに何も残らない状態になります。SLF4J の log.error(..., ex) の最後の引数に例外を渡すと、スタックトレースがログに出ます。
@ResponseStatus・ResponseStatusException・ErrorResponseExceptionとの使い分け
| 方法 | 書き方 | レスポンス | 向いている場面 |
|---|---|---|---|
@ResponseStatus を例外クラスに付ける |
@ResponseStatus(HttpStatus.NOT_FOUND) |
/error の JSON(ProblemDetail ではない) |
画面のアプリ、ステータスだけ決めたい |
ResponseStatusException を投げる |
throw new ResponseStatusException(...) |
継承した Advice があれば ProblemDetail | 例外クラスを作るほどでもない処理 |
ErrorResponseException を継承 |
super(HttpStatus.XXX, problemDetail, null) |
ProblemDetail(ヘッダーも持てる) | type・code・Retry-After を固定したい |
@ExceptionHandler で処理 |
Advice にハンドラーを書く | 自由に組み立てられる | ログ・errorId・errors の共通化(基本) |
よくあるつまずき:@ResponseStatusの例外が500になる
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ItemNotFoundException extends RuntimeException { ... }
この例外を、完成形の Advice(@ExceptionHandler(Exception.class) あり)のアプリで投げた結果です。
HTTP/1.1 500
Content-Type: application/problem+json
Connection: close
{"detail":"サーバーでエラーが発生しました。時間をおいて再度お試しください。","instance":"/api/legacy/5","status":500,"title":"Internal Server Error","type":"https://example.com/problems/internal-error","errorId":"4feea94e-4384-4bc0-806c-1a84bb0b06c8"}
404 のつもりが 500 になりました。 前述の図のとおり、@ExceptionHandler の探索が @ResponseStatus の処理より先に行われるためです。直し方は次のどれかです。
| 直し方 | 内容 |
|---|---|
| 専用のハンドラーを書く | @ExceptionHandler(ItemNotFoundException.class) を追加する |
ErrorResponseException を継承する |
ResponseEntityExceptionHandler が処理するので 404 の ProblemDetail になる |
ResponseStatusException を投げる |
上の実測のとおり 404 の ProblemDetail になる |
Exception 用ハンドラーで判定する |
AnnotatedElementUtils.findMergedAnnotation(ex.getClass(), ResponseStatus.class) があればそのステータスを使う |
MockMvcでテストする
Advice が意図どおりのステータスと JSON を返すかは、MockMvc で確かめます。Spring Boot の版によって import が変わるテスト用アノテーションを使わず、spring-test だけで書ける standaloneSetup を使います(spring-boot-starter-test に含まれます)。
package com.example.order.web;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.example.order.exception.ResourceNotFoundException;
import com.example.order.exception.TemporarilyUnavailableException;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.*;
class GlobalExceptionHandlerTest {
// テスト専用の Controller:各例外をわざと起こす
@RestController
@RequestMapping("/test")
static class TestController {
record OrderRequest(@NotBlank String itemId, @Min(1) int quantity) {}
@GetMapping("/orders/{id}")
String notFound(@PathVariable long id) {
throw new ResourceNotFoundException("ORDER_NOT_FOUND", "注文が見つかりません: id=" + id);
}
@PostMapping("/orders")
String create(@Valid @RequestBody OrderRequest req) { return "ok"; }
@GetMapping("/busy")
String busy() {
throw new TemporarilyUnavailableException("REPORT_API_DOWN", "集計サーバーが応答しません",
Duration.ofSeconds(30), null);
}
@GetMapping("/boom")
String boom() {
throw new IllegalStateException("DB接続先 jdbc:postgresql://db.internal:5432/app に接続できません");
}
}
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TestController())
.setControllerAdvice(new GlobalExceptionHandler())
.build();
@Test
void 独自例外は404のProblemDetailになる() throws Exception {
mockMvc.perform(get("/test/orders/999"))
.andExpect(status().isNotFound())
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.title").value("Resource Not Found"))
.andExpect(jsonPath("$.detail").value("注文が見つかりません: id=999"))
.andExpect(jsonPath("$.code").value("ORDER_NOT_FOUND"))
.andExpect(jsonPath("$.instance").value("/test/orders/999"));
}
@Test
void 検証エラーは400で項目ごとのerrorsが返る() throws Exception {
mockMvc.perform(post("/test/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"itemId": "", "quantity": 0}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.detail").value("入力内容に誤りがあります"))
.andExpect(jsonPath("$.errors.length()").value(2));
}
@Test
void 壊れたJSONは400で既定のdetailが返る() throws Exception {
mockMvc.perform(post("/test/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"itemId\": \"A-100\", \"quantity\": }"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.detail").value("Failed to read request"));
}
@Test
void 一時的に使えないときは503とRetryAfterが返る() throws Exception {
mockMvc.perform(get("/test/busy"))
.andExpect(status().isServiceUnavailable())
.andExpect(header().string("Retry-After", "30"))
.andExpect(jsonPath("$.code").value("REPORT_API_DOWN"));
}
@Test
void 想定外の例外は500で内部情報を返さない() throws Exception {
mockMvc.perform(get("/test/boom"))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.errorId").exists())
.andExpect(jsonPath("$.trace").doesNotExist())
.andExpect(content().string(not(containsString("jdbc:"))))
.andExpect(content().string(not(containsString("IllegalStateException"))));
}
}
JUnit 6.0.3 で実行した結果です。5件すべて成功しました。
[OK] 想定外の例外は500で内部情報を返さない()
[OK] 壊れたJSONは400で既定のdetailが返る()
[OK] 一時的に使えないときは503とRetryAfterが返る()
[OK] 独自例外は404のProblemDetailになる()
[OK] 検証エラーは400で項目ごとのerrorsが返る()
Test run finished after 1020 ms
[ 5 tests found ]
[ 5 tests successful ]
[ 0 tests failed ]
| 確かめていること | なぜ確かめるか |
|---|---|
content().contentType(APPLICATION_PROBLEM_JSON) |
ProblemDetail は application/problem+json で返る |
jsonPath("$.code") |
setProperty がトップレベルに展開されているか |
jsonPath("$.instance") |
未設定の instance にパスが入るか |
jsonPath("$.errors.length()") |
項目ごとのエラーがそろっているか |
header().string("Retry-After", "30") |
503 に再試行の目安が付いているか |
content().string(not(containsString("jdbc:"))) |
例外メッセージの内部情報が漏れていないか |
standaloneSetup では、Hibernate Validator がクラスパスにあれば @Valid の検証も動きました(setValidator は書いていません)。500 のテストで「返さないこと」を確かめておくと、後からハンドラーを変えたときの情報漏えいに気づけます。 standaloneSetup には Spring Security のフィルターが入らないため、401・403 は @WebMvcTest などアプリの設定を読み込むテストで確かめます。その書き方は、関連記事「Spring Bootテスト入門|@WebMvcTestとMockMvcの使い分け」で扱っています。
よくある質問
Q. Spring Boot 例外処理の基本の書き方は?
A. @RestControllerAdvice を付けたクラスで ResponseEntityExceptionHandler を継承し、独自例外ごとに @ExceptionHandler で ProblemDetail を返します。完成形は「コピペ用グローバルハンドラー」の節にあります。
Q. @ControllerAdvice と @RestControllerAdvice の違いは?
A. @RestControllerAdvice は @ControllerAdvice に @ResponseBody を足したもので、戻り値がすべて本文になります。REST API なら @RestControllerAdvice を使います。
Q. spring boot エラー ステータスコードはどう返し分ける? A. 呼び出し側が直せる失敗は 4xx(入力 400・無い 404・状態の食い違い 409)、待てば直る失敗は 503、それ以外だけ 500 にします。冒頭の早見表と「迷ったときの判断表」にまとめています。
Q. ResponseStatusException を投げると ProblemDetail になりますか?
A. ResponseEntityExceptionHandler を継承した Advice があれば、ProblemDetail になり、理由の文字列が detail に入りました。無い場合は Spring Boot の /error の JSON になります。
Q. spring boot 503 の返し方は?
A. 業務例外を @ExceptionHandler で受けて ResponseEntity.status(503).header(HttpHeaders.RETRY_AFTER, "30") と ProblemDetail を返すか、ErrorResponseException を継承してヘッダーに Retry-After を入れます。「503とRetry-After」の節に3通りの実例があります。
Q. Retry-After は秒数と日時のどちらで書きますか? A. RFC 9110 ではどちらも使えます。混雑や一時停止は秒数、終了時刻が決まったメンテナンスは HTTP-date(GMT)が向いています。
Q. RestClient は Retry-After を見て自動で待ちますか?
A. 待ちません。Spring Framework 7.0.9 の RestClient・WebClient・@Retryable は Retry-After を読まないので、ヘッダーを読んで待つコードを自分で書きます(記事内の RetryAfterClient)。
Q. フィルターや Spring Security で起きた例外も @RestControllerAdvice で処理できますか?
A. @ControllerAdvice が処理するのは、DispatcherServlet が Controller を呼ぶ過程で起きた例外です。フィルターの中の例外は対象外で、Spring Security の 401・403 は AuthenticationEntryPoint と AccessDeniedHandler で ProblemDetail にそろえます。
Q. spring.mvc.problemdetails.enabled=true と継承は両方要りますか?
A. どちらか一方です。自分で ResponseEntityExceptionHandler を継承した Bean があると、Spring Boot の自動登録のハンドラーは作られません。
まとめ
- 500 は想定外の失敗だけ。 入力は 400、無いは 404、状態の食い違いは 409、未ログインは 401、権限なしは 403、一時的に使えないは 503
- REST API は
@RestControllerAdvice+ResponseEntityExceptionHandlerの継承 で、標準例外も独自例外も ProblemDetail にそろう。405 のAllow・415 のAcceptも自動で付いた - 401・403 は Spring Security のフィルターが返す。
AuthenticationEntryPoint・AccessDeniedHandlerで同じ形にし、@PreAuthorizeの拒否は Advice で再スローする - 503 には Retry-After(秒数か HTTP-date)を付ける。 レート制限は 429。長く続く 503 は検索のインデックスから外れることがある
- RestClient は Retry-After を自動では読まない。読んで待つ・上限を決める コードを書く
- 500 では スタックトレースや例外メッセージを返さず、ログに出して
errorIdでつなぐ - MockMvc の
standaloneSetupで、ステータス・Retry-After・「内部情報を返さないこと」までテストする
参考・出典
確認日はいずれも 2026年10月17日です。
- RFC 9110 HTTP Semantics(10.2.3 Retry-After、15.6.4 503 Service Unavailable、5.6.7 Date/Time Formats):https://www.rfc-editor.org/rfc/rfc9110
- RFC 6585 Additional HTTP Status Codes(4. 429 Too Many Requests):https://www.rfc-editor.org/rfc/rfc6585
- RFC 9457 Problem Details for HTTP APIs:https://www.rfc-editor.org/rfc/rfc9457
- Spring Framework リファレンス Error Responses:https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-rest-exceptions.html
- Spring Framework リファレンス Exceptions(@ExceptionHandler):https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-exceptionhandler.html
- Spring Framework リファレンス Resilience Features:https://docs.spring.io/spring-framework/reference/core/resilience.html
- Spring Security リファレンス Architecture(Handling Security Exceptions):https://docs.spring.io/spring-security/reference/servlet/architecture.html
- Google 検索セントラル「HTTP ステータス コードが Google のクローラーに及ぼす影響」:https://developers.google.com/search/docs/crawling-indexing/http-network-errors
- Google 検索セントラル「ウェブサイトを一時停止または無効化する」:https://developers.google.com/search/docs/crawling-indexing/pause-online-business
- Google for Developers「Google のクロール頻度を下げる」:https://developers.google.com/crawling/docs/crawlers-fetchers/reduce-crawl-rate
- MDN Retry-After:https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After
- Spring Boot(GitHub v4.1.1 のソース):https://github.com/spring-projects/spring-boot
- Spring Framework(GitHub v7.0.9 のソース):https://github.com/spring-projects/spring-framework
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Spring Boot 例外処理|ステータスコードの返し分けと503実例」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。