📝

カスタムエラー処理について考察してみた

2023/12/05に公開

この記事はエアークローゼットのアドベントカレンダー20235日目の記事になります

みなさま、こんにちは!
妻が愛用している足元を暖める暖房機器を妻がいない間に使ったらデスクから離れられなくなった大西です。(自分用のやつ買おうかな。。。)

少し前にバックエンドアプリケーション向けにカスタムエラー処理を作成しました。
今回は開発の中で得た学びをを書こうと思います。
ネットで検索しても(特にWEB系だと)意外とカスタムエラー処理の設計について書かれた記事がなかったのでみなさまの参考になれば幸いです。

課題

まずは何故カスタムエラークラスを作るに至ったかの背景から書こうと思います。

弊社では開発もしつつ保守運用も当番制で行うスタイルを取っています。
保守運用のターンにはプロダクトのバグっぽいものが見つかっては問い合わせが来て調査して修正というサイクルを繰り返しているわけですが、その中で以下のような事象がありました。

  • エラーの詰め替えを行う際にstack traceが引き継がれておらず調査ができない
  • お客様からの問い合わせがあり、事象が発生した際のスクリーンショットを送ってもらったが、表示されているメッセージが汎用的すぎてエラー内容がわからない

という事象がありました。

どちらもエラーハンドリングが正しく実装されていないことに起因していました。
例えば、「エラーの詰め替えを行う際にstack traceが引き継がれておらず調査ができない」でいうと以下のように実装されていました。

try {
...
} catch (error) {
    throw new Error('500エラーだよ ${error}');
     
}

スタックトレース出そうな見た目ですが、これだとスタックトレース出ないんですよね、、、

開発者次第で品質が安定しなかったり、開発量がかなり多いためにレビューで拾いきれないなどがこのような問題の背景としてありました。

「正しくコードが書けないなら脳死で書けるようにすればいいじゃない」ということで、上記の問題を可能な限り隠蔽できるようなカスタムエラー処理を作るに至りました。

一般的なエラー処理

本題のカスタムエラー処理の話に入る前に、一般論としてのエラー処理の役割を考えてみたいと思います。

まず、エラーが発生した時にやらないといけないことをざっくり書き出すと以下のものがあります。

  • リソースの解放
    • メモリ
    • CPU
  • 進行中の処理を停止し、元の状態に復元する
    • DBトランザクションのロールバック
    • 外部APIに対する処理のキャンセル
  • メッセージ出力
    • ユーザー向けのエラーメッセージ出力
    • エンジニア向けのログ出力
      (普段JS、TSで生きている人間なので、ある程度のバイアスがかかっているかと思います。他にあればコメントに書いていただければ嬉しいです)

近年のプログラミング言語であれば、プログラミングをする上で必須となるエラー処理は言語側でよしなにやってくれるので、そこまで意識しなくてもいいものも含まれます。

特にメモリや計算リソースの解放は、アンチパターンさえ気をつければあまり意識しなくても言語側がやってくれますよね(ありがとうJS)

途中まで進んだ処理を元の状態に戻す処理も(正しく実装されていれば)ライブラリ側でよしなにやってくれることが多いので、意識的に書くこともなかなかないかと思います。

他の記事とかを見ていると元に戻す処理を細かく制御したり、カスタムしたいという需要はあるみたいです。
また、外部APIに対する処理は流石に自前で書く必要がありますが、今解決したい課題ではありません。
そのため、今回はどちらも今後拡張できる余地を残しながらスコープ外としました。

ということで、今回私が作ったカスタムエラー処理はメッセージ出力に対する諸々を解決することを目的に実装しました。

カスタムエラー処理を作る目的

エラー処理の一般論を書いてきましたが、結局カスタムエラー処理を作る目的は何か?と問われると以下の二つに集約されるかと思います。

  • エラー処理を一元管理したい
  • 開発しやすい(=開発時には考えることが少ない)エラーハンドリングを実現する

解決できることが最低条件として、今後あらゆるニーズに耐えうる拡張性を持ったエラークラスを作ることを今回のゴールとしました。

カスタムエラークラスの実装

いよいよ実装の話に入って行きます。今回作成したものはざっくり以下の二つです。

  • カスタムエラークラス
  • カスタムエラークラスを前提としたエラーハンドラー

各々の設計思想と実装内容を書きます。実装はJS(TS)とkoaを前提としています。とはいえ基本的な作りはフレームワークに依存しないものとなっています。

カスタムエラークラス

設計思想

カスタムエラークラスを作る1番の目的は、弊社固有の問題を吸収することにあるため、エラー時に行いたいロジックはとにかくカスタムエラークラスに詰め込みました。
また、今後拡張する可能性があるため、疎結合を保ったままエラー処理を差し込めるようにしています。

エラー処理の汎用性を担保するために下図のような構造を取りました。
矢印が依存関係を表し、子は全ての親が持つエラー処理を実行します。

エラークラスはエラーメッセージの表示上必要性や、エラー処理が分かれる可能性があるかなどの点で分類しています。

たとえば、レスポンスには必ずステータスコードを含めたい、500系のエラーはサーバーに異常が発生している可能性があるためslackにも通知したいなどのニーズに応えることができます。

DDDで開発してるAPIサーバーであれば上記の分類をしておけば大外れはしないかと思います。

とは書いたものの、弊社のAPIサーバーはローンチ後にDDDを取り入れたという背景があり、ドメインを整理しきれていないため、DDDのレイヤーは実装していません。
また、メッセージの管理が主な実装であるため、個別のエラーに対するクラスも実装していません。

とはいえどちらも実装しようと思えばできるように適切な責務分離は心がけました。

また、冒頭で紹介した課題の本質はエラーメッセージを構築がアプリケーション側に委ねられていることにあります。
messageとerrorを渡せば、あとはカスタムエラークラスがよしなにやってくれる状態を目指しました。

実装

実装は下記の通りです。かなり量があるのでプルダウンにしています。
上記の図と照らし合わせながら見ていただければ幸いです。

ディレクトリ構造

.
├── AcError.ts // ベースクラス
├── ClientError
│   ├── ClientError.ts // 400系エラー
│   ├── BadRequestError.ts // 400エラー
│   ├── UnauthorizedError.ts // 401エラー
│   └── ForbiddenError.ts // 403エラー
└── ServerError
    ├── ServerError.ts // 500系エラー
    ├── InternalServerError.ts // 500エラー
    └── GatewayTimeoutError.ts // 504エラー
AcError.ts
// 自前のlogger
import logger from 'utils/logger';

// エラーコードとエラーメッセージのマッピングを格納したconfig(実際には別ファイルにいます)
const ERROR_MESSAGES = {
	"AC0000": "システムエラーが発生しました。しばらくしてから再度アクセスいただくか、お客様サポートにお問い合わせください。"
};

export interface IErrorParameters {
  message: string;
  code?: string;
  text?: string;
}

export class AcError extends Error {
  protected _code: string = 'AC0000';
  protected _text?: string;
  protected _message: string;

  protected _prefix: string = 'AcError';
  protected _status: number = 500;

  protected _userId?: number;
  protected _params?: string;
  protected _body?: string;
  protected _query?: string;
  protected _path?: string;

  private _isExecuted: boolean;

  constructor({ message, code, text }: IErrorParameters) {
    super();

    this._code = code;
    this._text = text;
    this._message = message;

    this._isExecuted = false;
  }

  public execute(ctx: ICtx): void {
    if (this._isExecuted) {
      return;
    }

    this._isExecuted = true;
    this._parseCtx(ctx);
    this._logging(ctx);
  }

  protected _parseCtx(ctx: ICtx): void {
    const {
      params,
      request: { query, body, user, path },
    } = ctx;

    this._userId = user?.id;
    this._params = JSON.stringify(params);
    this._body = JSON.stringify(body);
    this._query = JSON.stringify(query);
    this._path = path || '';
  }

  protected _logging(_ctx: ICtx): void {
    const errorMessage = `
    ${this.stack},
    data: {
      user_id: ${this._userId},
      path: "${this._path}",
      body: ${this._body},
      params: ${this._params},
      query: ${this._query},
      code: "${this._code}"
    }
    `;
    logger.api.error(errorMessage);
  }

  get message(): string {
    return `${this._prefix}: ${this._message}`;
  }

  get code() {
    return this._code;
  }

  get text() {
    // 引数でtextが渡っていれば優先して使う
    return this._text ? this._text : ERROR_MESSAGES[this._code];
  }

  get status() {
    return this._status;
  }

  get isExecuted() {
    return this._isExecuted;
  }

  get userId() {
    return this._userId;
  }

  get params() {
    return this._params;
  }

  get body() {
    return this._body;
  }

  get query() {
    return this._query;
  }

  get path() {
    return this._path;
  }
}
ClientError.ts(400系エラーの実装)
import AcError from '../AcError';

export class ClientError extends AcError {
  public _status = 400; // デフォルトのステータスは400

  public _prefix = 'ClientError';
}
ServerError.ts(500系エラーの実装)
// 自前のlogger
import logger from 'utils/logger';
import AcError, { IErrorParameters } from '../AcError';

// 500番台系のエラーはサーバーないで起こった不測のエラーを扱うため、引数としてerrorを受け取る
export interface IServerErrorParameters extends IErrorParameters {
  error: Error | AcError;
}

export class ServerError extends AcError {
  public _status = 500; // デフォルトのステータスは500
  public _prefix = 'ServerError';

  constructor(props: IServerErrorParameters) {
    super(props);
    const _error = props.error;

    if (_error instanceof AcError) {
      this._code = _error.code;
    }
    this._message = `${props.message}\n${_error.stack}`;
  }

  public _logging(ctx: ICtx) {
    super._logging(ctx);

    const errorMessage = `
    ${this.stack},
    data: {
      user_id: ${this._userId},
      path: "${this._path}",
      body: ${this._body},
      params: ${this._params},
      query: ${this._query},
      code: "${this._code}"
    }
  `;
    logger.slack.error(errorMessage);
  }
}
BadRequestError.ts(400エラーの実装)
import ClientError from './ClientError';

class BadRequestError extends ClientError {
  public _status = 400;

  public _prefix = 'BadRequestError';
}

export default BadRequestError;
UnauthorizedError.ts(401エラーの実装)
import ClientError from './ClientError';

class BadRequestError extends ClientError {
  public _status = 401;

  public _prefix = 'BadRequestError';
}

export default BadRequestError;
InternalServerError.ts(500エラーの実装)
import ServerError from './ServerError';

class InternalServerError extends ServerError {
  public _status = 500;

  public _prefix = 'InternalServerError';
}

export default InternalServerError;

他のステータスのクラスは繰り返しになるので割愛します。

カスタムエラーハンドラー

設計思想

アプリケーション内でthrowされるエラーは基本的には適切なエラーコードが設定されたカスタムエラークラスでラップされていて、お客様の画面には適切なテキストが表示されるされることを期待します。
しかし、実際にには意図しないタイミングでエラーが発生して、どこでもcatchされないままエラーが上がってくる場合があります。

もちろんそのようなエラーに対してデフォルトのテキストを出すことはできますが、本来あるべきは何が失敗したかを明示して、お客様に適切なアクションを促すことです。

より理想に近い状態に近づけるために、エンドポイント単位でデフォルトのテキストを設定できるようにしました。

デフォルトエラーの場合

エンドポイント単位でエラーが設定されている場合

(流石にこれのレベルの話はちゃんと実装されてますけどね。。。例なので悪しからず)

実装

エンドポイント単位でデフォルトのテキストを設定できるようにするために、エラーハンドリングを行うミドルウェアに対してデフォルトエラーを設定できるようにしました。

routerの設定
import Koa from 'koa';
import handleAcError from 'handle-ac-error';

const router = require('koa-router')();

const app = new Koa();
app.use(router.routes());

router.put(
      '/me/password',
      handleAcError('AC0001'), // デフォルトのエラーコードを設定できる
      require('routes/v1/me/password/put'), // 処理の本体
    )

app.listen(process.env.PORT || 5000);
ミドルウェアの実装
import AcError from 'AcError';
import { InternalServerError } from 'AcError/ServerError';

const createResponse = require('utils/ac-response');
const Process = require('services/process');
const userEvent = require('services/user-event');

interface ResponseCtx extends ICtx {
  status?: number;
  body?: IResponse;
}

// error classに移行後、exportを削除する
export const errorHandling = (
  ctx: ResponseCtx,
  error: AcError | Error,
  defaultErrorCode?: string,
  defaultErrorMessage?: string,
): AcError => {
  let acError: AcError;
  if (error instanceof AcError) {
    acError = error;
  } else {
    // AcError型でない場合問答無用でInternalServerErrorエラーとする
    acError = new InternalServerError({
      message: defaultErrorMessage || 'unexpected error',
      error,
      code: defaultErrorCode,
    });
  }

  // isExecutedをexecute()を実行する前に取り出す
  const { isExecuted } = acError;
  acError.execute(ctx);

  if (!isExecuted) {
    ctx.status = acError.status;
    ctx.body = createResponse(acError.status, error, acError.data, null);
  }
};

interface AcHTTPError extends Error {
  code: string;
}

export default (defaultErrorCode: string, defaultErrorMessage?: string) => async (
  ctx: ICtx,
  next: () => Promise<any>,
): Promise<void> => {
  await next().catch((error) => {
    errorHandling(ctx, error, defaultErrorCode, defaultErrorMessage);
  });
};

さいごに

この記事ではカスタムエラークラスをどのように考えて作成したかを実装ともに紹介しました。
長期の運用に耐えうることに注力して作成したので、今後どのように使われていくかを見守りたいと思います。

エアークローゼットのアドベントカレンダー2023はまだまだ続きますので、ぜひ他のエンジニア、デザイナー、PMの記事もご覧いただければと思います。

また、エアークローゼットはエンジニア採用活動も行っておりますので、興味のある方はぜひご覧ください!
https://corp.air-closet.com/recruiting/developers/

https://www.wantedly.com/companies/airCloset/projects

Discussion