From a46c0b9bb2163edf2e08cdcd286ceb4247e03aa9 Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:55:32 +0800 Subject: [PATCH] feat(ratelimit): make rate-limit thresholds runtime-configurable The per-endpoint quotas were compile-time constants in the @RateLimit annotation, so operators could not tune them or turn limiting off without rebuilding (#726). Add RateLimitProperties (skillhub.ratelimit) with a global `enabled` toggle and per-category threshold overrides, and have RateLimitInterceptor resolve the effective authenticated/anonymous limit and window from config, falling back to the annotation defaults. Unset categories keep the built-in values, so behavior is unchanged until an override is provided; `enabled=false` disables quota checks entirely. All configurable via SKILLHUB_RATELIMIT_* environment variables. Closes #726 Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> --- .../skillhub/config/RateLimitProperties.java | 93 +++++++++++++++++++ .../ratelimit/RateLimitInterceptor.java | 43 ++++++--- .../src/main/resources/application.yml | 20 ++++ .../config/RateLimitPropertiesTest.java | 54 +++++++++++ 4 files changed, 196 insertions(+), 14 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RateLimitProperties.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RateLimitPropertiesTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RateLimitProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RateLimitProperties.java new file mode 100644 index 00000000..5d0895ae --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RateLimitProperties.java @@ -0,0 +1,93 @@ +package com.iflytek.skillhub.config; + +import java.util.HashMap; +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Runtime-configurable overrides for request rate limiting. + * + *

The compile-time {@link com.iflytek.skillhub.ratelimit.RateLimit} annotation on each endpoint + * supplies the built-in defaults. Values set here — typically via {@code SKILLHUB_RATELIMIT_*} + * environment variables — override those defaults per {@code category}, and {@code enabled=false} + * turns request rate limiting off entirely. When nothing is configured the annotation defaults are + * used unchanged, so existing deployments behave exactly as before. + * + *

An override applies to every endpoint that shares the same {@code category}. Only the fields + * you set are overridden; the rest fall back to the annotation. + */ +@Component +@ConfigurationProperties(prefix = "skillhub.ratelimit") +public class RateLimitProperties { + + /** Master switch. When {@code false} the interceptor performs no quota checks. */ + private boolean enabled = true; + + /** Per-category overrides keyed by {@code RateLimit#category} (e.g. "search", "download", "publish"). */ + private Map categories = new HashMap<>(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Map getCategories() { + return categories; + } + + public void setCategories(Map categories) { + this.categories = categories; + } + + /** Configured authenticated quota for {@code category}, or {@code fallback} when unset. */ + public int authenticatedFor(String category, int fallback) { + CategoryLimit c = categories.get(category); + return c != null && c.getAuthenticated() != null ? c.getAuthenticated() : fallback; + } + + /** Configured anonymous quota for {@code category}, or {@code fallback} when unset. */ + public int anonymousFor(String category, int fallback) { + CategoryLimit c = categories.get(category); + return c != null && c.getAnonymous() != null ? c.getAnonymous() : fallback; + } + + /** Configured window (seconds) for {@code category}, or {@code fallback} when unset. */ + public int windowSecondsFor(String category, int fallback) { + CategoryLimit c = categories.get(category); + return c != null && c.getWindowSeconds() != null ? c.getWindowSeconds() : fallback; + } + + public static class CategoryLimit { + private Integer authenticated; + private Integer anonymous; + private Integer windowSeconds; + + public Integer getAuthenticated() { + return authenticated; + } + + public void setAuthenticated(Integer authenticated) { + this.authenticated = authenticated; + } + + public Integer getAnonymous() { + return anonymous; + } + + public void setAnonymous(Integer anonymous) { + this.anonymous = anonymous; + } + + public Integer getWindowSeconds() { + return windowSeconds; + } + + public void setWindowSeconds(Integer windowSeconds) { + this.windowSeconds = windowSeconds; + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java index da421b5d..6da411c4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.ratelimit; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.config.RateLimitProperties; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.metrics.SkillHubMetrics; @@ -28,19 +29,22 @@ public class RateLimitInterceptor implements HandlerInterceptor { private final ApiResponseFactory apiResponseFactory; private final ObjectMapper objectMapper; private final SkillHubMetrics metrics; + private final RateLimitProperties properties; public RateLimitInterceptor(RateLimiter rateLimiter, ClientIpResolver clientIpResolver, AnonymousDownloadIdentityService anonymousDownloadIdentityService, ApiResponseFactory apiResponseFactory, ObjectMapper objectMapper, - SkillHubMetrics metrics) { + SkillHubMetrics metrics, + RateLimitProperties properties) { this.rateLimiter = rateLimiter; this.clientIpResolver = clientIpResolver; this.anonymousDownloadIdentityService = anonymousDownloadIdentityService; this.apiResponseFactory = apiResponseFactory; this.objectMapper = objectMapper; this.metrics = metrics; + this.properties = properties; } @Override @@ -56,23 +60,33 @@ public class RateLimitInterceptor implements HandlerInterceptor { return true; } + // Master switch: when disabled, perform no quota checks at all. + if (!properties.isEnabled()) { + return true; + } + // Determine if user is authenticated String userId = (String) request.getAttribute("userId"); boolean isAuthenticated = userId != null; - // Get limit based on authentication status - int limit = isAuthenticated ? rateLimit.authenticated() : rateLimit.anonymous(); - String resourceSuffix = resolveResourceSuffix(rateLimit.category(), request); + // Effective limits: runtime config overrides (per category) fall back to the + // annotation defaults, so unconfigured deployments behave exactly as before. + String category = rateLimit.category(); + int windowSeconds = properties.windowSecondsFor(category, rateLimit.windowSeconds()); + int limit = isAuthenticated + ? properties.authenticatedFor(category, rateLimit.authenticated()) + : properties.anonymousFor(category, rateLimit.anonymous()); + String resourceSuffix = resolveResourceSuffix(category, request); boolean allowed = isAuthenticated ? rateLimiter.tryAcquire( - "ratelimit:" + rateLimit.category() + ":user:" + userId + resourceSuffix, + "ratelimit:" + category + ":user:" + userId + resourceSuffix, limit, - rateLimit.windowSeconds()) - : checkAnonymousLimit(request, response, rateLimit, limit, resourceSuffix); + windowSeconds) + : checkAnonymousLimit(request, response, category, limit, windowSeconds, resourceSuffix); if (!allowed) { - metrics.incrementRateLimitExceeded(rateLimit.category()); + metrics.incrementRateLimitExceeded(category); response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); ApiResponse body = apiResponseFactory.error(429, "error.rateLimit.exceeded"); @@ -85,14 +99,15 @@ public class RateLimitInterceptor implements HandlerInterceptor { private boolean checkAnonymousLimit(HttpServletRequest request, HttpServletResponse response, - RateLimit rateLimit, + String category, int limit, + int windowSeconds, String resourceSuffix) { - if (!"download".equals(rateLimit.category())) { + if (!"download".equals(category)) { return rateLimiter.tryAcquire( - "ratelimit:" + rateLimit.category() + ":ip:" + clientIpResolver.resolve(request) + resourceSuffix, + "ratelimit:" + category + ":ip:" + clientIpResolver.resolve(request) + resourceSuffix, limit, - rateLimit.windowSeconds() + windowSeconds ); } @@ -101,7 +116,7 @@ public class RateLimitInterceptor implements HandlerInterceptor { boolean ipAllowed = rateLimiter.tryAcquire( "ratelimit:download:ip:" + identity.ipHash() + resourceSuffix, limit, - rateLimit.windowSeconds() + windowSeconds ); if (!ipAllowed) { return false; @@ -109,7 +124,7 @@ public class RateLimitInterceptor implements HandlerInterceptor { return rateLimiter.tryAcquire( "ratelimit:download:anon:" + identity.cookieHash() + resourceSuffix, limit, - rateLimit.windowSeconds() + windowSeconds ); } diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index b290fc77..2039d844 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -152,6 +152,26 @@ skillhub: candidate-multiplier: 8 max-candidates: 120 ratelimit: + # Master switch for per-endpoint request rate limiting. Set false (or + # SKILLHUB_RATELIMIT_ENABLED=false) to turn quota checks off entirely. + enabled: ${SKILLHUB_RATELIMIT_ENABLED:true} + # Per-category threshold overrides. Unset categories use the built-in + # @RateLimit annotation defaults, so this block is optional and changes + # nothing until you set a value. Each override applies to every endpoint + # sharing that category. Override via env vars, e.g.: + # SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_AUTHENTICATED=120 + # SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_ANONYMOUS=40 + # SKILLHUB_RATELIMIT_CATEGORIES_PUBLISH_WINDOW_SECONDS=3600 + # categories: + # search: + # authenticated: 60 + # anonymous: 20 + # download: + # authenticated: 120 + # anonymous: 30 + # publish: + # authenticated: 10 + # window-seconds: 60 download: anonymous-cookie-name: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_NAME:skillhub_anon_dl} anonymous-cookie-max-age: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_MAX_AGE:P30D} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RateLimitPropertiesTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RateLimitPropertiesTest.java new file mode 100644 index 00000000..7145fb71 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RateLimitPropertiesTest.java @@ -0,0 +1,54 @@ +package com.iflytek.skillhub.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class RateLimitPropertiesTest { + + @Test + void enabledByDefaultAndNoOverrides() { + RateLimitProperties properties = new RateLimitProperties(); + + assertThat(properties.isEnabled()).isTrue(); + assertThat(properties.getCategories()).isEmpty(); + } + + @Test + void fallsBackToAnnotationDefaultsWhenCategoryUnset() { + RateLimitProperties properties = new RateLimitProperties(); + + assertThat(properties.authenticatedFor("search", 60)).isEqualTo(60); + assertThat(properties.anonymousFor("search", 20)).isEqualTo(20); + assertThat(properties.windowSecondsFor("search", 60)).isEqualTo(60); + } + + @Test + void overridesOnlyTheFieldsThatAreSet() { + RateLimitProperties.CategoryLimit search = new RateLimitProperties.CategoryLimit(); + search.setAuthenticated(120); + // anonymous and windowSeconds intentionally left null + + RateLimitProperties properties = new RateLimitProperties(); + properties.getCategories().put("search", search); + + assertThat(properties.authenticatedFor("search", 60)).isEqualTo(120); + assertThat(properties.anonymousFor("search", 20)).isEqualTo(20); + assertThat(properties.windowSecondsFor("search", 60)).isEqualTo(60); + } + + @Test + void overrideAppliesPerCategoryOnly() { + RateLimitProperties.CategoryLimit publish = new RateLimitProperties.CategoryLimit(); + publish.setAuthenticated(5); + publish.setWindowSeconds(3600); + + RateLimitProperties properties = new RateLimitProperties(); + properties.getCategories().put("publish", publish); + + assertThat(properties.authenticatedFor("publish", 10)).isEqualTo(5); + assertThat(properties.windowSecondsFor("publish", 60)).isEqualTo(3600); + // A different category is unaffected. + assertThat(properties.authenticatedFor("download", 120)).isEqualTo(120); + } +}