zukucode
主にWEB関連の情報を技術メモとして発信しています。

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エラーの短い概要
statusHTTPステータスコード
detail必要な場合だけ返す補足情報
instanceエラーが発生したリクエストのパス
codeフロントエンドが処理を分岐する安定したコード
traceIdサーバーのログと照合するID

画面の処理はtitledetailの文章ではなく、statuscodeで分岐します。文章は変更や翻訳があるため、プログラムの判定には適していません。

アプリケーションの例外を定義する

例として、対象データが見つからない場合と、同時更新を検出した場合の例外を定義します。

ApiException.cs
public 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.cs
using 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.cs
using 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.cs
builder.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.ts
export 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.ts
export 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()を確認し、errorsfieldに対応する入力欄へメッセージを設定します。

エラーを処理する場所を分ける

すべてのエラーを各画面で処理すると、同じ分岐が増えていきます。エラーの種類によって処理する場所を分けます。

エラー処理する場所の例
入力エラーフォームの各入力欄
保存時の業務エラー操作した画面のメッセージ領域
401 Unauthorized認証処理またはログイン画面への遷移
404 Not Foundルート単位のエラー画面
500などの想定外エラーError Boundaryや共通エラー画面

画面内で利用者が修正できるエラーは、その操作の近くに表示します。画面を継続できないエラーは、ルート単位のエラー画面で扱います。

再試行するエラーを限定する

取得処理を自動再試行する場合、すべてのエラーを対象にしてはいけません。

queryClient.ts
import { 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;
      },
    },
  },
});

400401403404などは、同じリクエストを送っても通常は成功しません。通信障害や一時的な5xxだけを再試行の候補にします。

更新APIは、再試行によって同じ処理が複数回実行される可能性があります。自動再試行するなら、冪等な操作であること、または冪等性キーで重複実行を防げることを確認します。

ステータスコードとエラーコードを使い分ける

HTTPステータスは、HTTPクライアントや監視ツールが理解できる大分類として使用します。アプリケーション固有の違いはcodeで表します。

状況ステータスcodeの例
入力内容が不正400VALIDATION_FAILED
認証が必要401UNAUTHORIZED
権限がない403FORBIDDEN
データがない404ARTICLE_NOT_FOUND
同時更新を検出412PRECONDITION_FAILED
業務上処理できない422ARTICLE_ALREADY_PUBLISHED
回数制限を超えた429RATE_LIMIT_EXCEEDED
想定外の障害500SYSTEM_ERROR

すべてを400 Bad Requestで返すと、認証切れ、データなし、入力不備を共通処理で区別しにくくなります。

注意点

例外メッセージやスタックトレースには、SQL、ファイルパス、内部IDなどが含まれる場合があります。

想定外の例外は共通のメッセージへ変換し、詳細はtraceIdと一緒にサーバーのログへ記録します。

codeはフロントエンドとの契約になります。クラス名や英語メッセージから自動生成せず、変更しにくい識別子として明示的に定義します。

エラー形式を統一すると、画面ごとの条件分岐を減らせます。同時に、ログの追跡、入力エラーの表示、自動再試行の判断も同じルールで実装できます。


関連記事