Repository: tipsy/javalin
Pull request #1149 (2021-01-20 16:21:33)

Optionally tagging 3xx and 404 responses in the Micrometer plugin
Currently, the Micrometer plugin tags 3xx and 404 responses with the values `REDIRECTION` and `NOT_FOUND`, respectively. This PR adds configuration parameters on the plugin constructor to make it possible for the paths to requests like these to be tagged normally.

(In my case, the biggest motivator for this is for 304 responses generated via `config.autoGenerateEtags`)


Patch:
diff --git a/README.md b/README.md
index 0e2c88d3..55ca8410 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,5 @@
 [![Chat at https://gitter.im/javalin-io/general](https://badges.gitter.im/javalin-io/general.svg)](https://gitter.im/javalin-io/general)
+[![Chat at https://discord.gg/sgak4e5NKv](https://img.shields.io/badge/chat-on%20Discord-%234cb697)](https://discord.gg/sgak4e5NKv)
 [![Travis](https://github.com/tipsy/javalin/workflows/Test%20all%20JDKs%20on%20all%20OSes/badge.svg)](https://github.com/tipsy/javalin/actions)
 [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
 [![Maven](https://img.shields.io/maven-central/v/io.javalin/javalin.svg)](https://search.maven.org/#search%7Cgav%7C1%7Cg%3A%22io.javalin%22%20AND%20a%3A%22javalin%22)
@@ -16,7 +17,8 @@ Javalin is more of a library than a framework. Some key points:
 General information:
 * The project webpage is [javalin.io](https://javalin.io) (repo for webpage is at [github.com/javalin/javalin.github.io](https://github.com/javalin/javalin.github.io)).
 * Documentation: [javalin.io/documentation](https://javalin.io/documentation)
-* Chat: https://gitter.im/javalin-io/general
+* Chat on Gitter: https://gitter.im/javalin-io/general
+* Chat on Discord: https://discord.gg/sgak4e5NKv
 * Contributions are very welcome: [CONTRIBUTING.md](https://github.com/tipsy/javalin/blob/master/CONTRIBUTING.md)
 * License summary: https://tldrlegal.com/license/apache-license-2.0-(apache-2.0)
 * Interesting issues: [/tipsy/javalin/issues?q=label:INFO](https://github.com/tipsy/javalin/issues?q=is%3Aissue+label%3AINFO)
@@ -31,14 +33,14 @@ General information:
 <dependency>
     <groupId>io.javalin</groupId>
     <artifactId>javalin</artifactId>
-    <version>3.12.0</version>
+    <version>3.13.3</version>
 </dependency>
 ```
 
 #### Gradle
 
 ```groovy
-compile "io.javalin:javalin:3.12.0"
+compile "io.javalin:javalin:3.13.3"
 ```
 
 ### Start programming (Java)
diff --git a/javalin-bundle/pom.xml b/javalin-bundle/pom.xml
index afb04382..6ff5a0c8 100644
--- a/javalin-bundle/pom.xml
+++ b/javalin-bundle/pom.xml
@@ -3,7 +3,7 @@
     <parent>
         <artifactId>javalin-parent</artifactId>
         <groupId>io.javalin</groupId>
-        <version>3.13.3-SNAPSHOT</version>
+        <version>3.13.4-SNAPSHOT</version>
     </parent>
     <modelVersion>4.0.0</modelVersion>
 
diff --git a/javalin-graphql/pom.xml b/javalin-graphql/pom.xml
index 6b8df2fd..023c29f6 100644
--- a/javalin-graphql/pom.xml
+++ b/javalin-graphql/pom.xml
@@ -3,7 +3,7 @@
     <parent>
         <artifactId>javalin-parent</artifactId>
         <groupId>io.javalin</groupId>
-        <version>3.13.3-SNAPSHOT</version>
+        <version>3.13.4-SNAPSHOT</version>
     </parent>
     <modelVersion>4.0.0</modelVersion>
 
diff --git a/javalin-openapi/pom.xml b/javalin-openapi/pom.xml
index 11cd5c03..e7f0f588 100644
--- a/javalin-openapi/pom.xml
+++ b/javalin-openapi/pom.xml
@@ -3,7 +3,7 @@
     <parent>
         <artifactId>javalin-parent</artifactId>
         <groupId>io.javalin</groupId>
-        <version>3.13.3-SNAPSHOT</version>
+        <version>3.13.4-SNAPSHOT</version>
     </parent>
     <modelVersion>4.0.0</modelVersion>
 
diff --git a/javalin-without-jetty/pom.xml b/javalin-without-jetty/pom.xml
index df770481..392a4f53 100644
--- a/javalin-without-jetty/pom.xml
+++ b/javalin-without-jetty/pom.xml
@@ -3,7 +3,7 @@
     <parent>
         <artifactId>javalin-parent</artifactId>
         <groupId>io.javalin</groupId>
-        <version>3.13.3-SNAPSHOT</version>
+        <version>3.13.4-SNAPSHOT</version>
     </parent>
     <modelVersion>4.0.0</modelVersion>
 
diff --git a/javalin/pom.xml b/javalin/pom.xml
index 61be27c9..a0582dd1 100644
--- a/javalin/pom.xml
+++ b/javalin/pom.xml
@@ -3,7 +3,7 @@
     <parent>
         <artifactId>javalin-parent</artifactId>
         <groupId>io.javalin</groupId>
-        <version>3.13.3-SNAPSHOT</version>
+        <version>3.13.4-SNAPSHOT</version>
     </parent>
     <modelVersion>4.0.0</modelVersion>
 
diff --git a/javalin/src/main/java/io/javalin/http/Context.kt b/javalin/src/main/java/io/javalin/http/Context.kt
index 726d8471..85dc84d7 100644
--- a/javalin/src/main/java/io/javalin/http/Context.kt
+++ b/javalin/src/main/java/io/javalin/http/Context.kt
@@ -163,6 +163,11 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
         return if (isMultipartFormData()) MultipartUtil.getUploadedFiles(req, fileName) else listOf()
     }
 
+    /** Gets a list of [UploadedFile]s, or empty list. */
+    fun uploadedFiles(): List<UploadedFile> {
+        return if (isMultipartFormData()) MultipartUtil.getUploadedFiles(req) else listOf()
+    }
+
     /**
      * Gets a form param if it exists, else a default value (null if not specified explicitly).
      * Including a default value is mainly useful when calling from Java,
diff --git a/javalin/src/main/java/io/javalin/http/util/MultipartUtil.kt b/javalin/src/main/java/io/javalin/http/util/MultipartUtil.kt
index ff588d9f..60e0e3f3 100644
--- a/javalin/src/main/java/io/javalin/http/util/MultipartUtil.kt
+++ b/javalin/src/main/java/io/javalin/http/util/MultipartUtil.kt
@@ -20,16 +20,12 @@ object MultipartUtil {
 
     fun getUploadedFiles(servletRequest: HttpServletRequest, partName: String): List<UploadedFile> {
         preUploadFunction(servletRequest)
-        return servletRequest.parts.filter { isFile(it) && it.name == partName }.map { filePart ->
-            UploadedFile(
-                    content = filePart.inputStream,
-                    contentType = filePart.contentType,
-                    contentLength = filePart.size.toInt(),
-                    filename = filePart.submittedFileName,
-                    extension = filePart.submittedFileName.replaceBeforeLast(".", ""),
-                    size = filePart.size
-            )
-        }
+        return servletRequest.parts.filter { isFile(it) && it.name == partName }.map(this::toUploadedFile)
+    }
+
+    fun getUploadedFiles(servletRequest: HttpServletRequest): List<UploadedFile> {
+        preUploadFunction(servletRequest)
+        return servletRequest.parts.filter(this::isFile).map(this::toUploadedFile)
     }
 
     fun getFieldMap(req: HttpServletRequest): Map<String, List<String>> {
@@ -43,6 +39,17 @@ object MultipartUtil {
         }.toList()
     }
 
+    private fun toUploadedFile(filePart: Part): UploadedFile {
+        return UploadedFile(
+            content = filePart.inputStream,
+            contentType = filePart.contentType,
+            contentLength = filePart.size.toInt(),
+            filename = filePart.submittedFileName,
+            extension = filePart.submittedFileName.replaceBeforeLast(".", ""),
+            size = filePart.size
+        )
+    }
+
     private fun isField(filePart: Part) = filePart.submittedFileName == null // this is what Apache FileUpload does ...
     private fun isFile(filePart: Part) = !isField(filePart)
 }
diff --git a/javalin/src/main/java/io/javalin/http/util/RateLimitUtil.kt b/javalin/src/main/java/io/javalin/http/util/RateLimitUtil.kt
index 6cb5ae24..51500d2b 100644
--- a/javalin/src/main/java/io/javalin/http/util/RateLimitUtil.kt
+++ b/javalin/src/main/java/io/javalin/http/util/RateLimitUtil.kt
@@ -10,46 +10,42 @@ import io.javalin.http.Context
 import io.javalin.http.HttpResponseException
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.Executors
+import java.util.concurrent.ScheduledExecutorService
 import java.util.concurrent.TimeUnit
 
-private val limiters = ConcurrentHashMap<TimeUnit, RateLimiter>()
-
-class RateLimiter(timeUnit: TimeUnit) {
+object RateLimitUtil {
+    val limiters = ConcurrentHashMap<TimeUnit, RateLimiter>()
+    var keyFunction: (Context) -> String = { ip(it) + it.method() + it.matchedPath() }
+    val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
+    private fun ip(ctx: Context) = ctx.header("X-Forwarded-For")?.split(",")?.get(0) ?: ctx.ip()
+}
 
-    private val handlerToIpToRequestCount = ConcurrentHashMap<String, ConcurrentHashMap<String, Int>>()
+class RateLimiter(val timeUnit: TimeUnit) {
 
-    init {
-        Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate({
-            handlerToIpToRequestCount.forEach { (_, ipRequestCount) -> ipRequestCount.clear() }
-        }, /*delay=*/0,  /*period=*/1, timeUnit)
+    private val timeUnitString = timeUnit.toString().toLowerCase().removeSuffix("s")
+    private val keyToRequestCount = ConcurrentHashMap<String, Int>().also {
+        RateLimitUtil.executor.scheduleAtFixedRate({ it.clear() }, /*delay=*/0,  /*period=*/1, timeUnit)
     }
 
     fun incrementCounter(ctx: Context, requestLimit: Int) {
-        val limiterName = ctx.method() + ctx.matchedPath()
-        handlerToIpToRequestCount.putIfAbsent(limiterName, ConcurrentHashMap())
-        handlerToIpToRequestCount[limiterName]!!.compute(ip(ctx)) { _, count ->
+        keyToRequestCount.compute(RateLimitUtil.keyFunction(ctx)) { _, count ->
             when {
                 count == null -> 1
                 count < requestLimit -> count + 1
-                else -> throw RuntimeException("Too many requests!")
+                else -> throw HttpResponseException(429, "Rate limit exceeded - Server allows $requestLimit requests per $timeUnitString.")
             }
         }
     }
-
 }
 
-private fun ip(ctx: Context) = ctx.header("X-Forwarded-For")?.split(",")?.get(0) ?: ctx.ip()
-
 class RateLimit(private val ctx: Context) {
     /**
-     * Simple IP-and-handler-based rate-limiting, activated by calling it in a [io.javalin.http.Handler]
-     * A map of maps in a [RateLimiter] holds one ip/counter map per method/path (handler).
-     * On each request the counter for that IP is incremented. If the counter exceeds [numRequests]
-     * per [timeUnit], an exception is thrown. All counters are cleared every [timeUnit].
+     * Simple key/count rate-limiting, activated by calling it in a [io.javalin.http.Handler].
+     * All counters are cleared every [timeUnit].
+     * You can change the key by changing [RateLimitUtil.keyFunction], the default is ip + method + path
+     * @throws HttpResponseException if the counter exceeds [numRequests] per [timeUnit]
      */
-    fun requestPerTimeUnit(numRequests: Int, timeUnit: TimeUnit) = try {
-        limiters.computeIfAbsent(timeUnit) { RateLimiter(timeUnit) }.incrementCounter(ctx, numRequests)
-    } catch (e: RuntimeException) {
-        throw HttpResponseException(429, "Rate limit exceeded - Server allows $numRequests requests per ${timeUnit.toString().toLowerCase().removeSuffix("s")}.")
+    fun requestPerTimeUnit(numRequests: Int, timeUnit: TimeUnit) {
+        RateLimitUtil.limiters.computeIfAbsent(timeUnit) { RateLimiter(timeUnit) }.incrementCounter(ctx, numRequests)
     }
 }
diff --git a/javalin/src/main/java/io/javalin/plugin/metrics/MicrometerPlugin.kt b/javalin/src/main/java/io/javalin/plugin/metrics/MicrometerPlugin.kt
index 465e6ef6..5578338b 100644
--- a/javalin/src/main/java/io/javalin/plugin/metrics/MicrometerPlugin.kt
+++ b/javalin/src/main/java/io/javalin/plugin/metrics/MicrometerPlugin.kt
@@ -26,7 +26,9 @@ import javax.servlet.http.HttpServletResponse
 
 class MicrometerPlugin @JvmOverloads constructor(private val registry: MeterRegistry = Metrics.globalRegistry,
                                                  private val tags: Iterable<Tag> = Tags.empty(),
-                                                 private val tagExceptionName: Boolean = false) : Plugin {
+                                                 private val tagExceptionName: Boolean = false,
+                                                 private val tagRedirectPaths: Boolean = false,
+                                                 private val tagNotFoundMappedPaths: Boolean = false) : Plugin {
     override fun apply(app: Javalin) {
         app.server()?.server()?.let { server ->
             if (tagExceptionName) {
@@ -49,8 +51,12 @@ class MicrometerPlugin @JvmOverloads constructor(private val registry: MeterRegi
                             .findAny()
                             .map(HandlerEntry::path)
                             .map { path: String -> if (path == "/" || StringUtils.isBlank(path)) "root" else path }
-                            .map { path: String -> if (response.status in 300..399) "REDIRECTION" else path }
-                            .map { path: String -> if (response.status == 404) "NOT_FOUND" else path }
+                            .map { path: String ->
+                                if (!tagRedirectPaths && response.status in 300..399) "REDIRECTION" else path
+                            }
+                            .map { path: String ->
+                                if (!tagNotFoundMappedPaths && response.status == 404) "NOT_FOUND" else path
+                            }
                             .orElse("NOT_FOUND")
                     return Tags.concat(
                             super.getTags(request, response),
diff --git a/pom.xml b/pom.xml
index fcf9d574..495e101f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -17,7 +17,7 @@
 
     <groupId>io.javalin</groupId>
     <artifactId>javalin-parent</artifactId>
-    <version>3.13.3-SNAPSHOT</version>
+    <version>3.13.4-SNAPSHOT</version>
 
     <name>Javalin (parent)</name>
     <description>Javalin: Simple REST APIs for Java and Kotlin</description>


Test patch:
diff --git a/javalin/src/test/java/io/javalin/TestMicrometerPlugin.kt b/javalin/src/test/java/io/javalin/TestMicrometerPlugin.kt
index 40efb883..928b29e9 100644
--- a/javalin/src/test/java/io/javalin/TestMicrometerPlugin.kt
+++ b/javalin/src/test/java/io/javalin/TestMicrometerPlugin.kt
@@ -6,36 +6,23 @@
 
 package io.javalin
 
+import io.javalin.http.NotFoundResponse
 import io.javalin.plugin.metrics.MicrometerPlugin
 import io.javalin.testing.HttpUtil
 import io.micrometer.core.instrument.MeterRegistry
 import io.micrometer.core.instrument.Tags
 import io.micrometer.core.instrument.simple.SimpleMeterRegistry
-import org.junit.After
-import org.junit.Before
+import org.assertj.core.api.Assertions.assertThat
 import org.junit.Test
 
 class TestMicrometerPlugin {
     private val meterRegistry: MeterRegistry = SimpleMeterRegistry()
-    val app: Javalin = Javalin.create { config -> config.registerPlugin(MicrometerPlugin(meterRegistry, Tags.empty(), true)) }.start(0);
-    val http = HttpUtil(app.port())
-
-    @Before
-    fun before() {
-        // must manually delegate to Micrometer exception handler for excepton tags to be correct
-        app.exception(IllegalArgumentException::class.java) { e, ctx ->
-            MicrometerPlugin.EXCEPTION_HANDLER.handle(e, ctx);
-            e.printStackTrace();
-        };
-    }
-
-    @After
-    fun after() {
-        app.stop()
-    }
 
     @Test
     fun `successful request`() {
+        val app: Javalin = setupApp()
+        val http = HttpUtil(app.port())
+
         app.get("/hello/:name") { ctx -> ctx.result("Hello: " + ctx.pathParam("name")) }
         http.get("/hello/jon")
         meterRegistry.get("jetty.server.requests")
@@ -45,11 +32,16 @@ class TestMicrometerPlugin {
                 .tag("status", "200")
                 .tag("outcome", "SUCCESS")
                 .timer()
+
+        app.stop()
     }
 
     @Test
     fun `request throwing exception`() {
-        app.get("/boom") { ctx -> throw IllegalArgumentException("boom") }
+        val app: Javalin = setupApp()
+        val http = HttpUtil(app.port())
+
+        app.get("/boom") { throw IllegalArgumentException("boom") }
         http.get("/boom")
         meterRegistry.get("jetty.server.requests")
                 .tag("uri", "/boom")
@@ -58,10 +50,15 @@ class TestMicrometerPlugin {
                 .tag("status", "500")
                 .tag("outcome", "SERVER_ERROR")
                 .timer()
+
+        app.stop()
     }
 
     @Test
     fun redirect() {
+        val app: Javalin = setupApp()
+        val http = HttpUtil(app.port())
+
         app.get("/hello") { ctx -> ctx.result("Hello") }
         app.get("/redirect") { ctx -> ctx.redirect("/hello") }
         http.get("/redirect")
@@ -79,11 +76,114 @@ class TestMicrometerPlugin {
                 .tag("status", "200")
                 .tag("outcome", "SUCCESS")
                 .timer()
+
+        app.stop()
+    }
+
+    @Test
+    fun `redirect tagged`() {
+        val app: Javalin = setupApp(tagRedirectPaths = true)
+        val http = HttpUtil(app.port())
+
+        app.get("/hello") { ctx -> ctx.result("Hello") }
+        app.get("/redirect") { ctx -> ctx.redirect("/hello") }
+        http.get("/redirect")
+        meterRegistry.get("jetty.server.requests")
+                .tag("uri", "/redirect")
+                .tag("method", "GET")
+                .tag("exception", "None")
+                .tag("status", "302")
+                .tag("outcome", "REDIRECTION")
+                .timer()
+        meterRegistry.get("jetty.server.requests")
+                .tag("uri", "/hello")
+                .tag("method", "GET")
+                .tag("exception", "None")
+                .tag("status", "200")
+                .tag("outcome", "SUCCESS")
+                .timer()
+
+        app.stop()
+    }
+
+    @Test
+    fun `etags tagged`() {
+        val app: Javalin = setupApp(tagRedirectPaths = true, autoGenerateEtags = true)
+        val http = HttpUtil(app.port())
+
+        app.get("/hello") { ctx -> ctx.result("Hello") }
+
+        val response = http.get("/hello")
+        val etag = response.headers["ETag"]?.first() ?: ""
+
+        val response2 = http.get("/hello", mapOf("If-None-Match" to etag))
+        assertThat(response2.status).isEqualTo(304)
+
+        meterRegistry.get("jetty.server.requests")
+                .tag("uri", "/hello")
+                .tag("method", "GET")
+                .tag("exception", "None")
+                .tag("status", "304")
+                .tag("outcome", "REDIRECTION")
+                .timer()
+        meterRegistry.get("jetty.server.requests")
+                .tag("uri", "/hello")
+                .tag("method", "GET")
+                .tag("exception", "None")
+                .tag("status", "200")
+                .tag("outcome", "SUCCESS")
+                .timer()
+
+        app.stop()
     }
 
     @Test
     fun `not found`() {
+        val app: Javalin = setupApp()
+        val http = HttpUtil(app.port())
+
+        http.get("/doesNotExist")
+        meterRegistry.get("jetty.server.requests")
+                .tag("uri", "NOT_FOUND")
+                .tag("method", "GET")
+                .tag("exception", "None")
+                .tag("status", "404")
+                .tag("outcome", "CLIENT_ERROR")
+                .timer()
+
+        app.stop()
+    }
+
+    @Test
+    fun `not found tagged`() {
+        val app: Javalin = setupApp(tagNotFoundMappedPaths = true)
+        val http = HttpUtil(app.port())
+
+        app.get("/hello/:name") { ctx ->
+            if (ctx.pathParam("name") == "jon") ctx.result("Hello: " + ctx.pathParam("name"))
+            else throw NotFoundResponse()
+        }
+
+        http.get("/hello/jon")
+        http.get("/hello/wil")
         http.get("/doesNotExist")
+
+        meterRegistry.get("jetty.server.requests")
+                .tag("uri", "/hello/:name")
+                .tag("method", "GET")
+                .tag("exception", "None")
+                .tag("status", "200")
+                .tag("outcome", "SUCCESS")
+                .timer()
+
+        meterRegistry.get("jetty.server.requests")
+                .tag("uri", "/hello/:name")
+                .tag("method", "GET")
+                .tag("exception", "None")
+                .tag("status", "404")
+                .tag("outcome", "CLIENT_ERROR")
+                .timer()
+
         meterRegistry.get("jetty.server.requests")
                 .tag("uri", "NOT_FOUND")
                 .tag("method", "GET")
@@ -91,5 +191,25 @@ class TestMicrometerPlugin {
                 .tag("status", "404")
                 .tag("outcome", "CLIENT_ERROR")
                 .timer()
+
+        app.stop()
     }
+
+    private fun setupApp(tagRedirectPaths: Boolean = false,
+                         tagNotFoundMappedPaths: Boolean = false,
+                         autoGenerateEtags: Boolean? = null) =
+            Javalin.create { config ->
+                config.registerPlugin(MicrometerPlugin(registry = meterRegistry,
+                        tags = Tags.empty(),
+                        tagExceptionName = true,
+                        tagRedirectPaths = tagRedirectPaths,
+                        tagNotFoundMappedPaths = tagNotFoundMappedPaths)
+                )
+                if (autoGenerateEtags != null) config.autogenerateEtags = autoGenerateEtags
+
+                // must manually delegate to Micrometer exception handler for exception tags to be correct
+            }.start(0).exception(IllegalArgumentException::class.java) { e, ctx ->
+                MicrometerPlugin.EXCEPTION_HANDLER.handle(e, ctx)
+                e.printStackTrace()
+            }
 }
diff --git a/javalin/src/test/java/io/javalin/TestMultipartForms.kt b/javalin/src/test/java/io/javalin/TestMultipartForms.kt
index 6f6de8da..30787ef9 100644
--- a/javalin/src/test/java/io/javalin/TestMultipartForms.kt
+++ b/javalin/src/test/java/io/javalin/TestMultipartForms.kt
@@ -98,6 +98,32 @@ class TestMultipartForms {
         assertThat(http.post("/test-upload").asString().body).isEqualTo("OK")
     }
 
+    @Test
+    fun `getting all files is handled correctly`() = TestUtil.test { app, http ->
+        app.post("/test-upload") { ctx ->
+            ctx.result(ctx.uploadedFiles().joinToString(", ") { it.filename })
+        }
+        val response = http.post("/test-upload")
+            .field("upload", File("src/test/resources/upload-test/image.png"))
+            .field("upload", File("src/test/resources/upload-test/sound.mp3"))
+            .field("upload", File("src/test/resources/upload-test/text.txt"))
+            .field("text-field", "text")
+            .asString()
+        assertThat(response.body).isEqualTo("image.png, sound.mp3, text.txt")
+    }
+
+    @Test
+    fun `getting all files doesn't throw for non multipart request`() = TestUtil.test { app, http ->
+        app.post("/test-upload") { ctx -> ctx.result(ctx.uploadedFiles().joinToString("\n")) }
+
+        val response = http.post("/test-upload")
+            .header("content-type", "plain/text")
+            .body("")
+            .asString()
+
+        assertThat(response.body).isEqualTo("")
+    }
+
     @Test
     fun `mixing files and text fields works`() = TestUtil.test { app, http ->
         app.post("/test-upload") { ctx -> ctx.result(ctx.formParam("field") + " and " + ctx.uploadedFile("upload")!!.filename) }
diff --git a/javalin/src/test/java/io/javalin/TestRateLimitUtil.kt b/javalin/src/test/java/io/javalin/TestRateLimitUtil.kt
index 61cec9ad..14dc6f7e 100644
--- a/javalin/src/test/java/io/javalin/TestRateLimitUtil.kt
+++ b/javalin/src/test/java/io/javalin/TestRateLimitUtil.kt
@@ -24,14 +24,14 @@ class TestRateLimitUtil {
 
     @Test
     fun `rate limiting kicks in if number of requests exceeds rate limit`() = TestUtil.test(testApp) { app, http ->
-        repeat(10) { http.get("/") }
+        repeat(20) { http.get("/") }
         assertThat(http.get("/").status).isEqualTo(429)
         assertThat(http.get("/").body).isEqualTo("Rate limit exceeded - Server allows 5 requests per hour.")
     }
 
     @Test
     fun `both path and HTTP method must match for rate limiting to kick in`() = TestUtil.test(testApp) { app, http ->
-        repeat(10) { http.get("/") }
+        repeat(20) { http.get("/") }
         assertThat(http.get("/").status).isEqualTo(429)
         assertThat(http.get("/test").status).isNotEqualTo(429)
         assertThat(http.post("/").asString().status).isNotEqualTo(429)
@@ -39,9 +39,9 @@ class TestRateLimitUtil {
 
     @Test
     fun `rate limit on dynamic path-params limits per endpoint, not per URL`() = TestUtil.test(testApp) { app, http ->
-        repeat(5) { http.get("/dynamic/1") }
-        repeat(5) { http.get("/dynamic/2") }
-        repeat(5) { http.get("/dynamic/3") }
+        repeat(10) { http.get("/dynamic/1") }
+        repeat(10) { http.get("/dynamic/2") }
+        repeat(10) { http.get("/dynamic/3") }
         assertThat(http.get("/dynamic/4").status).isEqualTo(429)
         assertThat(http.get("/dynamic/5").body).isEqualTo("Rate limit exceeded - Server allows 5 requests per minute.")
     }
diff --git a/javalin/src/test/java/io/javalin/testing/HttpUtil.kt b/javalin/src/test/java/io/javalin/testing/HttpUtil.kt
index f18dc36c..90820996 100644
--- a/javalin/src/test/java/io/javalin/testing/HttpUtil.kt
+++ b/javalin/src/test/java/io/javalin/testing/HttpUtil.kt
@@ -21,6 +21,7 @@ class HttpUtil(port: Int) {
 
     // Unirest
     fun get(path: String) = Unirest.get(origin + path).asString()
+    fun get(path: String, headers: Map<String, String>) = Unirest.get(origin + path).headers(headers).asString()
 
     fun getBody(path: String) = Unirest.get(origin + path).asString().body
     fun getBody(path: String, headers: Map<String, String>) = Unirest.get(origin + path).headers(headers).asString().body