Skip to content
Kona

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.

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

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.

DefaultExceptionHandler chooses a response by exception type:

{
"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.

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.

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

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.

When debug mode is on, the default handler helps you diagnose problems locally:

  • For 500 responses, message is the exception’s real message (which may be null) instead of Server Error.
  • For 500 and HttpException responses, an extra exception field 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.

The kernel converts exceptions at two points:

  1. 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.
  2. 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.

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

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.

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

ExceptionHandler is a functional interface, so a lambda works as well when the logic is short.

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).

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.

  • Built-in HTML error templates beyond the small default page
  • Per-status view mapping in the default handler
  • Exception reporting channels beyond Log.error / your report callback