feat(search): add NlSearchRateLimiter with Bucket4j/Caffeine
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String, Bucket> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user