diff --git a/pom.xml b/pom.xml index bb6d9b8a..f30e7d03 100644 --- a/pom.xml +++ b/pom.xml @@ -63,6 +63,11 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + diff --git a/src/main/java/hse/java/lectures/lesson7/limiter/RateLimiter.java b/src/main/java/hse/java/lectures/lesson7/limiter/RateLimiter.java index 08df1b13..1362015f 100644 --- a/src/main/java/hse/java/lectures/lesson7/limiter/RateLimiter.java +++ b/src/main/java/hse/java/lectures/lesson7/limiter/RateLimiter.java @@ -1,26 +1,51 @@ package hse.java.lectures.lesson7.limiter; import java.time.temporal.ChronoUnit; +import java.util.ArrayDeque; +import java.util.Deque; /** * Скользящий рейтлимитер: не больше заданного числа успешных {@link #check()} за последнюю секунду или минуту. */ public class RateLimiter { + private final long windowNanos; + private final int maxRequests; + private final Deque successTimestamps = new ArrayDeque<>(); + /** * @param unit длина окна — только {@link ChronoUnit#SECONDS} или {@link ChronoUnit#MINUTES} * (скользящее окно 1 секунда или 1 минута) * @param maxRequests максимум успешных {@link #check()} за окно (должно быть > 0) */ public RateLimiter(ChronoUnit unit, int maxRequests) { - throw new UnsupportedOperationException("Not implemented"); + if (unit != ChronoUnit.SECONDS && unit != ChronoUnit.MINUTES) { + throw new IllegalArgumentException("Only SECONDS or MINUTES are supported"); + } + if (maxRequests <= 0) { + throw new IllegalArgumentException("maxRequests must be > 0"); + } + this.windowNanos = unit.getDuration().toNanos(); + this.maxRequests = maxRequests; } /** * Регистрирует попытку и возвращает, разрешена ли она в пределах лимита. */ - public boolean check() { - throw new UnsupportedOperationException("Not implemented"); + public synchronized boolean check() { + long now = System.nanoTime(); + long windowStart = now - windowNanos; + + while (!successTimestamps.isEmpty() && successTimestamps.peekFirst() <= windowStart) { + successTimestamps.pollFirst(); + } + + if (successTimestamps.size() < maxRequests) { + successTimestamps.addLast(now); + return true; + } + + return false; } } diff --git a/src/test/java/hse/java/lectures/lesson7/limiter/RateLimiterTest.java b/src/test/java/hse/java/lectures/lesson7/limiter/RateLimiterTest.java new file mode 100644 index 00000000..18e2c791 --- /dev/null +++ b/src/test/java/hse/java/lectures/lesson7/limiter/RateLimiterTest.java @@ -0,0 +1,91 @@ +package hse.java.lectures.lesson7.limiter; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.time.temporal.ChronoUnit; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("limiter") +class RateLimiterTest { + + @Test + void illegalUnit_throwsException() { + assertThrows(IllegalArgumentException.class, + () -> new RateLimiter(ChronoUnit.HOURS, 5)); + } + + @Test + void zeroMaxRequests_throwsException() { + assertThrows(IllegalArgumentException.class, + () -> new RateLimiter(ChronoUnit.SECONDS, 0)); + } + + @Test + void negativeMaxRequests_throwsException() { + assertThrows(IllegalArgumentException.class, + () -> new RateLimiter(ChronoUnit.SECONDS, -1)); + } + + @Test + void withinLimit_allCallsSucceed() { + RateLimiter limiter = new RateLimiter(ChronoUnit.SECONDS, 3); + + assertTrue(limiter.check()); + assertTrue(limiter.check()); + assertTrue(limiter.check()); + } + + @Test + void overLimit_extraCallFails() { + RateLimiter limiter = new RateLimiter(ChronoUnit.SECONDS, 3); + + limiter.check(); + limiter.check(); + limiter.check(); + + assertFalse(limiter.check()); + } + + @Test + void failedCall_doesNotOccupySlot() { + RateLimiter limiter = new RateLimiter(ChronoUnit.SECONDS, 2); + + limiter.check(); + limiter.check(); + limiter.check(); + + assertFalse(limiter.check()); + } + + @Test + void slidingWindow_oldCallsExpire() throws InterruptedException { + RateLimiter limiter = new RateLimiter(ChronoUnit.SECONDS, 2); + + assertTrue(limiter.check()); + assertTrue(limiter.check()); + assertFalse(limiter.check()); + + Thread.sleep(1100); + + assertTrue(limiter.check()); + assertTrue(limiter.check()); + assertFalse(limiter.check()); + } + + @Test + void slidingWindow_partialExpiry() throws InterruptedException { + RateLimiter limiter = new RateLimiter(ChronoUnit.SECONDS, 2); + + assertTrue(limiter.check()); + Thread.sleep(600); + assertTrue(limiter.check()); + assertFalse(limiter.check()); + + Thread.sleep(500); + + assertTrue(limiter.check()); + assertFalse(limiter.check()); + } +}