jimble

Error handling#

There are three paths by which an error reaches the surface. They go through different places.

What happened Who catches it What comes back by default
Something threw The error(...) hook The status depends on the exception. The app builds the body
No route matched The outermost error(...) 404
Validation failed ValidationExecutor (error is not reached) 422 and a validation JSON body

Writing an error handler#

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

It takes three arguments. You get the status code, not only the exception. That way you do not re-derive the code from cause every single time.

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

How far it reaches#

Same as before / after: it attaches to the block you wrote it in (not to a node in the path). Execution runs from the inside outward. See Routing for the details.

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");
			});
		});
	}
};

A request to /admin/x calls them inner first, then outer. Even when the inner one throws, execution moves on to the outer one (see "When the handler itself fails" below).

Note

A miss (404) is the one case that works differently. Only an error written directly in the outermost scope is called. No route matched, so there is no "inner" to pick. An error written inside path("/admin", ...) is not called, not even for /admin/nope.

How the status code is decided#

JimbleApp#resolveStatusCode(Throwable) decides it. The default is only this.

Exception Code
HttpException Whatever statusCode() says
NotFoundException (a subclass of HttpException) 404
Everything else 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, "だめ");
		});
	}
};

Override it to assign codes to your own exceptions.

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);
	}

};

Careful

CodeException (io.jimble.util.exception.CodeException) has no effect on the HTTP status. It is the checked exception used for DB errors (db.getError()) and inside validators; throw it and you get a 500.

Who builds the body#

The order is this.

  1. context.response().code(statusCode) goes in first (so a handler can override it)
  2. error(...) handlers are called from the inside out. It stops the moment one of them sends
  3. If nobody has built a body, the default error response goes in
  4. response().send() is called

The point of step 4 is that it is send(), not send(statusCode). Whatever a handler built up with json(...) or the like is sent, not thrown away.

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

	// JSON for an API, HTML for a screen
	if (context.request().acceptJson()) {
		context.response().json("error", cause.getMessage());
		return;
	}

	context.response().send("error: %d %s".formatted(statusCode, cause.getMessage()));

});

Careful

Putting cause.getMessage() in the body, as above, means whatever is in it goes out. On a 500 that is the DB's error text and your internal paths. Hand a Throwable straight to json(...) and the whole stack trace goes out. The default response below carries neither, but what you write is yours to keep clean.

When you write nothing#

With no error(...) at all — or one that never builds a body — jimble returns a fixed shape.

If Accept names JSON, it is JSON.

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

Otherwise it is a short line of text (Content-Type: text/plain; charset=UTF-8).

404 Not Found
  • message is the short phrase from RFC 9110 (Not Found, Internal Server Error) — the same wording you see in the status line and in client libraries
  • The cause is not in there. No exception message, no SQL, no stack trace. The cause is in the log (5xx goes to Log.error)
  • */* gets text. "Anything will do" is not "JSON, please"
  • An error(...) handler wins, even one that built a body without sending it

acceptJson() asks whether Accept names application/json or text/javascript. Parameters are fine — application/json;q=0.9 counts. q=0 (don't want it) and */* (anything will do) do not.

405 and 404 are different answers#

When the path matches and only the method is wrong, you get a 405 with an Allow header.

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

404 means "there is no such thing"; 405 means "there is, but not by that name". Merge them and writing get where you meant post looks like a wrong path.

When the handler itself fails#

We swallow it and move on to the next one, outward. Being left unable to return anything because error handling failed is the worse outcome. The failure is logged as エラーハンドラで例外が発生しました.

How it is logged#

Status Log
5xx Log.error (with a stack trace)
Everything else (404 included) Log.debug

Tip

Keeping 404 out of the error log is deliberate. Mix in the thousands a day that crawlers and stale links produce, and the real 500s are buried.

An exception thrown inside after or onComplete is swallowed and logged the same way (the response still goes out).

Validation failures do not go through error#

ValidationExecutor does not throw. It cancels itself, discards the executors behind it, and returns as-is.

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

This is the shape you get back. The input comes back with it, so you can rebuild the form.

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

Trap

Put your validation-error formatting in error(...) and it is never called. To change how a 422 looks, work on the ValidationExecutor side (onCancel).

What you can replace application-wide#

There are exactly three things you can override on JimbleApp.

Method When it is called
protected void onRequest (WebContext context) throws Exception First thing on every request. Called even on a miss. Send from here and nothing after it runs
protected void onComplete (WebContext context) Last thing on every request. Always called, exception or not
protected int resolveStatusCode (Throwable cause) Exception to status code

The dispatcher itself cannot be replaced (it is final). There is meant to be exactly one path through.