Exceptions
Handlers fail: a record does not exist, a client is not allowed in, input is invalid, or a bug throws an unexpected exception. In Kona you usually throw in those situations instead of building an error response by hand. The HTTP kernel catches the exception and gives it to an exception handler — an object implementing ExceptionHandler — whose only job is to turn the exception into a Response.
fun interface ExceptionHandler { fun handle(request: Request, throwable: Throwable): Response}Unless you replace it, the kernel uses DefaultExceptionHandler. By default it answers with JSON. If the request Accept header prefers text/html (and does not also list application/json), the same handler returns a small HTML error page instead.
Throwing HTTP errors
Section titled “Throwing HTTP errors”HttpException carries an HTTP status, a message, and optional extra response headers. Throw it from a handler, a controller, a service, or a middleware:
router.get("/posts/{id}") { request -> val post = posts.find(request.getAttribute("id") as String) ?: throw NotFoundException("Post not found.") if (!post.published) { throw HttpException(403, "This post is not published yet.") } Response.json(post)}
throw HttpException(429, "Too many requests.", mapOf("Retry-After" to "30"))router.get("/posts/{id}", request -> { Post post = posts.find((String) request.getAttribute("id")); if (post == null) { throw new NotFoundException("Post not found."); } if (!post.isPublished()) { throw new HttpException(403, "This post is not published yet."); } return Response.json(post);});
throw new HttpException(429, "Too many requests.", Map.of("Retry-After", "30"));Kona ships these types in org.konaframework.http:
| Type | Status | Notes |
|---|---|---|
HttpException(status, message, headers) |
Any | headers defaults to none. It is an open class, so you can subclass it for your own error types. |
NotFoundException(message) |
404 | Message defaults to Not Found. The router throws it when no route matches. |
MethodNotAllowedException(allowed, message) |
405 | Adds Allow: GET, HEAD (the allowed list, comma-and-space separated) when the list is not empty. Message defaults to Method Not Allowed. The router throws it when the path exists under other methods. |
ValidationException(errors) |
422 | Thrown by Request.validate and Validator.validate; see Validation. |
All of them are unchecked (RuntimeException), so Java code does not need throws clauses.
What the default handler returns
Section titled “What the default handler returns”DefaultExceptionHandler chooses a response by exception type:
Validation failures → 422
Section titled “Validation failures → 422”{ "message": "The email field is required. (and 1 more error)", "errors": { "email": ["The email field is required."], "age": ["The age field must be at least 18."] }}HttpException and subclasses → the exception’s status
Section titled “HttpException and subclasses → the exception’s status”The body is {"message": "<exception message>"}, and every entry in the exception’s headers is copied onto the response. The messages the router produces look like this:
{ "message": "The route users/42 could not be found." }{ "message": "The POST method is not supported for route users. Supported methods: GET, HEAD." }The 405 response also carries the header Allow: GET, HEAD.
Anything else → 500
Section titled “Anything else → 500”Any other Throwable is treated as a bug. The handler logs it at error level (with its stack trace) through Kona’s logger and responds with:
{ "message": "Server Error" }The real message is hidden on purpose — exception text often contains SQL, file paths, or other internals you should not show to clients.
JSON responses from the default handler use Content-Type: application/json; charset=utf-8. HTML error pages use text/html; charset=utf-8.
Report and render callbacks
Section titled “Report and render callbacks”Without writing a full custom handler, you can attach callbacks on DefaultExceptionHandler:
val handler = DefaultExceptionHandler(debug = true) .report { throwable -> metrics.increment("http.exceptions") } .render { request, throwable -> if (throwable is PaymentFailedException) { Response.json(mapOf("error" to "payment_failed"), 402) } else { null // fall through to the default envelope } }DefaultExceptionHandler handler = new DefaultExceptionHandler(true) .report(throwable -> metrics.increment("http.exceptions")) .render((request, throwable) -> { if (throwable instanceof PaymentFailedException) { return Response.json(Map.of("error", "payment_failed"), 402); } return null; });report runs first. When you set it, the default Log.error path for unexpected failures is skipped — your callback owns reporting. render may return a Response or null to keep the built-in JSON/HTML envelopes.
Debug mode
Section titled “Debug mode”When debug mode is on, the default handler helps you diagnose problems locally:
- For 500 responses,
messageis the exception’s real message (which may benull) instead ofServer Error. - For 500 and
HttpExceptionresponses, an extraexceptionfield holds the fully qualified class name.
{ "message": "Connection refused", "exception": "java.net.ConnectException"}Validation responses are the same in both modes.
The kernel registered by HttpServiceProvider reads debug mode from the app.debug config value, which comes from APP_DEBUG in the environment or .env (default false). Projects created by kona new set APP_DEBUG=true in .env for local work. Set APP_DEBUG=false in production, either in that environment’s .env or as a real environment variable, which takes precedence. To build a handler directly, pass the flag: DefaultExceptionHandler(debug = true) in Kotlin, new DefaultExceptionHandler(true) in Java.
Where exceptions are caught
Section titled “Where exceptions are caught”The kernel converts exceptions at two points:
- Inside routing — exceptions from route resolution (404, 405), route and group middleware, and your handler. The resulting response travels back out through all global middleware, so access logs, CORS headers, and request IDs still apply to error responses.
- Around the whole pipeline — exceptions thrown by a global middleware. These responses are not seen by any global middleware.
For HEAD requests the kernel removes the body from error responses too, keeping the status and headers. See Middleware for how this interacts with your own middleware.
Writing a custom handler
Section titled “Writing a custom handler”Write your own ExceptionHandler when you need a different error format — HTML pages for a browser site, a different JSON shape for an API, or metrics on every failure. A common pattern is to handle the cases you care about and delegate everything else to the default handler:
class HtmlErrorHandler(debug: Boolean) : ExceptionHandler { private val fallback = DefaultExceptionHandler(debug)
override fun handle(request: Request, throwable: Throwable): Response { val wantsHtml = request.header("Accept").orEmpty().contains("text/html") if (wantsHtml && throwable is NotFoundException) { return Response.html("<h1>Page not found</h1>", 404) } return fallback.handle(request, throwable) }}public final class HtmlErrorHandler implements ExceptionHandler { private final DefaultExceptionHandler fallback;
public HtmlErrorHandler(boolean debug) { this.fallback = new DefaultExceptionHandler(debug); }
@Override public Response handle(Request request, Throwable throwable) { boolean wantsHtml = request.header("Accept", "").contains("text/html"); if (wantsHtml && throwable instanceof NotFoundException) { return Response.html("<h1>Page not found</h1>", 404); } return fallback.handle(request, throwable); }}An exception handler must always return a response and never throw. It is the last line of defence; an exception escaping it leaves the client without a reply.
Installing it
Section titled “Installing it”Pass the handler as the third argument when you construct an HttpKernel. To replace the kernel that HttpServiceProvider registers, rebind HttpKernel from the register method of a provider listed after HttpServiceProvider:
class AppServiceProvider : ServiceProvider { override fun register(app: Application) { val debug = app.config.getBoolean("app.debug", false) app.container.singleton(HttpKernel::class.java) { c -> HttpKernel(c.resolve(Router::class.java), exceptionHandler = HtmlErrorHandler(debug)) } }}public class AppServiceProvider implements ServiceProvider { @Override public void register(Application app) { boolean debug = app.getConfig().getBoolean("app.debug", false); app.getContainer().singleton(HttpKernel.class, c -> new HttpKernel(c.resolve(Router.class), List.of(), new HtmlErrorHandler(debug))); }}ExceptionHandler is a functional interface, so a lambda works as well when the logic is short.
HTML vs JSON selection
Section titled “HTML vs JSON selection”DefaultExceptionHandler uses a small Accept check: if Accept contains text/html and does not also contain application/json, you get the HTML error page. Otherwise JSON. Validation failures use request.expectsJson() instead (JSON Accept/Content-Type → 422 JSON; else redirect-back).
Pitfalls and FAQ
Section titled “Pitfalls and FAQ”Why is my custom message missing in production?
Non-HttpException failures show "Server Error" when app.debug is false. HttpException messages are always used.
Why don’t global middleware see an error from another global middleware?
That catch happens outside the pipeline — see Where exceptions are caught.
Can the handler throw?
It must not. Always return a Response.
What is not available today
Section titled “What is not available today”- Built-in HTML error templates beyond the small default page
- Per-status view mapping in the default handler
- Exception reporting channels beyond
Log.error/ yourreportcallback
Next steps
Section titled “Next steps”- Validation errors: Validation.
- Middleware catch semantics: Middleware.
- Kernel pipeline: Kernel & server.