Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b7rnC7jppGGdHGESvy
107 lines
4.3 KiB
Java
107 lines
4.3 KiB
Java
package com.ankurm.ratelimit;
|
|
|
|
import java.time.Duration;
|
|
|
|
import org.junit.jupiter.api.AfterAll;
|
|
import org.junit.jupiter.api.BeforeAll;
|
|
import org.junit.jupiter.api.MethodOrderer;
|
|
import org.junit.jupiter.api.Order;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.TestMethodOrder;
|
|
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
import org.springframework.test.web.servlet.MockMvc;
|
|
import org.springframework.test.web.servlet.MvcResult;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
|
|
/** Per-user limit: 10 requests per minute, greedy refill (one token every 6 s). Writes output/02-per-user.txt. */
|
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
|
class PerUserLimitTest extends RedisBackedTest {
|
|
|
|
static Transcript t;
|
|
|
|
@Autowired
|
|
MockMvc mvc;
|
|
|
|
@BeforeAll
|
|
static void open() {
|
|
t = Transcript.open("02-per-user.txt");
|
|
}
|
|
|
|
@AfterAll
|
|
static void close() {
|
|
t.close();
|
|
}
|
|
|
|
private MvcResult call(String user) throws Exception {
|
|
MvcResult r = mvc.perform(get("/api/hello").header("X-User-Id", user)).andReturn();
|
|
var res = r.getResponse();
|
|
t.line("GET /api/hello X-User-Id: %-5s -> %d limit=%s remaining=%s reset=%s retry-after=%s scope=%s",
|
|
user, res.getStatus(), res.getHeader("X-RateLimit-Limit"), res.getHeader("X-RateLimit-Remaining"),
|
|
res.getHeader("X-RateLimit-Reset"), res.getHeader("Retry-After"), res.getHeader("X-RateLimit-Scope"));
|
|
return r;
|
|
}
|
|
|
|
@Test
|
|
@Order(1)
|
|
void eleventhRequestInAMinuteGets429() throws Exception {
|
|
t.line("== alice sends 12 requests with no pause");
|
|
for (int i = 1; i <= 10; i++) {
|
|
var res = call("alice").getResponse();
|
|
assertThat(res.getStatus()).isEqualTo(200);
|
|
assertThat(res.getHeader("X-RateLimit-Remaining")).isEqualTo(String.valueOf(10 - i));
|
|
}
|
|
MvcResult rejected = call("alice");
|
|
assertThat(rejected.getResponse().getStatus()).isEqualTo(429);
|
|
assertThat(rejected.getResponse().getHeader("Retry-After")).isEqualTo("6");
|
|
assertThat(rejected.getResponse().getContentType()).isEqualTo("application/problem+json");
|
|
call("alice");
|
|
t.line("-- body of the 429:");
|
|
t.line(rejected.getResponse().getContentAsString());
|
|
}
|
|
|
|
@Test
|
|
@Order(2)
|
|
void aDifferentUserIsUnaffected() throws Exception {
|
|
t.line("== alice is out of tokens; bob is a different bucket");
|
|
for (int i = 0; i < 10; i++) {
|
|
mvc.perform(get("/api/hello").header("X-User-Id", "alice"));
|
|
}
|
|
assertThat(call("alice").getResponse().getStatus()).isEqualTo(429);
|
|
MvcResult bob = call("bob");
|
|
assertThat(bob.getResponse().getStatus()).isEqualTo(200);
|
|
assertThat(bob.getResponse().getHeader("X-RateLimit-Remaining")).isEqualTo("9");
|
|
}
|
|
|
|
@Test
|
|
@Order(3)
|
|
void honouringRetryAfterWorks() throws Exception {
|
|
t.line("== alice waits exactly Retry-After (6 s on the test clock) and tries again");
|
|
for (int i = 0; i < 10; i++) {
|
|
mvc.perform(get("/api/hello").header("X-User-Id", "alice"));
|
|
}
|
|
MvcResult rejected = call("alice");
|
|
long wait = Long.parseLong(rejected.getResponse().getHeader("Retry-After"));
|
|
clock.advance(Duration.ofSeconds(wait - 1));
|
|
t.line("-- after %d s: still too early", wait - 1);
|
|
assertThat(call("alice").getResponse().getStatus()).isEqualTo(429);
|
|
clock.advance(Duration.ofSeconds(1));
|
|
t.line("-- after %d s in total:", wait);
|
|
assertThat(call("alice").getResponse().getStatus()).isEqualTo(200);
|
|
assertThat(call("alice").getResponse().getStatus()).isEqualTo(429);
|
|
}
|
|
|
|
@Test
|
|
@Order(4)
|
|
void healthIsNotLimited() throws Exception {
|
|
t.line("== /health is outside /api/, so the filter skips it");
|
|
for (int i = 0; i < 30; i++) {
|
|
assertThat(mvc.perform(get("/health")).andReturn().getResponse().getStatus()).isEqualTo(200);
|
|
}
|
|
t.line("30 requests to /health -> all 200, X-RateLimit-Limit header present: %s",
|
|
mvc.perform(get("/health")).andReturn().getResponse().containsHeader("X-RateLimit-Limit"));
|
|
}
|
|
}
|