jimble

エラー処理#

エラーが表に出る道は4本あります。通る場所が違います。

何が起きたか 誰が拾うか 既定の返り
例外が投げられた error(...) フック ステータスは例外しだい。本文はアプリが作る
ルートに当たらなかった いちばん外側の error(...) 404
validate(...) が通らなかった(ValidationException) error(...) フック 422 と validation の JSON
ValidationExecutor の検証に失敗した ValidationExecutor(error は通らない) 422 と validation の JSON

error の書き方#

error((context, cause, statusCode) -> {
	context.response().code(statusCode).send("エラー: " + statusCode);
});

引数は3つです。例外だけでなくステータスコードも渡ってきます。 cause から毎回コードを引き直さなくて済むようにするためです。

void handle (WebContext context, Throwable cause, int statusCode) throws Exception;

効く範囲#

before / after と同じで、書いたブロックに付きます(パスのノードではありません)。 実行は内側から外側へ。詳しくは ルーティング を見てください。

JimbleApp app = new JimbleApp() {
	{
		error((context, cause, statusCode) -> log.add("outer"));

		path("/admin", () -> {
			error((context, cause, statusCode) -> {
				log.add("inner");
				throw new IllegalStateException("error handler failed");
			});
			get("/x", context -> {
				throw new HttpException(400, "bad");
			});
		});
	}
};

/admin/x に来ると、内側 → 外側の順に呼ばれます。 内側が例外を投げても外側に進みます(下の「ハンドラ自身が失敗したら」)。

補足

未マッチ(404)のときだけ扱いが違います。いちばん外側のスコープに直接書いた error だけが呼ばれます。 どのルートにも当たっていないので、「内側」が決まらないためです。 path("/admin", ...) の中に書いた error は、/admin/nope でも呼ばれません。

ステータスコードの決まり方#

JimbleApp#resolveStatusCode(Throwable) が決めます。既定はこれだけです。

例外 コード
HttpException statusCode() の値
NotFoundException(HttpException の子) 404
ValidationException(HttpException の子) 422
そのほか全部 500
JimbleApp app = new JimbleApp() {
	{
		error((context, cause, statusCode) -> {
			log.add("error:" + statusCode);
			context.response().code(statusCode).send();
		});
		get("/forbidden", context -> {
			throw new HttpException(403, "だめ");
		});
	}
};

自分の例外にコードを割り当てるなら上書きします。

JimbleApp app = new JimbleApp() {

	{
		error((context, cause, statusCode) -> log.add("error:" + statusCode));
		get("/x", context -> {
			throw new MyException();
		});
	}

	@Override
	protected int resolveStatusCode (Throwable cause) {
		if (cause instanceof MyException) {
			return 409;
		}
		return super.resolveStatusCode(cause);
	}

};

注意

CodeException(io.jimble.util.exception.CodeException)は HTTP のステータスには効きません。 コードつきの非検査例外(RuntimeException の子)で、投げれば 500 です。 DB の失敗(SqlExecuteException)なども、何もしなければ 500 です(下の「枠組みが投げる例外」)。

本文は誰が作るか#

順番はこうです。

  1. context.response().code(statusCode) を先に入れる(ハンドラが上書きできる)
  2. error(...) を内側から順に呼ぶ。送った時点で止まる
  3. 誰も本文を用意していなければ、既定のエラー応答を入れる
  4. response().send() を呼ぶ

4 が send(statusCode) ではなく send() なのが要点です。 ハンドラが json(...) などで組み立てた中身を、捨てずに送るためです。

**500 番台では cause.getMessage() を返さないでください。**DB の誤り(重複したキーの値など)や内部のパスが、相手にそのまま届きます。中身はログで見ます。

error((context, cause, statusCode) -> {

	// API なら JSON、画面なら HTML
	if (context.request().acceptJson()) {
		context.response().json("error", (statusCode < 500 ? cause.getMessage() : "サーバーで問題が起きました"));
		return;
	}

	context.response().send("エラー: %d %s".formatted(statusCode, (statusCode < 500 ? cause.getMessage() : "サーバーで問題が起きました")));

});

注意

この例のように cause.getMessage() を本文に出すのは、中身次第では外に見せてはいけないものです。 500 のときは DB のエラー文や内部のパスがそのまま入ります。 Throwable をそのまま json(...) に渡すと、スタックトレースが丸ごと出ます。 既定のエラー応答(次の節)はどちらも載せませんが、自分で書いたぶんは自分で抑えてください。

何も書かなかったとき#

error(...) を1つも書いていない、あるいは書いたけれど本文を作らなかったとき、 jimble が決まった形を返します。

Accept が JSON を名指ししていれば JSON です。

{"error": {"status": 404, "message": "Not Found"}}

そうでなければ、短いテキストです(Content-Type: text/plain; charset=UTF-8)。

404 Not Found
  • message は RFC 9110 の短い語です(Not Found / Internal Server Error)。 ステータス行やクライアントのライブラリに出てくる語と同じにしてあります
  • 原因は入りません。 例外のメッセージも SQL もスタックトレースも載せません。 原因はログに出ています(500 番台は Log.error)
  • */* はテキストです。「何でもいい」であって「JSON がいい」ではありません
  • error(...) で組み立てていれば、そちらが勝ちます。 送信まで済んでいなくてもです

acceptJson() は Accept が application/json か text/javascript を 名指ししているかを見ます。application/json;q=0.9 のようにパラメータが付いていても効きます。 q=0(要らない)と */*(何でもいい)は名指しに数えません。

405 と 404 は分かれます#

パスは合っていてメソッドだけ違うときは 405 で、Allow が付きます。

$ curl -i -X POST http://localhost:9000/hello
HTTP/1.1 405 Method Not Allowed
Allow: GET

404 は「そんなものは無い」、405 は「あるが、その呼び方ではない」です。 一緒にすると、post と書くべきところを get と書いただけの間違いが 「パスが違う」に見えてしまいます。

ハンドラ自身が失敗したら#

握って次(外側)へ進みます。 エラー処理の失敗で何も返せなくなるほうが困るからです。 失敗は エラーハンドラで例外が発生しました としてログに出ます。

ログの出しかた#

ステータス ログ
500 番台 Log.error(スタックトレースつき)
それ以外(404 を含む) Log.debug

こつ

404 をエラーログに出さないのは意図的です。 クローラや古いリンクで毎日何千件も出るものを error に混ぜると、 本物の 500 が埋もれます。

after と onComplete の中で例外が出た場合も、握ってログに出します(レスポンスは返ります)。

検証の失敗#

validate(...) は例外で返る#

ValidationRules.validate(db, data) / Validator.validate(...) は、通らなければ ValidationException(422。HttpException の子)を投げます。 ほかの例外と同じく error(...) を通り、誰も本文を作らなければ枠組みが次の形(ValidationExecutor と同じ)で返します。

rules.validate(db, data);   // 通らなければここで抜けて 422

一覧を自分で扱いたいときは errors(db, data) で受け取ってください(例外にしません)。 一覧の検査は validate(db, List) / errors(db, List) で、本文は {"rows": [...]} です。

ValidationExecutor は error を通らない#

ValidationExecutor は例外を投げません。 自分をキャンセルして、後続の Executor を捨て、そのまま返します。

context.response().putForm(context.request().bodyAll());
context.response().json("validation", errors);
context.response().code(422);

返る形はこうです。入力値も一緒に返るので、画面を組み直せます。

{
  "validation": { "title": ["入力してください"] },
  "title": "",
  "body": "..."
}

落とし穴

ValidationExecutor の失敗は、error(...) に整形を書いても呼ばれません。 422 の見た目を変えたいときは ValidationExecutor 側(onCancel)を見てください。 validate(...) の ValidationException のほうは error(...) で組み立てられます。

枠組みが投げる例外#

どれも非検査例外です。HttpException の子のほかは、何もしなければ 500 になります。

例外 いつ 親
SqlExecuteException SQL が失敗した。getCode() でコードが取れる(下の表) RuntimeException
DuplicateKeyException 一意制約に当たった SqlExecuteException
TransactionException トランザクションを確定・開始・巻き戻しできなかった SqlExecuteException
SqlBuildException SQL を組めない(eq(null) / not(null) など) RuntimeException
RedisLockException RedisLock.lock(...) でロックが取れなかった RuntimeException
DataConversionException Data の値が、あるのに読めない(getInt に "abc" など) IllegalArgumentException
JsonParseException Data.fromJsonString / Dson.decodes に壊れた JSON IllegalArgumentException
ValidationException validate(...) が通らなかった(422) HttpException
CodeException コードつきの失敗(ほかの例外の getCause() に入っていることが多い) RuntimeException

補足

本文が application/json なのに読めないときは、body() / bodyJson() / bodyAll() が 400 の HttpException を投げます。 JsonParseException(500)にはなりません。

DB のコードはこうです。元の JDBC の例外は getCause().getCause() にあります。

コード 例外 意味
DB_001 TransactionException トランザクションを始められなかった
DB_002 TransactionException 巻き戻せなかった
DB_003 TransactionException コミットできなかった
DB_004 TransactionException トランザクションの中で SQL が失敗していたので、コミットしなかった(全部巻き戻した)
DB_005 TransactionException 合流した中の Tx が巻き戻しを求めたので、外をコミットしなかった(全部巻き戻した)
DB_006 TransactionException transaction(tx -> ...) の中身が検査例外を投げた(包んで投げ直した)
DB_007 SqlExecuteException DBUtil.load(...) がデータソースを作れなかった(起動が止まる)
DB_998 SqlExecuteException executeBatch / insertBatch に違う SQL が混ざっていた
DB_999 SqlExecuteException / DuplicateKeyException SQL の実行に失敗した

詳しくは DB を使う と トランザクション を見てください。

アプリ全体で差し替えられるもの#

JimbleApp で上書きできるのは3つだけです。

メソッド いつ呼ばれるか
protected void onRequest (WebContext context) throws Exception 全リクエストの最初。**未マッチでも呼ばれる。**ここで送れば以降は走らない
protected void onComplete (WebContext context) 全リクエストの最後。例外が出ても必ず呼ばれる
protected int resolveStatusCode (Throwable cause) 例外 → ステータスコード

ディスパッチャそのものは差し替えられません(final)。 道を1本にしておくためです。