diff --git a/RateLimitingFilter.java b/RateLimitingFilter.java deleted file mode 100644 index 0d35a5a..0000000 --- a/RateLimitingFilter.java +++ /dev/null @@ -1,176 +0,0 @@ -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 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 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); - } -} - - - diff --git a/pom.xml b/pom.xml index a50ad5a..6f11b75 100644 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,12 @@ + + + io.github.resilience4j + resilience4j-spring-boot3 + 2.2.0 + org.springframework.boot @@ -179,6 +185,8 @@ spring-boot-configuration-processor true + + diff --git a/src/main/java/com/billpaymentservice/bbps/config/RateLimitingFilter.java b/src/main/java/com/billpaymentservice/bbps/config/RateLimitingFilter.java new file mode 100644 index 0000000..14ff370 --- /dev/null +++ b/src/main/java/com/billpaymentservice/bbps/config/RateLimitingFilter.java @@ -0,0 +1,116 @@ +package com.billpaymentservice.bbps.config; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +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.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import com.billpaymentservice.bbps.util.BBPSConstant; +import com.billpaymentservice.bbps.dto.ResponseModel; + +import io.github.resilience4j.ratelimiter.RateLimiter; +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +import com.fasterxml.jackson.databind.ObjectMapper; + +@Component +@Order(1) +public class RateLimitingFilter extends OncePerRequestFilter { + + private final RateLimiterRegistry rateLimiterRegistry; + private static final Logger LOGGER = LoggerFactory.getLogger(RateLimitingFilter.class); + + // Har MID ke liye alag limiter + private final Map limiterCache = new ConcurrentHashMap<>(); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + public RateLimitingFilter(RateLimiterRegistry rateLimiterRegistry) { + this.rateLimiterRegistry = rateLimiterRegistry; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + String mid = request.getHeader("mid"); + if(mid == null) { + mid = "GLOBAL"; + } + + // Validate MID + if (mid == null || mid.isBlank()) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.setContentType("application/json"); + + ResponseModel apiResponse = new ResponseModel<>("mid cannot be null or blank", "FAILURE", HttpServletResponse.SC_BAD_REQUEST, null); + response.getWriter().write(OBJECT_MAPPER.writeValueAsString(apiResponse)); + return; + } + + try { + RateLimiter rateLimiter = limiterCache.computeIfAbsent( + mid, + key -> rateLimiterRegistry.rateLimiter("default") + ); + + // Rate limit check + if (!rateLimiter.acquirePermission()) { + + // Prefer existing constant if it maps to 429, otherwise fallback to literal 429 + int tooManyRequestsStatus =429; + + response.setStatus(tooManyRequestsStatus); + response.setContentType("application/json"); + + ResponseModel apiResponse = new ResponseModel<>("Rate limit exceeded for MID: " + mid, "FAILURE", tooManyRequestsStatus, null); + response.getWriter().write(OBJECT_MAPPER.writeValueAsString(apiResponse)); + return; + } + + // Proceed with the chain and catch downstream exceptions to return consistent model + try { + filterChain.doFilter(request, response); + } catch (Exception ex) { + LOGGER.error("Unhandled exception in filter chain for MID: {}", mid, ex); + if (!response.isCommitted()) { + response.reset(); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.setContentType("application/json"); + ResponseModel apiResponse = new ResponseModel<>("Internal server error", "FAILURE", HttpServletResponse.SC_INTERNAL_SERVER_ERROR, null); + response.getWriter().write(OBJECT_MAPPER.writeValueAsString(apiResponse)); + } + } + + } catch (Exception e) { + LOGGER.error("Error while rate limiting request", e); + if (!response.isCommitted()) { + response.reset(); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.setContentType("application/json"); + ResponseModel apiResponse = new ResponseModel<>("Internal server error", "FAILURE", HttpServletResponse.SC_INTERNAL_SERVER_ERROR, null); + response.getWriter().write(OBJECT_MAPPER.writeValueAsString(apiResponse)); + } + return; + } + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + + String path = request.getRequestURI(); + + return path.startsWith("/swagger") + || path.startsWith("/v3/api-docs") + || path.startsWith("/actuator"); + } +} \ No newline at end of file diff --git a/src/main/java/com/billpaymentservice/bbps/util/BBPSConstant.java b/src/main/java/com/billpaymentservice/bbps/util/BBPSConstant.java index 1dcc1d8..d8b5e09 100644 --- a/src/main/java/com/billpaymentservice/bbps/util/BBPSConstant.java +++ b/src/main/java/com/billpaymentservice/bbps/util/BBPSConstant.java @@ -130,6 +130,7 @@ public class BBPSConstant extends BBPS_RESPONSE_COD_CONSTANT { public static final int HTTP_OK_INT = 200; public static final int HTTP_NOT_FOUND = 404; public static final int HTTP_INTERNAL_SERVER_ERROR = 500; + public static final int HTTP_TO_MANY_REQUEST=429; // Response and helper keys public static final String HTTP_SERVICE_ERROR_PREFIX = "HttpService Error"; diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index 16caf94..01467e6 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -55,6 +55,9 @@ master.data.url = https://bbps-staging.digiledge.in/agent/cou-master/masters agent.data.url=https://bbps-staging.digiledge.in/agent/bill-payment-new payment.data.url=https://bbps-staging.digiledge.in/couapp +resilience4j.ratelimiter.configs.default.limit-for-period=1000 +resilience4j.ratelimiter.configs.default.limit-refresh-period=1m +resilience4j.ratelimiter.configs.default.timeout-duration=0 # Resilience4j Retry resilience4j.retry.instances.callbackService.max-attempts=1 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b64b5a8..7efe143 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -72,7 +72,13 @@ spring.mvc.pathmatch.matching-strategy=ANT_PATH_MATCHER jwt.secret=6C756148487475787A414646736D4B6E444E585A766145396E6A4555594A595566657A6E434D62536856674A704C494F6C6E666B366A4A6A6D5630323564716C614F +# =============================== +# Resilience4j Rate Limiter +# =============================== +resilience4j.ratelimiter.configs.default.limit-for-period=5 +resilience4j.ratelimiter.configs.default.limit-refresh-period=1m +resilience4j.ratelimiter.configs.default.timeout-duration=0 # Resilience4j Retry resilience4j.retry.instances.callbackService.max-attempts=1 resilience4j.retry.instances.callbackService.wait-duration=2s