added rate limiter
This commit is contained in:
@@ -180,6 +180,19 @@
|
|||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Redis (Spring Data Redis + Lettuce) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Bucket4j core for local token-bucket smoothing -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.vladimir-bukhtoyarov</groupId>
|
||||||
|
<artifactId>bucket4j-core</artifactId>
|
||||||
|
<version>7.6.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.billpaymentservice.bbps.config;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||||
|
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
|
||||||
|
import com.billpaymentservice.bbps.filter.RateLimitingFilter;
|
||||||
|
import com.billpaymentservice.bbps.ratelimit.LocalBucketManager;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class RateLimiterConfig {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(RateLimiterConfig.class);
|
||||||
|
|
||||||
|
@Value("${spring.redis.host:localhost}")
|
||||||
|
private String redisHost;
|
||||||
|
|
||||||
|
@Value("${spring.redis.port:6379}")
|
||||||
|
private int redisPort;
|
||||||
|
|
||||||
|
// allow comma-separated patterns, default to /api/* and /masterData/* for backward compatibility
|
||||||
|
@Value("${rate.limiter.url-pattern:/api/*,/masterData/*}")
|
||||||
|
private String urlPattern;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public LettuceConnectionFactory redisConnectionFactory() {
|
||||||
|
RedisStandaloneConfiguration cfg = new RedisStandaloneConfiguration(redisHost, redisPort);
|
||||||
|
LettuceConnectionFactory factory = new LettuceConnectionFactory(cfg);
|
||||||
|
// optional tuning
|
||||||
|
factory.setValidateConnection(true);
|
||||||
|
return factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public StringRedisTemplate stringRedisTemplate(LettuceConnectionFactory connectionFactory) {
|
||||||
|
StringRedisTemplate tpl = new StringRedisTemplate(connectionFactory);
|
||||||
|
tpl.setEnableTransactionSupport(false);
|
||||||
|
return tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public FilterRegistrationBean<RateLimitingFilter> rateLimiterFilterRegistration(StringRedisTemplate stringRedisTemplate, LocalBucketManager localBucketManager) {
|
||||||
|
RateLimitingFilter filter = new RateLimitingFilter(stringRedisTemplate, localBucketManager);
|
||||||
|
FilterRegistrationBean<RateLimitingFilter> reg = new FilterRegistrationBean<>();
|
||||||
|
reg.setFilter(filter);
|
||||||
|
|
||||||
|
// support comma-separated patterns in property (e.g. "/api/*,/masterData/*")
|
||||||
|
String[] patterns = urlPattern.split("\\s*,\\s*");
|
||||||
|
reg.addUrlPatterns(patterns);
|
||||||
|
|
||||||
|
LOGGER.info("Registering RateLimitingFilter for patterns={}", Arrays.toString(patterns));
|
||||||
|
|
||||||
|
reg.setName("rateLimitingFilter");
|
||||||
|
// set the filter to highest precedence so it runs before other servlet filters (e.g., security chain)
|
||||||
|
reg.setOrder(Ordered.HIGHEST_PRECEDENCE);
|
||||||
|
return reg;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package com.billpaymentservice.bbps.filter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||||
|
import org.springframework.data.redis.RedisConnectionFailureException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.billpaymentservice.bbps.ratelimit.LocalBucketManager;
|
||||||
|
import com.billpaymentservice.bbps.util.Helper;
|
||||||
|
|
||||||
|
import io.github.bucket4j.Bucket;
|
||||||
|
|
||||||
|
|
||||||
|
public class RateLimitingFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(RateLimitingFilter.class);
|
||||||
|
|
||||||
|
private static final long RATE_PER_WINDOW = 1L;
|
||||||
|
private static final long BURST = 0L;
|
||||||
|
private static final long WINDOW_MILLIS = 60_000L;
|
||||||
|
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
private final LocalBucketManager localBucketManager;
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
private final DefaultRedisScript<Long> incrWithExpireScript;
|
||||||
|
|
||||||
|
public RateLimitingFilter(StringRedisTemplate redisTemplate, LocalBucketManager localBucketManager) {
|
||||||
|
this.redisTemplate = redisTemplate;
|
||||||
|
this.localBucketManager = localBucketManager;
|
||||||
|
String script =
|
||||||
|
"local v = redis.call('INCR', KEYS[1]);" +
|
||||||
|
"if v == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end;" +
|
||||||
|
"return v";
|
||||||
|
|
||||||
|
this.incrWithExpireScript = new DefaultRedisScript<>(script, Long.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
|
||||||
|
String mid = validateAndExtractMid(request, response);
|
||||||
|
if (mid == null) return;
|
||||||
|
|
||||||
|
LOGGER.info("RateLimiter activated → path={} mid={}", request.getRequestURI(), mid);
|
||||||
|
response.setHeader("X-RateLimit-Filter", "invoked");
|
||||||
|
|
||||||
|
long limit = RATE_PER_WINDOW + BURST;
|
||||||
|
String redisKey = buildRedisKey(mid);
|
||||||
|
|
||||||
|
// 1️⃣ Local Bucket Check
|
||||||
|
if (!consumeLocalToken(mid, response)) {
|
||||||
|
long resetAt = calculateResetTime();
|
||||||
|
setRateHeaders(response, limit, 0, resetAt);
|
||||||
|
writeErrorResponse(request, response, 429, "Too Many Requests", "Rate limit exceeded (local)", mid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ Redis Counter
|
||||||
|
Long count = incrementRedisCounter(redisKey);
|
||||||
|
long resetAt = calculateResetTime();
|
||||||
|
long remaining = Math.max(0, limit - count);
|
||||||
|
|
||||||
|
setRateHeaders(response, limit, remaining, resetAt);
|
||||||
|
response.setHeader("X-RateLimit-Count", String.valueOf(count));
|
||||||
|
|
||||||
|
if (count > limit) {
|
||||||
|
handleGlobalLimitExceeded(mid, response, request, resetAt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
|
||||||
|
} // -------------------------HELPERS-------------------------
|
||||||
|
|
||||||
|
|
||||||
|
private String validateAndExtractMid(HttpServletRequest request, HttpServletResponse response) throws IOException
|
||||||
|
{ String mid = request.getHeader("mid");
|
||||||
|
if (mid == null || mid.trim().isEmpty())
|
||||||
|
{ writeErrorResponse(request, response, 400, "Bad Request", "Required header 'mid' is missing or empty", null);
|
||||||
|
return null; }
|
||||||
|
return mid.trim(); }
|
||||||
|
|
||||||
|
private boolean consumeLocalToken(String mid, HttpServletResponse response) {
|
||||||
|
Bucket bucket = localBucketManager.getBucket("local:mid:" + mid);
|
||||||
|
boolean consumed = bucket.tryConsume(1);
|
||||||
|
response.setHeader("X-RateLimit-Local-Remaining", String.valueOf(bucket.getAvailableTokens()));
|
||||||
|
return consumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long incrementRedisCounter(String key) {
|
||||||
|
long ttlSeconds = (WINDOW_MILLIS / 1000L) + 2;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Long result = redisTemplate.execute(
|
||||||
|
incrWithExpireScript,
|
||||||
|
Collections.singletonList(key),
|
||||||
|
String.valueOf(ttlSeconds)
|
||||||
|
);
|
||||||
|
return (result == null ? 1 : result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.warn("Redis unavailable → fallback mode");
|
||||||
|
return 1L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long calculateResetTime() {
|
||||||
|
long nowSec = System.currentTimeMillis() / 1000L;
|
||||||
|
long win = WINDOW_MILLIS / 1000L;
|
||||||
|
return ((nowSec / win) * win) + win;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildRedisKey(String mid) {
|
||||||
|
long index = System.currentTimeMillis() / WINDOW_MILLIS;
|
||||||
|
return "ratelimit:mid:" + mid + ":ts:" + index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setRateHeaders(HttpServletResponse response, long limit, long remaining, long resetAt) {
|
||||||
|
response.setHeader("X-RateLimit-Limit", String.valueOf(limit));
|
||||||
|
response.setHeader("X-RateLimit-Remaining", String.valueOf(remaining));
|
||||||
|
response.setHeader("X-RateLimit-Reset", String.valueOf(resetAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleGlobalLimitExceeded(String mid,
|
||||||
|
HttpServletResponse res,
|
||||||
|
HttpServletRequest req,
|
||||||
|
long resetAt) throws IOException {
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis() / 1000L;
|
||||||
|
res.setHeader("Retry-After", String.valueOf(Math.max(1, resetAt - now)));
|
||||||
|
|
||||||
|
writeErrorResponse(req, res, 429,
|
||||||
|
"Too Many Requests", "Rate limit exceeded (global)", mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeErrorResponse(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
int status, String error,
|
||||||
|
String message, String mid) throws IOException {
|
||||||
|
|
||||||
|
if (response.isCommitted()) return;
|
||||||
|
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("timestamp", Instant.now().toString());
|
||||||
|
body.put("status", status);
|
||||||
|
body.put("error", error);
|
||||||
|
body.put("message", message);
|
||||||
|
body.put("path", request.getRequestURI());
|
||||||
|
|
||||||
|
if (mid != null) body.put("mid", mid);
|
||||||
|
|
||||||
|
response.setStatus(status);
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
mapper.writeValue(response.getWriter(), body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.billpaymentservice.bbps.ratelimit;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ConcurrentMap;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import io.github.bucket4j.Bandwidth;
|
||||||
|
import io.github.bucket4j.Bucket;
|
||||||
|
import io.github.bucket4j.Bucket4j;
|
||||||
|
import io.github.bucket4j.Refill;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class LocalBucketManager {
|
||||||
|
|
||||||
|
// match the filter: 1 request per minute, no burst
|
||||||
|
private static final long RATE_PER_WINDOW = 1L;
|
||||||
|
private static final long BURST = 0L;
|
||||||
|
private static final Duration WINDOW_DURATION = Duration.ofMinutes(1);
|
||||||
|
|
||||||
|
private final ConcurrentMap<String, Bucket> cache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public Bucket getBucket(String key) {
|
||||||
|
return cache.computeIfAbsent(key, k -> createBucket());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Bucket createBucket() {
|
||||||
|
Bandwidth limit = Bandwidth.classic(RATE_PER_WINDOW + BURST, Refill.greedy(RATE_PER_WINDOW, WINDOW_DURATION));
|
||||||
|
return Bucket4j.builder()
|
||||||
|
.addLimit(limit)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,8 +76,18 @@ public class AuthServiceImpl implements AuthService {
|
|||||||
try {
|
try {
|
||||||
Optional<UserEntity> byMobileNumber = userRepository.findByMobile(mobileNo);
|
Optional<UserEntity> byMobileNumber = userRepository.findByMobile(mobileNo);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (byMobileNumber.isPresent()) {
|
if (byMobileNumber.isPresent()) {
|
||||||
UserEntity user = byMobileNumber.get();
|
UserEntity user = byMobileNumber.get();
|
||||||
|
LOGGER.info("User found for mobileNo: {}", user);
|
||||||
|
|
||||||
|
if(user.getIsDeleted() != true && user.isEnabled()== false) {
|
||||||
|
LOGGER.warn("User with mobileNo: {} is deleted", mobileNo);
|
||||||
|
return ResponseModel.builder().message("user account is disabled.please contact your service provider").status("Failure")
|
||||||
|
.statusCode(BBPSConstant.USER_IS_DELTED_IN_DB).data(null).build();
|
||||||
|
}
|
||||||
|
|
||||||
String token = jwtUtil.generateToken(user.getMobile());
|
String token = jwtUtil.generateToken(user.getMobile());
|
||||||
|
|
||||||
LOGGER.info("Generated JWT token for mobileNo: " + mobileNo);
|
LOGGER.info("Generated JWT token for mobileNo: " + mobileNo);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class BBPSConstant extends BBPS_RESPONSE_COD_CONSTANT {
|
|||||||
public static final Long PENDING_CODE = 211L;
|
public static final Long PENDING_CODE = 211L;
|
||||||
public static final Long FRAUD_CODE = 1001L;
|
public static final Long FRAUD_CODE = 1001L;
|
||||||
public static final int BADREQUEST = 400;
|
public static final int BADREQUEST = 400;
|
||||||
|
public static final int TO_MANY_REQUEST=429;
|
||||||
|
|
||||||
public static final String SUCCESS_STATUS = "success";
|
public static final String SUCCESS_STATUS = "success";
|
||||||
public static final String FAILED_STATUS = "failed";
|
public static final String FAILED_STATUS = "failed";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.billpaymentservice.bbps.util;
|
package com.billpaymentservice.bbps.util;
|
||||||
|
|
||||||
public class BBPS_RESPONSE_COD_CONSTANT {
|
public class BBPS_RESPONSE_COD_CONSTANT {
|
||||||
|
public static final int USER_IS_DELTED_IN_DB =602;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.billpaymentservice.bbps.util;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
public class ConfigHelper {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ import com.billpaymentservice.bbps.repository.UserRepository;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class Helper {
|
public class Helper extends ConfigHelper {
|
||||||
|
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(Helper.class);
|
private static final Logger LOGGER = LoggerFactory.getLogger(Helper.class);
|
||||||
private final InboundTraceLogRepository inboundTraceLogRepository;
|
private final InboundTraceLogRepository inboundTraceLogRepository;
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ swagger.server.description=Local Development Server
|
|||||||
|
|
||||||
|
|
||||||
# DataSource
|
# DataSource
|
||||||
spring.datasource.url=jdbc:postgresql://localhost:5432/bbps_new_updated
|
#spring.datasource.url=jdbc:postgresql://localhost:5432/bbps_new_updated
|
||||||
|
|
||||||
#spring.datasource.url=jdbc:postgresql://localhost:5432/bbps_enhanced
|
spring.datasource.url=jdbc:postgresql://localhost:5432/bbps_enhanced
|
||||||
>>>>>>> Stashed changes
|
>>>>>>> Stashed changes
|
||||||
spring.datasource.username=postgres
|
spring.datasource.username=postgres
|
||||||
#spring.datasource.password=UAT@DB_2025
|
#spring.datasource.password=UAT@DB_2025
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ spring.profiles.active=local
|
|||||||
|
|
||||||
|
|
||||||
# DataSource
|
# DataSource
|
||||||
spring.datasource.url=jdbc:postgresql://localhost:5432/devdbuat
|
spring.datasource.url=jdbc:postgresql://localhost:5432/bbps_new_updated
|
||||||
spring.datasource.username=postgres
|
spring.datasource.username=postgres
|
||||||
#spring.datasource.password=UAT@DB_2025
|
#spring.datasource.password=UAT@DB_2025
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.billpaymentservice.bbps.filter;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
|
import org.springframework.mock.web.MockHttpServletResponse;
|
||||||
|
|
||||||
|
import com.billpaymentservice.bbps.ratelimit.LocalBucketManager;
|
||||||
|
|
||||||
|
import io.github.bucket4j.Bucket;
|
||||||
|
|
||||||
|
class RateLimitingFilterTest {
|
||||||
|
|
||||||
|
private StringRedisTemplate redisTemplate;
|
||||||
|
private LocalBucketManager localBucketManager;
|
||||||
|
private RateLimitingFilter filter;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setup() {
|
||||||
|
redisTemplate = mock(StringRedisTemplate.class);
|
||||||
|
localBucketManager = mock(LocalBucketManager.class);
|
||||||
|
filter = new RateLimitingFilter(redisTemplate, localBucketManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void whenLocalBucketExhausted_thenReturns429() throws ServletException, IOException {
|
||||||
|
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||||
|
req.addHeader("mid", "MID_LOCAL_EXHAUST");
|
||||||
|
MockHttpServletResponse resp = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
Bucket mockBucket = mock(Bucket.class);
|
||||||
|
when(localBucketManager.getBucket("local:mid:MID_LOCAL_EXHAUST")).thenReturn(mockBucket);
|
||||||
|
when(mockBucket.tryConsume(1)).thenReturn(false);
|
||||||
|
|
||||||
|
FilterChain chain = mock(FilterChain.class);
|
||||||
|
|
||||||
|
filter.doFilter(req, resp, chain);
|
||||||
|
|
||||||
|
assertEquals(429, resp.getStatus());
|
||||||
|
String content = resp.getContentAsString();
|
||||||
|
assertTrue(content.contains("Rate limit exceeded (local)"));
|
||||||
|
verify(chain, never()).doFilter(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void whenLocalAllowsAndRedisUnderLimit_thenPassesThrough() throws Exception {
|
||||||
|
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||||
|
req.addHeader("mid", "MID_OK");
|
||||||
|
MockHttpServletResponse resp = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
Bucket mockBucket = mock(Bucket.class);
|
||||||
|
when(localBucketManager.getBucket("local:mid:MID_OK")).thenReturn(mockBucket);
|
||||||
|
when(mockBucket.tryConsume(1)).thenReturn(true);
|
||||||
|
when(mockBucket.getAvailableTokens()).thenReturn(1000L);
|
||||||
|
|
||||||
|
// stub redis execute to return 1 (below limit)
|
||||||
|
when(redisTemplate.execute(any(DefaultRedisScript.class), anyList(), eq("2"))).thenReturn(1L);
|
||||||
|
|
||||||
|
// use a FilterChain that sets a flag by setting response status to 200
|
||||||
|
FilterChain chain = (request, response) -> {
|
||||||
|
((MockHttpServletResponse) response).setStatus(200);
|
||||||
|
};
|
||||||
|
|
||||||
|
filter.doFilter(req, resp, chain);
|
||||||
|
|
||||||
|
assertEquals(200, resp.getStatus());
|
||||||
|
// headers should be present
|
||||||
|
assertEquals("1200", resp.getHeader("X-RateLimit-Limit"));
|
||||||
|
assertNotNull(resp.getHeader("X-RateLimit-Remaining"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void whenLocalAllowsButRedisOverLimit_thenReturns429AndReturnsToken() throws Exception {
|
||||||
|
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||||
|
req.addHeader("mid", "MID_GLOBAL_EXHAUST");
|
||||||
|
MockHttpServletResponse resp = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
Bucket mockBucket = mock(Bucket.class);
|
||||||
|
when(localBucketManager.getBucket("local:mid:MID_GLOBAL_EXHAUST")).thenReturn(mockBucket);
|
||||||
|
when(mockBucket.tryConsume(1)).thenReturn(true);
|
||||||
|
when(mockBucket.getAvailableTokens()).thenReturn(0L);
|
||||||
|
|
||||||
|
// stub redis execute to indicate global over-limit
|
||||||
|
when(redisTemplate.execute(any(DefaultRedisScript.class), anyList(), eq("2"))).thenReturn(1201L);
|
||||||
|
|
||||||
|
FilterChain chain = mock(FilterChain.class);
|
||||||
|
|
||||||
|
filter.doFilter(req, resp, chain);
|
||||||
|
|
||||||
|
assertEquals(429, resp.getStatus());
|
||||||
|
String content = resp.getContentAsString();
|
||||||
|
assertTrue(content.contains("Rate limit exceeded (global)"));
|
||||||
|
|
||||||
|
// verify token returned to local bucket
|
||||||
|
verify(mockBucket, times(1)).addTokens(1);
|
||||||
|
verify(chain, never()).doFilter(any(), any());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user