ASP.NET CoreとReactでAPIエラーの形式と処理を統一する
ASP.NET CoreでAPIを作成していると、エンドポイントごとに異なる形式のエラーを返してしまうことがあります。
React側では、エラー形式が統一されていないと、ステータスコードやレスポンス本文を画面ごとに判定しなければなりません。
この記事では、ASP.NET Coreの例外をProblem Details形式へ変換し、Reactから同じ方法で扱えるようにします。
エラーの形式を統一する理由
APIから次のようなレスポンスが混在すると、フロントエンドの処理が複雑になります。
エラー形式が統一されていない例{ "message": "データが見つかりません" }別のエンドポイントの例{
"error": true,
"errorCode": "NOT_FOUND"
}HTTPステータスだけでも大まかな原因は分かりますが、画面が次の動作を決めるには情報が足りない場合があります。
- 入力項目のどこにエラーを表示するか
- ログイン画面へ移動するか
- データを再取得するか
- 自動的に再試行するか
- 問い合わせ時にどのログを確認するか
そこで、すべてのエラーに共通する形式を決めます。
Problem Detailsのレスポンスを決める
Problem Detailsは、HTTP APIのエラーを表すための共通形式です。
今回は標準的な項目に加えて、アプリケーションで使用するcodeと、ログを特定するためのtraceIdを返します。
エラーレスポンス{
"type": "https://api.example.com/problems/article-not-found",
"title": "Resource Not Found",
"status": 404,
"detail": null,
"instance": "/api/articles/42",
"code": "ARTICLE_NOT_FOUND",
"traceId": "0HNB7M8T2A1F3:00000001"
}各項目の役割は次のとおりです。
| 項目 | 用途 |
|---|---|
type | エラー種別を説明するURLまたは識別子 |
title | エラーの短い概要 |
status | HTTPステータスコード |
detail | 必要な場合だけ返す補足情報 |
instance | エラーが発生したリクエストのパス |
code | フロントエンドが処理を分岐する安定したコード |
traceId | サーバーのログと照合するID |
画面の処理はtitleやdetailの文章ではなく、statusとcodeで分岐します。文章は変更や翻訳があるため、プログラムの判定には適していません。
アプリケーションの例外を定義する
例として、対象データが見つからない場合と、同時更新を検出した場合の例外を定義します。
ApiException.cspublic abstract class ApiException(string code, string message)
: Exception(message)
{
public string Code { get; } = code;
}
public sealed class ArticleNotFoundException(Guid articleId)
: ApiException(
"ARTICLE_NOT_FOUND",
$"Article '{articleId}' was not found."
);
public sealed class PreconditionFailedException()
: ApiException(
"PRECONDITION_FAILED",
"The resource has already been updated."
);例外のメッセージにはログで必要な情報を含められますが、そのままレスポンスへ返す必要はありません。
データベースの情報やスタックトレースをクライアントへ返すと、内部構造を公開することになります。利用者向けの情報と、運用者がログで確認する情報は分けます。
例外をProblemDetailsへ変換する
例外の種類に応じて、HTTPステータスとエラーコードを決定します。
ApiProblemFactory.csusing Microsoft.AspNetCore.Mvc;
public static class ApiProblemFactory
{
public static ProblemDetails Create(
HttpContext context,
Exception exception
)
{
return exception switch
{
ArticleNotFoundException error => CreateProblem(
context,
StatusCodes.Status404NotFound,
"Resource Not Found",
error.Code
),
PreconditionFailedException error => CreateProblem(
context,
StatusCodes.Status412PreconditionFailed,
"Precondition Failed",
error.Code
),
UnauthorizedAccessException => CreateProblem(
context,
StatusCodes.Status401Unauthorized,
"Unauthorized",
"UNAUTHORIZED"
),
_ => CreateProblem(
context,
StatusCodes.Status500InternalServerError,
"System Error",
"SYSTEM_ERROR"
),
};
}
private static ProblemDetails CreateProblem(
HttpContext context,
int status,
string title,
string code
)
{
var problem = new ProblemDetails
{
Type = $"https://api.example.com/problems/{code.ToLowerInvariant()}",
Title = title,
Status = status,
Instance = context.Request.Path,
};
problem.Extensions["code"] = code;
problem.Extensions["traceId"] = context.TraceIdentifier;
return problem;
}
}想定外の例外は500 Internal Server Errorに変換します。例外メッセージは返さず、サーバー側で例外とtraceIdをログへ記録します。
IExceptionHandlerで一括処理する
IExceptionHandlerを実装すると、コントローラーごとにtry-catchを書く必要がありません。
GlobalExceptionHandler.csusing Microsoft.AspNetCore.Diagnostics;
public sealed class GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger
) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext context,
Exception exception,
CancellationToken cancellationToken
)
{
var problem = ApiProblemFactory.Create(context, exception);
if (problem.Status >= StatusCodes.Status500InternalServerError)
{
logger.LogError(
exception,
"Unhandled exception. TraceId: {TraceId}",
context.TraceIdentifier
);
}
context.Response.StatusCode =
problem.Status ?? StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(
problem,
cancellationToken
);
return true;
}
}サービスとミドルウェアを登録します。
Program.csbuilder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();これで、処理中に投げられた例外が共通形式のレスポンスへ変換されます。
入力エラーには項目名を含める
入力エラーでは、どの項目に何を表示するかをReactへ伝える必要があります。
入力エラー{
"type": "https://api.example.com/problems/validation-failed",
"title": "Validation Failed",
"status": 400,
"instance": "/api/articles",
"code": "VALIDATION_FAILED",
"traceId": "0HNB7M8T2A1F3:00000002",
"errors": [
{
"field": "title",
"code": "MAX_LENGTH",
"message": "タイトルは100文字以内で入力してください"
}
]
}fieldはReactのフォームで使用する名前と合わせます。サーバー側のC#プロパティ名がTitleで、フロントエンドがtitleなら、レスポンスを作るときにcamelCaseへ変換します。
エラーを配列にすると、同じ項目に複数の検証結果がある場合や、フォーム全体のエラーを追加する場合にも対応できます。
React側でApiErrorへ変換する
フロントエンドでは、Problem Detailsの型と専用のエラークラスを定義します。
apiError.tsexport type ValidationError = {
field: string;
code: string;
message: string;
};
export type ApiProblem = {
type?: string;
title?: string;
status?: number;
detail?: string;
instance?: string;
code: string;
traceId?: string;
errors?: ValidationError[];
};
export class ApiError extends Error {
constructor(readonly problem: ApiProblem) {
super(problem.title ?? 'API Error');
this.name = 'ApiError';
}
get status() {
return this.problem.status;
}
get code() {
return this.problem.code;
}
get traceId() {
return this.problem.traceId?.trim() || undefined;
}
isValidationError(): boolean {
return this.status === 400 && this.code === 'VALIDATION_FAILED';
}
}APIクライアントでは、成功以外のレスポンスをApiErrorへ変換します。
request.tsexport const request = async <T>(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<T> => {
const response = await fetch(input, init);
if (!response.ok) {
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/problem+json')) {
const problem = (await response.json()) as ApiProblem;
throw new ApiError(problem);
}
throw new Error(`API request failed: ${response.status}`);
}
return response.json() as Promise<T>;
};フォームではisValidationError()を確認し、errorsのfieldに対応する入力欄へメッセージを設定します。
エラーを処理する場所を分ける
すべてのエラーを各画面で処理すると、同じ分岐が増えていきます。エラーの種類によって処理する場所を分けます。
| エラー | 処理する場所の例 |
|---|---|
| 入力エラー | フォームの各入力欄 |
| 保存時の業務エラー | 操作した画面のメッセージ領域 |
401 Unauthorized | 認証処理またはログイン画面への遷移 |
404 Not Found | ルート単位のエラー画面 |
500などの想定外エラー | Error Boundaryや共通エラー画面 |
画面内で利用者が修正できるエラーは、その操作の近くに表示します。画面を継続できないエラーは、ルート単位のエラー画面で扱います。
再試行するエラーを限定する
取得処理を自動再試行する場合、すべてのエラーを対象にしてはいけません。
queryClient.tsimport { QueryClient } from '@tanstack/react-query';
import { ApiError } from './apiError';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (
error instanceof ApiError &&
error.status !== undefined &&
error.status < 500
) {
return false;
}
return failureCount < 3;
},
},
},
});400、401、403、404などは、同じリクエストを送っても通常は成功しません。通信障害や一時的な5xxだけを再試行の候補にします。
更新APIは、再試行によって同じ処理が複数回実行される可能性があります。自動再試行するなら、冪等な操作であること、または冪等性キーで重複実行を防げることを確認します。
ステータスコードとエラーコードを使い分ける
HTTPステータスは、HTTPクライアントや監視ツールが理解できる大分類として使用します。アプリケーション固有の違いはcodeで表します。
| 状況 | ステータス | codeの例 |
|---|---|---|
| 入力内容が不正 | 400 | VALIDATION_FAILED |
| 認証が必要 | 401 | UNAUTHORIZED |
| 権限がない | 403 | FORBIDDEN |
| データがない | 404 | ARTICLE_NOT_FOUND |
| 同時更新を検出 | 412 | PRECONDITION_FAILED |
| 業務上処理できない | 422 | ARTICLE_ALREADY_PUBLISHED |
| 回数制限を超えた | 429 | RATE_LIMIT_EXCEEDED |
| 想定外の障害 | 500 | SYSTEM_ERROR |
すべてを400 Bad Requestで返すと、認証切れ、データなし、入力不備を共通処理で区別しにくくなります。
注意点
例外メッセージやスタックトレースには、SQL、ファイルパス、内部IDなどが含まれる場合があります。
想定外の例外は共通のメッセージへ変換し、詳細はtraceIdと一緒にサーバーのログへ記録します。
codeはフロントエンドとの契約になります。クラス名や英語メッセージから自動生成せず、変更しにくい識別子として明示的に定義します。
エラー形式を統一すると、画面ごとの条件分岐を減らせます。同時に、ログの追跡、入力エラーの表示、自動再試行の判断も同じルールで実装できます。