Author SHA1 Message Date
Lincpay Tech 53641d97ea added rate limiter 2025-12-12 18:52:53 +05:30
12 changed files with 435 additions and 5 deletions
+13
View File
@@ -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
+1 -1
View File
@@ -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());
}
}