Repository: javalin/javalin
Pull request #2385 (2025-04-08 02:01:16)

Map client aborts to 499 and timeouts to 408.
## 🧠 Motivation

Map client aborts to 499 and timeouts to 408.

Previously, both were returned as 500, mixing them with genuine server errors. Now, client disconnects are tracked as 499 (Client Closed Request), and request timeouts produce 408 (Request Timeout) to better categorize errors in logs and monitoring tools.

- **Author:** @martinsk82 [martin.kozlowski@mercadolibre.com]
- **Affiliation:** MercadoLibre
- **OSPO Contact:** ospo@mercadolibre.com
- **Context:** Strategic collaboration from Mercadolibre's internal Open Source Program Office (OSPO)

tipsy: This looks really great @martinsk82, thank you !

Patch:
diff --git a/javalin/src/main/java/io/javalin/config/JettyConfig.kt b/javalin/src/main/java/io/javalin/config/JettyConfig.kt
index 7b370774..cdd6c090 100644
--- a/javalin/src/main/java/io/javalin/config/JettyConfig.kt
+++ b/javalin/src/main/java/io/javalin/config/JettyConfig.kt
@@ -23,6 +23,12 @@ class JettyConfig(private val cfg: JavalinConfig) {
     @JvmField var defaultPort = 8080
     @JvmField var multipartConfig = MultipartConfig()
     @JvmField var threadPool: ThreadPool? = null
+    /** Default HTTP status code when the server had a timeout. */
+    //TODO: change to 408 for javalin7
+    @JvmField var timeoutStatus = 500
+    /** Default HTTP status code when the client closes the connection. */
+    //TODO: change to 499 for javalin7
+    @JvmField var clientAbortStatus = 500
     //@formatter:on
 
     /** Configure the jetty [Server]. This is useful if you want to configure Jetty features that are not exposed by Javalin.
diff --git a/javalin/src/main/java/io/javalin/config/PrivateConfig.kt b/javalin/src/main/java/io/javalin/config/PrivateConfig.kt
index 9923d573..7e276015 100644
--- a/javalin/src/main/java/io/javalin/config/PrivateConfig.kt
+++ b/javalin/src/main/java/io/javalin/config/PrivateConfig.kt
@@ -25,7 +25,7 @@ class PrivateConfig(val cfg: JavalinConfig) {
 
     @JvmField val eventManager = EventManager()
     @JvmField val wsRouter = WsRouter(cfg.router)
-    @JvmField var internalRouter = InternalRouter(wsRouter, eventManager, cfg.router)
+    @JvmField var internalRouter = InternalRouter(wsRouter, eventManager, cfg.router, cfg.jetty)
     @JvmField var appDataManager = AppDataManager()
     @JvmField var pluginManager = PluginManager(cfg)
     @JvmField var jsonMapper: Lazy<JsonMapper> = javalinLazy { JavalinJackson(null, cfg.useVirtualThreads) }
diff --git a/javalin/src/main/java/io/javalin/http/HttpResponseException.kt b/javalin/src/main/java/io/javalin/http/HttpResponseException.kt
index c8a6f354..91707f9a 100644
--- a/javalin/src/main/java/io/javalin/http/HttpResponseException.kt
+++ b/javalin/src/main/java/io/javalin/http/HttpResponseException.kt
@@ -10,6 +10,7 @@ import io.javalin.http.HttpStatus.ACCEPTED
 import io.javalin.http.HttpStatus.ALREADY_REPORTED
 import io.javalin.http.HttpStatus.BAD_GATEWAY
 import io.javalin.http.HttpStatus.BAD_REQUEST
+import io.javalin.http.HttpStatus.CLIENT_CLOSED_REQUEST
 import io.javalin.http.HttpStatus.CONFLICT
 import io.javalin.http.HttpStatus.CONTENT_TOO_LARGE
 import io.javalin.http.HttpStatus.CONTINUE
@@ -346,6 +347,11 @@ open class UnavailableForLegalReasonsResponse @JvmOverloads constructor(
     details: Map<String, String> = mapOf()
 ) : HttpResponseException(UNAVAILABLE_FOR_LEGAL_REASONS, message, details)
 
+open class ClientClosedRequestResponse @JvmOverloads constructor(
+    message: String = CLIENT_CLOSED_REQUEST.message,
+    details: Map<String, String> = mapOf()
+) : HttpResponseException(CLIENT_CLOSED_REQUEST, message, details)
+
 open class InternalServerErrorResponse @JvmOverloads constructor(
     message: String = INTERNAL_SERVER_ERROR.message,
     details: Map<String, String> = mapOf()
diff --git a/javalin/src/main/java/io/javalin/http/HttpStatus.kt b/javalin/src/main/java/io/javalin/http/HttpStatus.kt
index e30da1de..4c2accee 100644
--- a/javalin/src/main/java/io/javalin/http/HttpStatus.kt
+++ b/javalin/src/main/java/io/javalin/http/HttpStatus.kt
@@ -110,6 +110,8 @@ enum class HttpStatus(val code: Int, val message: String) {
     REQUEST_HEADER_FIELDS_TOO_LARGE(431, "Request Header Fields Too Large"),
     /** The user agent requested a resource that cannot legally be provided, such as a web page censored by a government. */
     UNAVAILABLE_FOR_LEGAL_REASONS(451, "Unavailable for Legal Reason"),
+    /** The client has closed the connection prematurely, before the server could deliver a response. */
+    CLIENT_CLOSED_REQUEST(499, "Client Closed Request"),
 
     /** The server has encountered a situation it does not know how to handle. */
     INTERNAL_SERVER_ERROR(500, "Server Error"),
diff --git a/javalin/src/main/java/io/javalin/router/InternalRouter.kt b/javalin/src/main/java/io/javalin/router/InternalRouter.kt
index 898187c9..3f900763 100644
--- a/javalin/src/main/java/io/javalin/router/InternalRouter.kt
+++ b/javalin/src/main/java/io/javalin/router/InternalRouter.kt
@@ -1,5 +1,6 @@
 package io.javalin.router
 
+import io.javalin.config.JettyConfig
 import io.javalin.config.RouterConfig
 import io.javalin.event.EventManager
 import io.javalin.event.HandlerMetaInfo
@@ -26,12 +27,13 @@ import java.util.stream.Stream
 open class InternalRouter(
     private val wsRouter: WsRouter,
     private val eventManager: EventManager,
-    private val routerConfig: RouterConfig
+    private val routerConfig: RouterConfig,
+    jettyConfig: JettyConfig
 ) {
 
     protected open val httpPathMatcher = PathMatcher()
     protected open val httpErrorMapper = ErrorMapper()
-    protected open val httpExceptionMapper = ExceptionMapper(routerConfig)
+    protected open val httpExceptionMapper = ExceptionMapper(routerConfig, jettyConfig)
 
     /**
      * Adds a request handler for the specified handlerType and path to the instance.
diff --git a/javalin/src/main/java/io/javalin/router/exception/ExceptionMapper.kt b/javalin/src/main/java/io/javalin/router/exception/ExceptionMapper.kt
index 7766f4a1..ae2ac611 100644
--- a/javalin/src/main/java/io/javalin/router/exception/ExceptionMapper.kt
+++ b/javalin/src/main/java/io/javalin/router/exception/ExceptionMapper.kt
@@ -6,10 +6,12 @@
 
 package io.javalin.router.exception
 
+import io.javalin.config.JettyConfig
 import io.javalin.config.RouterConfig
 import io.javalin.http.Context
 import io.javalin.http.ExceptionHandler
 import io.javalin.http.HttpResponseException
+import io.javalin.http.HttpStatus
 import io.javalin.http.HttpStatus.INTERNAL_SERVER_ERROR
 import io.javalin.http.InternalServerErrorResponse
 import io.javalin.util.JavalinLogger
@@ -19,7 +21,7 @@ import java.io.IOException
 import java.util.concurrent.CompletionException
 import java.util.concurrent.TimeoutException
 
-class ExceptionMapper(private val routerConfig: RouterConfig) {
+class ExceptionMapper(private val routerConfig: RouterConfig, private val jettyConfig: JettyConfig) {
 
     val handlers = mutableMapOf<Class<out Exception>, ExceptionHandler<Exception>?>()
 
@@ -34,7 +36,8 @@ class ExceptionMapper(private val routerConfig: RouterConfig) {
             return handle(ctx, t.cause as Exception)
         }
         when {
-            isSomewhatExpectedException(t) -> logDebugAndSetError(t, ctx.res())
+            isClientAbortException(t) -> logDebugAndSetError(t, ctx.res(), HttpStatus.forStatus(jettyConfig.clientAbortStatus))
+            isJettyTimeoutException(t) -> logDebugAndSetError(t, ctx.res(), HttpStatus.forStatus(jettyConfig.timeoutStatus))
             t is Exception -> Util.findByClass(handlers, t.javaClass)?.handle(t, ctx) ?: uncaughtException(ctx, t)
             else -> handleUnexpectedThrowable(ctx.res(), t)
         }
@@ -49,7 +52,8 @@ class ExceptionMapper(private val routerConfig: RouterConfig) {
         res.status = INTERNAL_SERVER_ERROR.code
         when {
             throwable is Error -> routerConfig.javaLangErrorHandler.handle(res, throwable)
-            isSomewhatExpectedException(throwable) -> logDebugAndSetError(throwable, res)
+            isClientAbortException(throwable) -> logDebugAndSetError(throwable, res, HttpStatus.forStatus(jettyConfig.clientAbortStatus))
+            isJettyTimeoutException(throwable) -> logDebugAndSetError(throwable, res, HttpStatus.forStatus(jettyConfig.timeoutStatus))
             else -> JavalinLogger.error("Exception occurred while servicing http-request", throwable)
         }
         return null
@@ -57,19 +61,16 @@ class ExceptionMapper(private val routerConfig: RouterConfig) {
 
 }
 
+private fun unwrap(t: Throwable) = (t as? CompletionException)?.cause ?: t
+
 // Jetty throws if client aborts during response writing. testing name avoids hard dependency on jetty.
-private fun isClientAbortException(t: Throwable) = t::class.java.name == "org.eclipse.jetty.io.EofException"
+private fun isClientAbortException(t: Throwable) = unwrap(t)::class.java.name == "org.eclipse.jetty.io.EofException"
 
 // Jetty may time out connections to avoid having broken connections that remain open forever
 // This is rare, but intended (see issues #163 and #1277)
-private fun isJettyTimeoutException(t: Throwable) = t is IOException && t.cause is TimeoutException
-
-private fun isSomewhatExpectedException(t: Throwable): Boolean {
-    val unwrapped = (t as? CompletionException)?.cause ?: t
-    return isClientAbortException(unwrapped) || isJettyTimeoutException(unwrapped)
-}
+private fun isJettyTimeoutException(t: Throwable) = unwrap(t) is IOException && t.cause is TimeoutException
 
-private fun logDebugAndSetError(t: Throwable, res: HttpServletResponse) {
-    JavalinLogger.debug("Client aborted or timed out", t)
-    res.status = INTERNAL_SERVER_ERROR.code
+private fun logDebugAndSetError(t: Throwable, res: HttpServletResponse, status: HttpStatus) {
+    JavalinLogger.debug("HTTP " + status.code + " " + status.message, t)
+    res.status = status.code
 }


Test patch:
diff --git a/javalin/src/test/java/io/javalin/TestExceptionMapper.kt b/javalin/src/test/java/io/javalin/TestExceptionMapper.kt
index 3fee572a..910ee24d 100644
--- a/javalin/src/test/java/io/javalin/TestExceptionMapper.kt
+++ b/javalin/src/test/java/io/javalin/TestExceptionMapper.kt
@@ -9,6 +9,7 @@ package io.javalin
 
 import io.javalin.http.BadRequestResponse
 import io.javalin.http.HttpResponseException
+import io.javalin.http.HttpStatus
 import io.javalin.http.HttpStatus.BAD_REQUEST
 import io.javalin.http.HttpStatus.INTERNAL_SERVER_ERROR
 import io.javalin.http.HttpStatus.NOT_FOUND
@@ -20,6 +21,8 @@ import io.javalin.testing.httpCode
 import org.assertj.core.api.Assertions.assertThat
 import org.eclipse.jetty.io.EofException
 import org.junit.jupiter.api.Test
+import java.io.IOException
+import java.util.concurrent.TimeoutException
 import kotlin.reflect.full.allSuperclasses
 
 class TestExceptionMapper {
@@ -105,9 +108,19 @@ class TestExceptionMapper {
     }
 
     @Test
-    fun `jetty eof exceptions are caught and handled as errors`() = TestUtil.test { app, http ->
+    fun `jetty eof exceptions are caught and handled as errors`() = TestUtil.test(Javalin.create {
+        it.jetty.clientAbortStatus = 499
+    }) { app, http ->
         app.get("/") { throw EofException() }
-        assertThat(http.get("/").httpCode()).isEqualTo(INTERNAL_SERVER_ERROR)
+        assertThat(http.get("/").httpCode()).isEqualTo(HttpStatus.forStatus(499))
+    }
+
+    @Test
+    fun `jetty timeout exceptions are caught and handled as errors`() = TestUtil.test(Javalin.create {
+        it.jetty.timeoutStatus = 408
+    }) { app, http ->
+        app.get("/") { throw IOException("timeout", TimeoutException()) }
+        assertThat(http.get("/").httpCode()).isEqualTo(HttpStatus.forStatus(408))
     }
 
     @Test