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>
This commit is contained in:
FenjuFu 2026-08-25 19:55:32 +08:00
parent 954dfce7a4
commit a46c0b9bb2
4 changed files with 196 additions and 14 deletions

View file

@ -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.
*
* <p>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.
*
* <p>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<String, CategoryLimit> categories = new HashMap<>();
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public Map<String, CategoryLimit> getCategories() {
return categories;
}
public void setCategories(Map<String, CategoryLimit> 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;
}
}
}

View file

@ -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<Void> 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
);
}

View file

@ -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}

View file

@ -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);
}
}