- 設計
エラー処理設計の方針|業務エラーとシステムエラーの分類表とテンプレート
エラー処理設計の方針とは、起こりうるエラーを「業務エラー」と「システムエラー」に分け、どの層で検知し、どこで捕捉し、利用者・ログ・運用担当へ何を伝えるかをあらかじめ決めておくことです。 方針が1枚の表になっていると、実装する人ごとの判断のばらつきがなくなり、設計書にもそのまま貼れます。
この記事では、エラー区分ごとの方針表、層ごとの責務と捕捉位置の決め方、エラーコード体系とエラー一覧のテンプレート(Markdown・タブ区切り)、Java・TypeScript・Python で業務例外とシステム例外を分けて最上位でまとめて処理する最小コード、リトライ回数と待ち時間の早見表をまとめました。応答の形式やメッセージの文言ではなく、層ごとの責務と捕捉位置に絞って扱います。題材は架空の「社内の備品予約システム」です。
確認環境:Linux 上の OpenJDK 21.0.10、Node.js 22.22.2 と TypeScript 7.0.2、Python 3.11.15 で、掲載したコードをすべて実行しています。
エラー処理方針の早見表
| エラー区分 | 例 | 検知する層 | 利用者への表示 | ログレベル | 自動リトライ | 通知先 |
|---|---|---|---|---|---|---|
| 入力エラー | 必須項目が空、日付の形式が違う | プレゼンテーション層 | 項目ごとに直し方を表示 | 出さない、または INFO | しない | なし |
| 業務ルールエラー | 予約済みの備品、貸出上限の超過 | アプリケーション層・ドメイン層 | 理由と次にできる操作を表示 | INFO または WARN(スタックトレースなし) | しない | なし(件数を定期的に集計) |
| 認証・権限エラー | 未ログイン、権限のない操作 | 入口の共通処理(フィルター、ミドルウェア) | ログインの案内、権限がない旨 | WARN | しない | 短時間に多発したら管理者 |
| 競合エラー | 同時更新で版が合わない | インフラ層で検知し、アプリケーション層で判断 | 最新の内容を読み直す案内 | INFO | 条件つき(読み直して1回まで) | なし |
| 一時的なシステムエラー | 接続のタイムアウト、接続の切断 | インフラ層 | 時間をおいて再操作する案内 | 回復したら WARN、回復しなければ ERROR | する(回数と待ち時間に上限) | 続いたら運用担当 |
| 継続するシステムエラー | 設定の誤り、ディスクの容量不足 | インフラ層 | 汎用の文言と問い合わせ用のコード | ERROR(スタックトレースあり) | しない | すぐに運用担当 |
| 外部連携エラー | 連携先が応答しない、連携先がエラーを返す | インフラ層(連携用の部品) | 連携先の都合で完了できない旨 | ERROR | 相手側の一時的な不調だけする | 運用担当と連携先の窓口 |
| 想定外のエラー | null の参照、範囲外の添字(プログラムの不具合) | 最上位の共通処理 | 汎用の文言と問い合わせ用のコード | ERROR(スタックトレースあり) | しない | 開発担当 |
上の4行が広い意味での業務エラー、下の4行がシステムエラーです。業務エラーは利用者が操作を変えれば解消し、システムエラーは利用者には直せないという違いが、表示・ログ・通知のすべてを決めます。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「エラー処理設計の方針|業務エラーとシステムエラーの分類表とテンプレート」
業務エラーとシステムエラーの違い
| 観点 | 業務エラー | システムエラー |
|---|---|---|
| 意味 | 業務のルールとして受け付けられない | 処理を続けるための土台に問題がある |
| 仕様との関係 | 仕様書に書かれた正常な分岐の1つ | 仕様書の想定の外、または環境の問題 |
| 直せる人 | 利用者(入力や操作を変える) | 運用担当・開発担当 |
| 利用者への表示 | 理由と次の操作を具体的に | 内部の事情は出さず、汎用の文言とコード |
| ログ | 件数がわかれば足りる。スタックトレースは不要 | 原因の例外とスタックトレースを残す |
| 通知 | しない | する |
| 自動リトライ | しない(同じ結果になる) | 一時的なものだけする |
| テスト | 単体テストで全パターンを確かめる | 代表的なものを障害の注入で確かめる |
迷ったときの判定表
上から順に当てはめ、最初に「はい」になった行で決めます。
| 順 | 問い | はい の場合 |
|---|---|---|
| 1 | 利用者が入力や操作を変えれば通るか | 業務エラー(入力エラーまたは業務ルールエラー) |
| 2 | 同じ操作を少し後に行えば通る見込みがあるか | 一時的なシステムエラー |
| 3 | 設定・環境・連携先を直せば通るか | 継続するシステムエラーまたは外部連携エラー |
| 4 | プログラムを直さないと通らないか | 想定外のエラー(不具合) |
「在庫がない」「期限を過ぎている」のように、起きることが仕様でわかっているものは業務エラーです。発生頻度が低くても、仕様書に書ける分岐ならシステムエラーにはしません。逆に、業務エラーを ERROR レベルで記録すると、運用担当に届く通知が増え、本当に対応が必要なエラーが埋もれます。
層ごとの責務と捕捉位置の決め方
層ごとの責務表
| 層 | 検知するもの | 投げる例外 | 捕捉するか | ログを出すか |
|---|---|---|---|---|
| プレゼンテーション層(画面・APIの入口) | 入力の形式、必須、桁数 | 入力エラー | 自分が出した入力エラーだけ | 出さない |
| アプリケーション層(ユースケース) | 業務ルール、状態の不整合、権限 | 業務例外 | 原則しない。業務として回復する場合だけ | 出さない |
| ドメイン層(業務の規則) | 値の不変条件 | 業務例外 | しない | 出さない |
| インフラ層(DB・ファイル・外部連携) | 接続、タイムアウト、制約違反 | システム例外(原因を包む)。一意制約などは業務例外に置き換える | 技術的な例外を包み直すためだけに捕捉 | リトライの経過だけ |
| 最上位の共通処理(フィルター、ミドルウェア、ジョブの起動部) | 下の層で処理されなかったすべて | 投げない | すべて捕捉する | ここで1回だけ出す |
ログは最上位で1回だけ出すと決めておくと、同じエラーが層を上がるたびに記録されて件数が合わなくなることを防げます。
途中の層で捕捉してよい4つの場合
基本は「捕捉せずに最上位まで届ける」です。途中の層で捕捉するのは次の4つに限ります。
| 場合 | 行うこと | 例 |
|---|---|---|
| 回復できる | 再試行する、代わりの値を使う | タイムアウトを3回まで再試行する。設定値が読めなければ既定値で続ける |
| 意味を付け直す | 技術的な例外を、上の層が理解できる例外に包み直す | 一意制約の違反を「予約済み」の業務例外にする |
| 後始末をする | 資源を解放してから、同じ例外をそのまま上へ届ける | ファイルを閉じる、ロックを外す(finally、try-with-resources、with) |
| 1件ずつ続ける | 1件のエラーを記録して次の件へ進む | 取り込み処理で、形式が合わない行を記録して残りを続ける |
どれにも当てはまらない捕捉、とくに捕捉して何もしない書き方は、エラーが起きたこと自体が見えなくなるので避けます。
包み直すときの3つの決まり
| 決まり | 理由 |
|---|---|
| 原因の例外を必ず持たせる(Java は cause、TypeScript は cause オプション、Python は raise ... from) | 最上位のログから根本の原因までたどれる |
| 包み直すのは層の境界で1回だけ | 何重にも包むと、ログが長くなり原因が読み取りにくくなる |
| 利用者向けの文言と、ログ向けの詳細を分けて持つ | 接続先の名前やSQLが画面に出ることを防ぐ |
エラーコード体系のテンプレート
ログ・問い合わせ・エラー一覧をつなぐ識別子として、エラーコードを決めます。ここでは区分が先頭でわかる形にします。
E-{区分}-{機能または層}-{連番3桁}
例: E-BIZ-RSV-001 業務エラー / 予約機能 / 1番
E-SYS-DB-001 システムエラー / データベース / 1番
E-SYS-APP-999 システムエラー / アプリケーション / 想定外
| 部分 | 値 | 意味 |
|---|---|---|
| 接頭辞 | E | エラーであること |
| 区分 | BIZ | 業務エラー(入力、業務ルール、権限、競合) |
| 区分 | SYS | システムエラー(一時的、継続、外部連携、想定外) |
| 機能または層 | RSV、ITM、USR など | 業務エラーは機能の略号 |
| 機能または層 | DB、EXT、FILE、APP など | システムエラーは原因のある層・部品の略号 |
| 連番 | 001〜998 | 機能・層の中での通し番号。欠番は再利用しない |
| 連番 | 999 | その機能・層で想定していないもの |
区分をコードに入れておくと、ログの検索や通知の条件を「E-SYS- で始まるものだけ」と書けます。 最上位の共通処理も、コードの区分だけでログレベルと通知を決められます。
エラー一覧のテンプレート(Markdown・TSV)
エラー一覧は、エラーコード1つにつき1行で、方針表の列をそのまま持たせます。
Markdown のひな形と記入例
| エラーコード | 区分 | エラー名 | 発生条件 | 検知する層 | 利用者への表示 | ログレベル | 自動リトライ | 通知先 | 対処 |
|---|---|---|---|---|---|---|---|---|---|
| E-BIZ-RSV-001 | 業務 | 予約の重複 | 指定の時間帯に同じ備品の予約がある | アプリケーション層 | 予約済みであることと空いている時間帯を表示 | INFO | しない | なし | 利用者が時間帯を変える |
| E-BIZ-RSV-002 | 業務 | 予約上限の超過 | 1人の同時予約が5件を超える | アプリケーション層 | 上限と現在の件数を表示 | INFO | しない | なし | 利用者が不要な予約を取り消す |
| E-BIZ-RSV-003 | 業務 | 更新の競合 | 読み込み後に別の人が更新した | インフラ層で検知 | 最新の内容を読み直す案内 | INFO | 読み直して1回 | なし | 利用者が内容を確かめて再操作する |
| E-BIZ-USR-001 | 業務 | 権限なし | 管理者用の操作を一般の利用者が行った | 入口の共通処理 | 権限がない旨を表示 | WARN | しない | 10分に20件以上で管理者 | 管理者が権限を確認する |
| E-SYS-DB-001 | システム | DBの読み取りタイムアウト | 問い合わせが5秒以内に返らない | インフラ層 | 時間をおいて再操作する案内 | ERROR | 3回まで | 5分に3件以上で運用担当 | 遅い問い合わせと負荷を調べる |
| E-SYS-DB-002 | システム | DBへ接続できない | 接続の確立ができない | インフラ層 | 汎用の文言とコード | ERROR | 3回まで | すぐに運用担当 | DBの稼働と接続数を確かめる |
| E-SYS-EXT-001 | システム | 通知サービスの応答なし | メール通知の連携先が10秒以内に返らない | インフラ層 | 予約は完了し、通知が遅れる旨 | ERROR | 5回まで | 続いたら運用担当と連携先の窓口 | 連携先の状況を確かめ、通知を再送する |
| E-SYS-FILE-001 | システム | 書き込み先の容量不足 | 帳票の出力先に空きがない | インフラ層 | 汎用の文言とコード | ERROR | しない | すぐに運用担当 | 空きを確保して再実行する |
| E-SYS-APP-999 | システム | 想定外のエラー | 上のどれにも当てはまらない例外 | 最上位の共通処理 | 汎用の文言とコード | ERROR | しない | 開発担当 | スタックトレースから原因を調べる |
タブ区切り(表計算ソフトに貼り付け用)
エラーコード 区分 エラー名 発生条件 検知する層 利用者への表示 ログレベル 自動リトライ 通知先 対処
E-BIZ-RSV-001 業務 予約の重複 指定の時間帯に同じ備品の予約がある アプリケーション層 予約済みであることと空いている時間帯を表示 INFO しない なし 利用者が時間帯を変える
E-BIZ-RSV-002 業務 予約上限の超過 1人の同時予約が5件を超える アプリケーション層 上限と現在の件数を表示 INFO しない なし 利用者が不要な予約を取り消す
E-BIZ-RSV-003 業務 更新の競合 読み込み後に別の人が更新した インフラ層で検知 最新の内容を読み直す案内 INFO 読み直して1回 なし 利用者が内容を確かめて再操作する
E-BIZ-USR-001 業務 権限なし 管理者用の操作を一般の利用者が行った 入口の共通処理 権限がない旨を表示 WARN しない 10分に20件以上で管理者 管理者が権限を確認する
E-SYS-DB-001 システム DBの読み取りタイムアウト 問い合わせが5秒以内に返らない インフラ層 時間をおいて再操作する案内 ERROR 3回まで 5分に3件以上で運用担当 遅い問い合わせと負荷を調べる
E-SYS-DB-002 システム DBへ接続できない 接続の確立ができない インフラ層 汎用の文言とコード ERROR 3回まで すぐに運用担当 DBの稼働と接続数を確かめる
E-SYS-EXT-001 システム 通知サービスの応答なし メール通知の連携先が10秒以内に返らない インフラ層 予約は完了し、通知が遅れる旨 ERROR 5回まで 続いたら運用担当と連携先の窓口 連携先の状況を確かめ、通知を再送する
E-SYS-FILE-001 システム 書き込み先の容量不足 帳票の出力先に空きがない インフラ層 汎用の文言とコード ERROR しない すぐに運用担当 空きを確保して再実行する
E-SYS-APP-999 システム 想定外のエラー 上のどれにも当てはまらない例外 最上位の共通処理 汎用の文言とコード ERROR しない 開発担当 スタックトレースから原因を調べる
件数や秒数は記入例です。通知の条件(何分に何件)は、通常時の発生件数を1〜2週間測ってから決めると、通知が多すぎたり少なすぎたりしません。
方針表のタブ区切り(設計書に貼り付け用)
エラー区分 例 検知する層 利用者への表示 ログレベル 自動リトライ 通知先
入力エラー 必須項目が空、日付の形式が違う プレゼンテーション層 項目ごとに直し方を表示 出さない、またはINFO しない なし
業務ルールエラー 予約済みの備品、貸出上限の超過 アプリケーション層・ドメイン層 理由と次にできる操作を表示 INFOまたはWARN しない なし
認証・権限エラー 未ログイン、権限のない操作 入口の共通処理 ログインの案内、権限がない旨 WARN しない 短時間に多発したら管理者
競合エラー 同時更新で版が合わない インフラ層で検知 最新の内容を読み直す案内 INFO 読み直して1回まで なし
一時的なシステムエラー 接続のタイムアウト、接続の切断 インフラ層 時間をおいて再操作する案内 回復したらWARN、回復しなければERROR する(上限あり) 続いたら運用担当
継続するシステムエラー 設定の誤り、ディスクの容量不足 インフラ層 汎用の文言とコード ERROR しない すぐに運用担当
外部連携エラー 連携先が応答しない、連携先がエラーを返す インフラ層 連携先の都合で完了できない旨 ERROR 相手側の一時的な不調だけする 運用担当と連携先の窓口
想定外のエラー nullの参照、範囲外の添字 最上位の共通処理 汎用の文言とコード ERROR しない 開発担当
業務例外とシステム例外を最上位でまとめて処理する最小コード
3つの言語で同じ構成のコードを並べます。どれも次の4つの場面を順に実行します。
| 呼び出し | 起きること | 期待する扱い |
|---|---|---|
| reserve(PJ-02) | 正常に予約できる | 200 |
| reserve(PJ-01) | すでに予約されている | 業務例外 → WARN、理由をそのまま表示 |
| reserve(PJ-99) | DBの読み取りがタイムアウトする | システム例外 → ERROR、汎用の文言 |
| reserve(PJ-50) | プログラムの不具合で例外が出る | 想定外 → ERROR、汎用の文言とコード |
構成の要点は共通です。
- インフラ層は技術的な例外を捕捉し、原因を持たせたシステム例外に包み直す
- アプリケーション層は業務ルールを判定して業務例外を投げ、捕捉は書かない
- 最上位は「業務例外」「システム例外」「それ以外」の3段で受け、ログと応答を決める
Java
import java.io.IOException;
import java.util.Map;
public class Main {
/** 業務エラー: 利用者が操作を変えれば解消する。スタックトレースは残さない */
static class BusinessException extends RuntimeException {
final String code;
BusinessException(String code, String message) {
super(message);
this.code = code;
}
}
/** システムエラー: 利用者には直せない。原因の例外を必ず持たせる */
static class SystemException extends RuntimeException {
final String code;
final boolean retryable;
SystemException(String code, String message, boolean retryable, Throwable cause) {
super(message, cause);
this.code = code;
this.retryable = retryable;
}
}
record Response(int status, String code, String message) {}
/** インフラ層: 技術的な例外をシステム例外に包み直す */
static class ReservationRepository {
private final Map<String, String> reserved = new java.util.HashMap<>(Map.of("PJ-01", "sato"));
String findOwner(String itemId) {
try {
if (itemId.equals("PJ-99")) {
throw new IOException("Connection timed out: db-primary:5432");
}
return reserved.get(itemId);
} catch (IOException e) {
throw new SystemException("E-SYS-DB-001", "予約テーブルの読み取りに時間がかかりすぎました", true, e);
}
}
void save(String itemId, String user) {
reserved.put(itemId, user);
}
}
/** アプリケーション層: 業務ルールを判定して業務例外を投げる。catch は書かない */
static class ReservationService {
private final ReservationRepository repo = new ReservationRepository();
String reserve(String itemId, String user) {
String owner = repo.findOwner(itemId);
if (owner != null) {
throw new BusinessException("E-BIZ-RSV-001", "この備品は予約済みです: " + itemId);
}
if (itemId.equals("PJ-50")) {
Object config = null;
config.toString(); // 想定外の不具合の例
}
repo.save(itemId, user);
return "予約しました: " + itemId;
}
}
/** 最上位: 例外を1か所で受け、ログと応答を決める */
static Response handle(java.util.function.Supplier<String> action) {
try {
return new Response(200, "OK", action.get());
} catch (BusinessException e) {
System.out.println(" [WARN ] " + e.code + " " + e.getMessage());
return new Response(409, e.code, e.getMessage());
} catch (SystemException e) {
System.out.println(" [ERROR] " + e.code + " " + e.getMessage()
+ " retryable=" + e.retryable + " cause=" + e.getCause());
return new Response(503, e.code, "ただいま混み合っています。しばらくしてからもう一度お試しください。");
} catch (RuntimeException e) {
StackTraceElement at = e.getStackTrace()[0];
System.out.println(" [ERROR] E-SYS-APP-999 想定外の例外 " + e.getClass().getName()
+ " at " + at.getClassName() + "." + at.getMethodName());
return new Response(500, "E-SYS-APP-999", "処理を完了できませんでした。お問い合わせの際はコードをお伝えください。");
}
}
public static void main(String[] args) {
ReservationService service = new ReservationService();
for (String itemId : new String[] {"PJ-02", "PJ-01", "PJ-99", "PJ-50"}) {
System.out.println("reserve(" + itemId + ")");
Response r = handle(() -> service.reserve(itemId, "tanaka"));
System.out.println(" -> " + r.status() + " " + r.code() + " " + r.message());
}
}
}
実行結果です(java Main.java。日本語が文字化けする端末では java -Dstdout.encoding=UTF-8 Main.java)。
reserve(PJ-02)
-> 200 OK 予約しました: PJ-02
reserve(PJ-01)
[WARN ] E-BIZ-RSV-001 この備品は予約済みです: PJ-01
-> 409 E-BIZ-RSV-001 この備品は予約済みです: PJ-01
reserve(PJ-99)
[ERROR] E-SYS-DB-001 予約テーブルの読み取りに時間がかかりすぎました retryable=true cause=java.io.IOException: Connection timed out: db-primary:5432
-> 503 E-SYS-DB-001 ただいま混み合っています。しばらくしてからもう一度お試しください。
reserve(PJ-50)
[ERROR] E-SYS-APP-999 想定外の例外 java.lang.NullPointerException at Main$ReservationService.reserve
-> 500 E-SYS-APP-999 処理を完了できませんでした。お問い合わせの際はコードをお伝えください。
接続先の名前(db-primary:5432)はログにだけ出て、利用者向けの応答には出ていません。 業務例外もシステム例外も RuntimeException を継承しているので、途中の層のメソッドに throws を書かずに最上位まで届きます。
TypeScript
/** 業務エラー: 利用者が操作を変えれば解消する */
class BusinessError extends Error {
constructor(readonly code: string, message: string) {
super(message);
this.name = "BusinessError";
}
}
/** システムエラー: 利用者には直せない。原因を cause に持たせる */
class SystemError extends Error {
constructor(
readonly code: string,
message: string,
readonly retryable: boolean,
options?: { cause?: unknown },
) {
super(message, options);
this.name = "SystemError";
}
}
type Res = { status: number; code: string; message: string };
/** インフラ層: 技術的な例外をシステムエラーに包み直す */
class ReservationRepository {
private reserved = new Map<string, string>([["PJ-01", "sato"]]);
findOwner(itemId: string): string | undefined {
try {
if (itemId === "PJ-99") {
throw new Error("Connection timed out: db-primary:5432");
}
return this.reserved.get(itemId);
} catch (e) {
throw new SystemError("E-SYS-DB-001", "予約テーブルの読み取りに時間がかかりすぎました", true, { cause: e });
}
}
save(itemId: string, user: string): void {
this.reserved.set(itemId, user);
}
}
/** アプリケーション層: 業務ルールを判定する。catch は書かない */
class ReservationService {
private repo = new ReservationRepository();
reserve(itemId: string, user: string): string {
const owner = this.repo.findOwner(itemId);
if (owner !== undefined) {
throw new BusinessError("E-BIZ-RSV-001", `この備品は予約済みです: ${itemId}`);
}
if (itemId === "PJ-50") {
const config: any = undefined;
config.toString(); // 想定外の不具合の例
}
this.repo.save(itemId, user);
return `予約しました: ${itemId}`;
}
}
/** 最上位: 例外を1か所で受け、ログと応答を決める */
function handle(action: () => string): Res {
try {
return { status: 200, code: "OK", message: action() };
} catch (e) {
if (e instanceof BusinessError) {
console.log(` [WARN ] ${e.code} ${e.message}`);
return { status: 409, code: e.code, message: e.message };
}
if (e instanceof SystemError) {
console.log(` [ERROR] ${e.code} ${e.message} retryable=${e.retryable} cause=${e.cause}`);
return { status: 503, code: e.code, message: "ただいま混み合っています。しばらくしてからもう一度お試しください。" };
}
const name = e instanceof Error ? e.name : typeof e;
console.log(` [ERROR] E-SYS-APP-999 想定外の例外 ${name}`);
return { status: 500, code: "E-SYS-APP-999", message: "処理を完了できませんでした。お問い合わせの際はコードをお伝えください。" };
}
}
const service = new ReservationService();
for (const itemId of ["PJ-02", "PJ-01", "PJ-99", "PJ-50"]) {
console.log(`reserve(${itemId})`);
const r = handle(() => service.reserve(itemId, "tanaka"));
console.log(` -> ${r.status} ${r.code} ${r.message}`);
}
実行結果です(tsc -p . でコンパイルして node dist/main.js)。
reserve(PJ-02)
-> 200 OK 予約しました: PJ-02
reserve(PJ-01)
[WARN ] E-BIZ-RSV-001 この備品は予約済みです: PJ-01
-> 409 E-BIZ-RSV-001 この備品は予約済みです: PJ-01
reserve(PJ-99)
[ERROR] E-SYS-DB-001 予約テーブルの読み取りに時間がかかりすぎました retryable=true cause=Error: Connection timed out: db-primary:5432
-> 503 E-SYS-DB-001 ただいま混み合っています。しばらくしてからもう一度お試しください。
reserve(PJ-50)
[ERROR] E-SYS-APP-999 想定外の例外 TypeError
-> 500 E-SYS-APP-999 処理を完了できませんでした。お問い合わせの際はコードをお伝えください。
使った tsconfig.json は次のとおりです。
{ "compilerOptions": { "target": "es2022", "module": "nodenext", "strict": true, "outDir": "dist", "types": [] }, "files": ["main.ts"] }
TypeScript では、strict を有効にすると catch した値の型が unknown になります。instanceof で種類を確かめてからプロパティを読む書き方にすると、型の検査を通ります。確かめずに読むと、次のようにコンパイルの段階で知らせてくれます。
function handle(action: () => string): string {
try {
return action();
} catch (e) {
return e.code + " " + e.message;
}
}
console.log(handle(() => "ok"));
bad.ts(5,12): error TS18046: 'e' is of type 'unknown'.
bad.ts(5,27): error TS18046: 'e' is of type 'unknown'.
Python
class BusinessError(Exception):
"""業務エラー: 利用者が操作を変えれば解消する"""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
class AppSystemError(Exception):
"""システムエラー: 利用者には直せない。原因は raise ... from で残す"""
def __init__(self, code: str, message: str, retryable: bool) -> None:
super().__init__(message)
self.code = code
self.retryable = retryable
class ReservationRepository:
"""インフラ層: 技術的な例外をシステムエラーに包み直す"""
def __init__(self) -> None:
self._reserved = {"PJ-01": "sato"}
def find_owner(self, item_id: str) -> str | None:
try:
if item_id == "PJ-99":
raise TimeoutError("Connection timed out: db-primary:5432")
return self._reserved.get(item_id)
except OSError as e:
raise AppSystemError("E-SYS-DB-001", "予約テーブルの読み取りに時間がかかりすぎました", True) from e
def save(self, item_id: str, user: str) -> None:
self._reserved[item_id] = user
class ReservationService:
"""アプリケーション層: 業務ルールを判定する。except は書かない"""
def __init__(self) -> None:
self._repo = ReservationRepository()
def reserve(self, item_id: str, user: str) -> str:
owner = self._repo.find_owner(item_id)
if owner is not None:
raise BusinessError("E-BIZ-RSV-001", f"この備品は予約済みです: {item_id}")
if item_id == "PJ-50":
config = None
config.strip() # 想定外の不具合の例
self._repo.save(item_id, user)
return f"予約しました: {item_id}"
def handle(action) -> tuple[int, str, str]:
"""最上位: 例外を1か所で受け、ログと応答を決める"""
try:
return 200, "OK", action()
except BusinessError as e:
print(f" [WARN ] {e.code} {e}")
return 409, e.code, str(e)
except AppSystemError as e:
print(f" [ERROR] {e.code} {e} retryable={e.retryable} cause={e.__cause__!r}")
return 503, e.code, "ただいま混み合っています。しばらくしてからもう一度お試しください。"
except Exception as e:
print(f" [ERROR] E-SYS-APP-999 想定外の例外 {type(e).__name__}: {e}")
return 500, "E-SYS-APP-999", "処理を完了できませんでした。お問い合わせの際はコードをお伝えください。"
service = ReservationService()
for item_id in ["PJ-02", "PJ-01", "PJ-99", "PJ-50"]:
print(f"reserve({item_id})")
status, code, message = handle(lambda: service.reserve(item_id, "tanaka"))
print(f" -> {status} {code} {message}")
実行結果です(python3 main.py)。
reserve(PJ-02)
-> 200 OK 予約しました: PJ-02
reserve(PJ-01)
[WARN ] E-BIZ-RSV-001 この備品は予約済みです: PJ-01
-> 409 E-BIZ-RSV-001 この備品は予約済みです: PJ-01
reserve(PJ-99)
[ERROR] E-SYS-DB-001 予約テーブルの読み取りに時間がかかりすぎました retryable=True cause=TimeoutError('Connection timed out: db-primary:5432')
-> 503 E-SYS-DB-001 ただいま混み合っています。しばらくしてからもう一度お試しください。
reserve(PJ-50)
[ERROR] E-SYS-APP-999 想定外の例外 AttributeError: 'NoneType' object has no attribute 'strip'
-> 500 E-SYS-APP-999 処理を完了できませんでした。お問い合わせの際はコードをお伝えください。
Python には組み込みの SystemError があるため、独自のシステム例外は AppSystemError のように別の名前にします。raise ... from e で包み直すと、元の例外が __cause__ に入り、ログからたどれます。
3言語の書き方の対応表
| 項目 | Java | TypeScript | Python |
|---|---|---|---|
| 業務例外の親 | RuntimeException | Error | Exception |
| 原因の持たせ方 | コンストラクタの cause 引数 | super(message, { cause }) |
raise 新しい例外 from e |
| 原因の取り出し方 | getCause() |
e.cause |
e.__cause__ |
| 種類の見分け方 | catch 節を型ごとに並べる | catch の中で instanceof | except 節を型ごとに並べる |
| 並べる順番 | 具体的な型から先に | 具体的な型から先に | 具体的な型から先に |
| 最後の受け皿 | catch (RuntimeException e) |
instanceof に当てはまらなかった残り | except Exception |
| 後始末 | finally、try-with-resources | finally | finally、with 文 |
リトライ回数と待ち時間の早見表
リトライしてよいエラーの判断表
| エラー | 自動リトライ | 理由 |
|---|---|---|
| 入力エラー、業務ルールエラー、権限エラー | しない | 何度行っても同じ結果になる |
| 接続のタイムアウト、接続の切断 | する | 少し後なら通る見込みがある |
| 連携先が「混雑中」「一時的に利用不可」を返した | する | 相手の回復を待てば通る |
| 連携先が「入力が正しくない」「権限がない」を返した | しない | 送る内容を変えない限り同じ結果になる |
| デッドロック、楽観ロックの競合 | 条件つき | 読み直してからやり直す。1〜3回まで |
| 設定の誤り、容量不足 | しない | 人が直すまで通らない |
| 想定外のエラー | しない | 原因がわからないまま繰り返すと影響が広がる |
更新系の処理をリトライするときは、同じ要求が2回届いても結果が1回分になるしくみ(要求ごとの識別子で重複を見分ける など)を先に用意します。 読み取りだけの処理は、そのままリトライできます。
待ち時間の計算と実行結果
待ち時間は、毎回同じ長さの固定間隔より、回を追うごとに延ばす指数バックオフ(待ち時間を倍にしていく方式)が、相手の回復を待つ場面に向いています。
"""リトライの待ち時間: 固定間隔と指数バックオフ(上限つき)を並べる"""
import random
def backoff(attempt: int, base: float = 1.0, factor: float = 2.0, cap: float = 30.0) -> float:
"""attempt 回目のリトライの前に待つ秒数(1始まり)"""
return min(cap, base * factor ** (attempt - 1))
print("回 固定2秒 累計 指数(1秒x2,上限30秒) 累計 ゆらぎつきの例")
random.seed(1)
fixed_total = exp_total = 0.0
for n in range(1, 8):
fixed_total += 2.0
w = backoff(n)
exp_total += w
jitter = random.uniform(0, w) # 0〜w の範囲でばらつかせる
print(f"{n} {2.0:>5.1f} {fixed_total:>5.1f} {w:>19.1f} {exp_total:>5.1f} {jitter:>8.2f}")
回 固定2秒 累計 指数(1秒x2,上限30秒) 累計 ゆらぎつきの例
1 2.0 2.0 1.0 1.0 0.13
2 2.0 4.0 2.0 3.0 1.69
3 2.0 6.0 4.0 7.0 3.06
4 2.0 8.0 8.0 15.0 2.04
5 2.0 10.0 16.0 31.0 7.93
6 2.0 12.0 30.0 61.0 13.48
7 2.0 14.0 30.0 91.0 19.55
指数バックオフは3回で累計7秒、5回で累計31秒です。画面の操作のように利用者が待っている処理は3回・累計7秒まで、利用者が待っていない裏側の処理は5〜7回というように、累計の待ち時間から回数を決めます。ゆらぎ(待ち時間を乱数でばらつかせること)を入れると、同時にエラーになった多数の処理が同じ瞬間に再試行することを避けられます。
用途別の設定例
| 用途 | 回数 | 最初の待ち | 倍率 | 上限 | 累計の待ち時間 |
|---|---|---|---|---|---|
| 画面の操作から呼ぶDBの読み取り | 2回 | 0.1秒 | 2 | 1秒 | 0.3秒 |
| 画面の操作から呼ぶ外部連携 | 3回 | 1秒 | 2 | 30秒 | 7秒 |
| 裏側で行う通知の送信 | 5回 | 1秒 | 2 | 30秒 | 31秒 |
| 夜間の一括処理の外部連携 | 7回 | 1秒 | 2 | 30秒 | 91秒 |
| 楽観ロックの競合 | 1回 | なし(読み直してすぐ) | - | - | 0秒 |
値は例です。累計の待ち時間は上の実行結果から読み取れます(0.3秒は 0.1+0.2)。
リトライの最小コード
一時的なエラーだけを再試行し、業務エラーは再試行せずにそのまま通します。
"""リトライしてよいエラーだけを、回数と待ち時間を決めて再試行する"""
import time
class BusinessError(Exception):
pass
class TransientError(Exception):
"""一時的なシステムエラー(タイムアウト、接続の切断など)"""
def call_with_retry(func, max_retries: int = 3, base: float = 0.1, cap: float = 1.0, sleep=time.sleep):
for attempt in range(max_retries + 1):
try:
return func()
except TransientError as e:
if attempt == max_retries:
print(f" {attempt + 1}回目: {e} -> 上限に達したので呼び出し元へ")
raise
wait = min(cap, base * 2 ** attempt)
print(f" {attempt + 1}回目: {e} -> {wait:.1f}秒待って再試行")
sleep(wait)
def make(fail_times: int, error: Exception):
state = {"n": 0}
def func():
state["n"] += 1
if state["n"] <= fail_times:
raise error
return f"成功({state['n']}回目)"
return func
for title, func in [
("2回タイムアウトしてから成功", make(2, TransientError("タイムアウト"))),
("ずっとタイムアウト", make(99, TransientError("タイムアウト"))),
("業務エラー", make(99, BusinessError("この備品は予約済みです"))),
]:
print(title)
try:
print(" ->", call_with_retry(func))
except TransientError as e:
print(" -> システムエラーとして最上位へ:", e)
except BusinessError as e:
print(" -> 再試行せずにそのまま利用者へ:", e)
2回タイムアウトしてから成功
1回目: タイムアウト -> 0.1秒待って再試行
2回目: タイムアウト -> 0.2秒待って再試行
-> 成功(3回目)
ずっとタイムアウト
1回目: タイムアウト -> 0.1秒待って再試行
2回目: タイムアウト -> 0.2秒待って再試行
3回目: タイムアウト -> 0.4秒待って再試行
4回目: タイムアウト -> 上限に達したので呼び出し元へ
-> システムエラーとして最上位へ: タイムアウト
業務エラー
-> 再試行せずにそのまま利用者へ: この備品は予約済みです
リトライはインフラ層の中で完結させ、上限に達したら例外をそのまま上へ届けて、最上位の共通処理に任せます。リトライで回復した場合も経過を WARN で残しておくと、回復が増えてきた段階で兆しに気づけます。
設計書に書く項目のチェックリスト
| No | 項目 | 書く内容 |
|---|---|---|
| 1 | エラー区分の定義 | 業務エラーとシステムエラーの定義、判定表 |
| 2 | 方針表 | 区分ごとの検知層・表示・ログレベル・リトライ・通知先 |
| 3 | 例外クラスの構成 | 業務例外とシステム例外の親クラス、持たせる項目(コード、原因、リトライ可否) |
| 4 | 層ごとの責務 | どの層が何を投げ、何を捕捉するか |
| 5 | 最上位の共通処理 | 置き場所(フィルター、ミドルウェア、ジョブの起動部)と3段の受け方 |
| 6 | エラーコード体系 | 書式、区分・機能の略号、採番のルール |
| 7 | エラー一覧 | コードごとの発生条件と対処 |
| 8 | ログの項目 | 日時、レベル、エラーコード、要求の識別子、利用者の識別子、原因 |
| 9 | リトライの設定 | 対象のエラー、回数、待ち時間、上限、重複を防ぐしくみ |
| 10 | トランザクションの扱い | どの例外で取り消すか、途中まで済んだ処理の戻し方 |
| 11 | 通知の条件 | 通知先、件数のしきい値、時間帯 |
| 12 | 画面以外の入口 | 一括処理、非同期の処理、定期実行での扱いの違い |
つまずきやすい書き方と整え方
| 書き方 | 起きること | 整え方 |
|---|---|---|
| 捕捉して何もしない | エラーが起きたことが見えず、後の処理で別のエラーになる | 捕捉をやめて最上位へ届ける |
| 各層で捕捉してログを出し、投げ直す | 同じエラーが3回も4回も記録される | ログは最上位で1回だけにする |
| 業務エラーを ERROR で記録する | 通知が増え、対応が必要なものが埋もれる | INFO か WARN にして、件数の集計で見る |
| 原因を持たせずに包み直す | ログに根本の原因が残らない | cause や from で原因をつなぐ |
| 例外の文言をそのまま画面に出す | 接続先やSQLが利用者に見える | 利用者向けの文言とログ向けの詳細を分ける |
| すべての例外をリトライする | 業務エラーや不具合を繰り返し、負荷が上がる | 一時的なエラーの型だけを対象にする |
| 正常な分岐に例外を使う | 検索結果が0件のたびに例外が飛び、流れが追いにくくなる | 起きて当然の結果は戻り値で返す |
よくある質問
Q. 入力チェックのエラーは例外にするべきですか? A. 項目ごとに複数のエラーをまとめて返したい入力チェックは、例外ではなくエラーの一覧を戻り値で返す形が扱いやすくなります。1件でも見つかったら処理を止める業務ルールは、業務例外にすると途中の層の分岐が減ります。
Q. 業務エラーのログは残さなくてよいですか? A. 残します。ただしスタックトレースは不要で、エラーコードと件数がわかれば足ります。同じ業務エラーが急に増えたときは、画面の案内がわかりにくいなどの改善点が見つかることがあります。
Q. 一括処理や非同期の処理でも同じ方針でよいですか? A. 区分と層の責務は同じです。違うのは最上位の置き場所と伝える先で、画面では応答、一括処理では終了コードと運用担当への通知、非同期の処理では再配信や保留用の置き場への移動が最上位の役目になります。
Q. システムエラーは種類ごとに例外クラスを分けるべきですか? A. 扱いが変わる単位で分けます。「リトライするかどうか」「通知先が違うかどうか」で扱いが分かれるなら別のクラスかリトライ可否の項目で区別し、扱いが同じならエラーコードの違いだけで十分です。
まとめ
- 業務エラーは利用者が操作を変えれば解消するもの、システムエラーは利用者には直せないもの。 この違いが表示・ログ・通知を決める
- エラー区分×検知層×利用者への表示×ログレベル×リトライ可否×通知先を1枚の方針表にする
- 基本は捕捉せずに最上位へ届ける。 途中で捕捉するのは「回復できる」「意味を付け直す」「後始末」「1件ずつ続ける」の4つの場合だけ
- インフラ層で技術的な例外をシステム例外に包み直し、原因を必ず持たせる
- 最上位は「業務例外」「システム例外」「それ以外」の3段で受け、ログはここで1回だけ出す
- エラーコードに区分を入れると、ログの検索と通知の条件を書きやすくなる
- リトライは一時的なエラーだけ。回数は累計の待ち時間から決め、上限に達したら最上位へ届ける
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「エラー処理設計の方針|業務エラーとシステムエラーの分類表とテンプレート」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。