diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRateLimiter.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRateLimiter.java new file mode 100644 index 00000000..100296fa --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRateLimiter.java @@ -0,0 +1,46 @@ +package org.raddatz.familienarchiv.search; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +@Service +public class NlSearchRateLimiter { + + private final LoadingCache byUser; + private final int maxRequestsPerMinute; + + public NlSearchRateLimiter(NlSearchRateLimitProperties props) { + this.maxRequestsPerMinute = props.getMaxRequestsPerMinute(); + this.byUser = Caffeine.newBuilder() + .expireAfterAccess(1, TimeUnit.MINUTES) + .build(key -> newBucket(maxRequestsPerMinute)); + } + + public void checkAndConsume(String userKey) { + if (!byUser.get(userKey).tryConsume(1)) { + throw DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_RATE_LIMITED, + "NL search rate limit exceeded for user: " + userKey, 60L); + } + } + + void resetForTest() { + byUser.invalidateAll(); + } + + private static Bucket newBucket(int limit) { + return Bucket.builder() + .addLimit(Bandwidth.builder() + .capacity(limit) + .refillGreedy(limit, Duration.ofMinutes(1)) + .build()) + .build(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchRateLimiterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchRateLimiterTest.java new file mode 100644 index 00000000..43a2bbe0 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchRateLimiterTest.java @@ -0,0 +1,62 @@ +package org.raddatz.familienarchiv.search; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class NlSearchRateLimiterTest { + + private NlSearchRateLimiter rateLimiter; + + @BeforeEach + void setUp() { + NlSearchRateLimitProperties props = new NlSearchRateLimitProperties(); + props.setMaxRequestsPerMinute(5); + rateLimiter = new NlSearchRateLimiter(props); + } + + @Test + void checkAndConsume_allowsRequestsWithinLimit() { + for (int i = 0; i < 5; i++) { + assertThatCode(() -> rateLimiter.checkAndConsume("user@example.com")) + .doesNotThrowAnyException(); + } + } + + @Test + void checkAndConsume_throwsRateLimited_onSixthRequest() { + for (int i = 0; i < 5; i++) { + rateLimiter.checkAndConsume("user@example.com"); + } + + assertThatThrownBy(() -> rateLimiter.checkAndConsume("user@example.com")) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.SMART_SEARCH_RATE_LIMITED); + } + + @Test + void checkAndConsume_limitsAreIndependentPerUser() { + for (int i = 0; i < 5; i++) { + rateLimiter.checkAndConsume("alice@example.com"); + } + assertThatCode(() -> rateLimiter.checkAndConsume("bob@example.com")) + .doesNotThrowAnyException(); + } + + @Test + void resetForTest_clearsAllBuckets() { + for (int i = 0; i < 5; i++) { + rateLimiter.checkAndConsume("user@example.com"); + } + + rateLimiter.resetForTest(); + + assertThatCode(() -> rateLimiter.checkAndConsume("user@example.com")) + .doesNotThrowAnyException(); + } +}