Skip to content

Commit 272d534

Browse files
marevolclaude
andauthored
feat(filter): add CPU load-based request control with HTTP 429 (#3037)
Introduce LoadControlFilter that monitors OpenSearch CPU usage and returns HTTP 429 (Too Many Requests) when thresholds are exceeded. Web and API requests have independent configurable thresholds. - Add LoadControlFilter servlet filter with configurable CPU thresholds - Add LoadControlMonitorTarget for periodic OpenSearch CPU monitoring - Add ErrorBusyAction and busy.jsp for user-friendly 429 error page - Add web.xml filter registration and 429 error-page mapping - Add FessConfig properties: web.load.control, api.load.control, load.control.monitor.interval (disabled by default with threshold=100) - Add i18n labels for busy page across all supported languages - Add comprehensive unit tests for LoadControlFilter Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 44093a8 commit 272d534

29 files changed

+1575
-0
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright 2012-2025 CodeLibs Project and the Others.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13+
* either express or implied. See the License for the specific language
14+
* governing permissions and limitations under the License.
15+
*/
16+
package org.codelibs.fess.app.web.error;
17+
18+
import org.codelibs.fess.app.web.base.FessSearchAction;
19+
import org.lastaflute.web.Execute;
20+
import org.lastaflute.web.response.HtmlResponse;
21+
22+
/**
23+
* Action class for handling HTTP 429 Too Many Requests error pages.
24+
* This action displays error pages when the server is under high load.
25+
*/
26+
public class ErrorBusyAction extends FessSearchAction {
27+
28+
/**
29+
* Default constructor for ErrorBusyAction.
30+
*/
31+
public ErrorBusyAction() {
32+
super();
33+
}
34+
35+
/**
36+
* Displays the busy error page.
37+
*
38+
* @param form the error form containing error information
39+
* @return HTML response for the busy error page
40+
*/
41+
@Execute
42+
public HtmlResponse index(final ErrorForm form) {
43+
return asHtml(virtualHost(path_Error_BusyJsp));
44+
}
45+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
* Copyright 2012-2025 CodeLibs Project and the Others.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13+
* either express or implied. See the License for the specific language
14+
* governing permissions and limitations under the License.
15+
*/
16+
package org.codelibs.fess.filter;
17+
18+
import java.io.IOException;
19+
import java.util.Arrays;
20+
import java.util.Set;
21+
import java.util.stream.Collectors;
22+
23+
import org.apache.logging.log4j.LogManager;
24+
import org.apache.logging.log4j.Logger;
25+
import org.codelibs.fess.mylasta.direction.FessConfig;
26+
import org.codelibs.fess.util.ComponentUtil;
27+
28+
import jakarta.servlet.Filter;
29+
import jakarta.servlet.FilterChain;
30+
import jakarta.servlet.ServletException;
31+
import jakarta.servlet.ServletRequest;
32+
import jakarta.servlet.ServletResponse;
33+
import jakarta.servlet.http.HttpServletRequest;
34+
import jakarta.servlet.http.HttpServletResponse;
35+
36+
/**
37+
* Filter for CPU load-based request control.
38+
* Returns HTTP 429 (Too Many Requests) when CPU usage exceeds configurable thresholds.
39+
* Web and API requests have independent threshold settings.
40+
*/
41+
public class LoadControlFilter implements Filter {
42+
43+
private static final Logger logger = LogManager.getLogger(LoadControlFilter.class);
44+
45+
private static final int RETRY_AFTER_SECONDS = 60;
46+
47+
private static final Set<String> STATIC_EXTENSIONS =
48+
Arrays.stream(new String[] { ".css", ".js", ".png", ".jpg", ".gif", ".ico", ".svg", ".woff", ".woff2", ".ttf", ".eot" })
49+
.collect(Collectors.toSet());
50+
51+
/**
52+
* Creates a new instance of LoadControlFilter.
53+
*/
54+
public LoadControlFilter() {
55+
// Default constructor
56+
}
57+
58+
@Override
59+
public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain)
60+
throws IOException, ServletException {
61+
if (!ComponentUtil.available()) {
62+
chain.doFilter(request, response);
63+
return;
64+
}
65+
66+
final HttpServletRequest httpRequest = (HttpServletRequest) request;
67+
final HttpServletResponse httpResponse = (HttpServletResponse) response;
68+
final String path = httpRequest.getRequestURI().substring(httpRequest.getContextPath().length());
69+
70+
if (isExcludedPath(path)) {
71+
chain.doFilter(request, response);
72+
return;
73+
}
74+
75+
final boolean isApiPath = path.startsWith("/api/");
76+
final FessConfig fessConfig = ComponentUtil.getFessConfig();
77+
final int threshold = isApiPath ? fessConfig.getApiLoadControlAsInteger() : fessConfig.getWebLoadControlAsInteger();
78+
79+
if (threshold >= 100) {
80+
chain.doFilter(request, response);
81+
return;
82+
}
83+
84+
final short cpuPercent = ComponentUtil.getSystemHelper().getSearchEngineCpuPercent();
85+
86+
if (cpuPercent < threshold) {
87+
chain.doFilter(request, response);
88+
return;
89+
}
90+
91+
if (logger.isInfoEnabled()) {
92+
logger.info("Rejecting request due to high CPU load: path={}, cpu={}%, threshold={}%", path, cpuPercent, threshold);
93+
}
94+
95+
if (isApiPath) {
96+
sendApiResponse(httpResponse);
97+
} else {
98+
httpResponse.sendError(429);
99+
}
100+
}
101+
102+
/**
103+
* Checks if the given path should be excluded from load control.
104+
* @param path the request path
105+
* @return true if the path should be excluded
106+
*/
107+
protected boolean isExcludedPath(final String path) {
108+
if (path.startsWith("/admin") || path.startsWith("/error") || path.startsWith("/login")) {
109+
return true;
110+
}
111+
final int dotIndex = path.lastIndexOf('.');
112+
if (dotIndex >= 0) {
113+
final String extension = path.substring(dotIndex);
114+
return STATIC_EXTENSIONS.contains(extension);
115+
}
116+
return false;
117+
}
118+
119+
/**
120+
* Sends a 429 JSON response for API requests.
121+
* @param response the HTTP response
122+
* @throws IOException if an I/O error occurs
123+
*/
124+
protected void sendApiResponse(final HttpServletResponse response) throws IOException {
125+
response.setStatus(429);
126+
response.setContentType("application/json;charset=UTF-8");
127+
response.setHeader("Retry-After", String.valueOf(RETRY_AFTER_SECONDS));
128+
response.getWriter()
129+
.write("{\"response\":{\"status\":9,\"message\":\"Server is busy. Please retry after " + RETRY_AFTER_SECONDS
130+
+ " seconds.\",\"retry_after\":" + RETRY_AFTER_SECONDS + "}}");
131+
}
132+
}

src/main/java/org/codelibs/fess/helper/SystemHelper.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,15 @@
6363
import org.codelibs.core.lang.StringUtil;
6464
import org.codelibs.core.lang.ThreadUtil;
6565
import org.codelibs.core.misc.Pair;
66+
import org.codelibs.core.timer.TimeoutManager;
67+
import org.codelibs.core.timer.TimeoutTask;
6668
import org.codelibs.fess.Constants;
6769
import org.codelibs.fess.crawler.util.CharUtil;
6870
import org.codelibs.fess.exception.FessSystemException;
6971
import org.codelibs.fess.mylasta.action.FessMessages;
7072
import org.codelibs.fess.mylasta.action.FessUserBean;
7173
import org.codelibs.fess.mylasta.direction.FessConfig;
74+
import org.codelibs.fess.timer.LoadControlMonitorTarget;
7275
import org.codelibs.fess.util.ComponentUtil;
7376
import org.codelibs.fess.util.GsaConfigParser;
7477
import org.codelibs.fess.util.IpAddressUtil;
@@ -150,6 +153,10 @@ public SystemHelper() {
150153

151154
private long systemCpuCheckInterval = 1000L;
152155

156+
private volatile short searchEngineCpuPercent;
157+
158+
private volatile TimeoutTask loadControlMonitorTask;
159+
153160
/** A map of listeners for configuration updates. */
154161
protected Map<String, Supplier<String>> updateConfigListenerMap = new HashMap<>();
155162

@@ -1001,6 +1008,49 @@ protected short getSystemCpuPercent() {
10011008
return systemCpuPercent;
10021009
}
10031010

1011+
/**
1012+
* Gets the current system CPU usage percentage.
1013+
*
1014+
* @return The system CPU usage percentage.
1015+
*/
1016+
public short currentSystemCpuPercent() {
1017+
return getSystemCpuPercent();
1018+
}
1019+
1020+
/**
1021+
* Gets the search engine CPU usage percentage.
1022+
*
1023+
* @return The search engine CPU usage percentage.
1024+
*/
1025+
public short getSearchEngineCpuPercent() {
1026+
ensureLoadControlMonitorStarted();
1027+
return searchEngineCpuPercent;
1028+
}
1029+
1030+
/**
1031+
* Sets the search engine CPU usage percentage.
1032+
*
1033+
* @param percent The search engine CPU usage percentage.
1034+
*/
1035+
public void setSearchEngineCpuPercent(final short percent) {
1036+
searchEngineCpuPercent = percent;
1037+
}
1038+
1039+
private void ensureLoadControlMonitorStarted() {
1040+
if (loadControlMonitorTask == null) {
1041+
final FessConfig fessConfig = ComponentUtil.getFessConfig();
1042+
if (fessConfig.getWebLoadControlAsInteger() < 100 || fessConfig.getApiLoadControlAsInteger() < 100) {
1043+
synchronized (this) {
1044+
if (loadControlMonitorTask == null) {
1045+
final int interval = fessConfig.getLoadControlMonitorIntervalAsInteger();
1046+
loadControlMonitorTask =
1047+
TimeoutManager.getInstance().addTimeoutTarget(new LoadControlMonitorTarget(this), interval, true);
1048+
}
1049+
}
1050+
}
1051+
}
1052+
}
1053+
10041054
/**
10051055
* Gets a map of filtered environment variables.
10061056
*

src/main/java/org/codelibs/fess/mylasta/action/FessHtmlPath.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,9 @@ public interface FessHtmlPath {
409409
/** The path of the HTML: /error/badRequest.jsp */
410410
HtmlNext path_Error_BadRequestJsp = new HtmlNext("/error/badRequest.jsp");
411411

412+
/** The path of the HTML: /error/busy.jsp */
413+
HtmlNext path_Error_BusyJsp = new HtmlNext("/error/busy.jsp");
414+
412415
/** The path of the HTML: /error/error.jsp */
413416
HtmlNext path_Error_ErrorJsp = new HtmlNext("/error/error.jsp");
414417

src/main/java/org/codelibs/fess/mylasta/action/FessLabels.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,12 @@ public class FessLabels extends UserMessages {
10221022
/** The key of the message: Please check the URL. */
10231023
public static final String LABELS_check_url = "{labels.check_url}";
10241024

1025+
/** The key of the message: Service Temporarily Unavailable */
1026+
public static final String LABELS_busy_title = "{labels.busy_title}";
1027+
1028+
/** The key of the message: The server is currently experiencing high load. Please try again later. */
1029+
public static final String LABELS_busy_message = "{labels.busy_message}";
1030+
10251031
/** The key of the message: Username */
10261032
public static final String LABELS_user_name = "{labels.user_name}";
10271033

src/main/java/org/codelibs/fess/mylasta/direction/FessConfig.java

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,15 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction
226226
/** The key of the configuration. e.g. 50 */
227227
String ADAPTIVE_LOAD_CONTROL = "adaptive.load.control";
228228

229+
/** The key of the configuration. e.g. 100 */
230+
String WEB_LOAD_CONTROL = "web.load.control";
231+
232+
/** The key of the configuration. e.g. 100 */
233+
String API_LOAD_CONTROL = "api.load.control";
234+
235+
/** The key of the configuration. e.g. 1 */
236+
String LOAD_CONTROL_MONITOR_INTERVAL = "load.control.monitor.interval";
237+
229238
/** The key of the configuration. e.g. js */
230239
String SUPPORTED_UPLOADED_JS_EXTENTIONS = "supported.uploaded.js.extentions";
231240

@@ -2535,6 +2544,57 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction
25352544
*/
25362545
Integer getAdaptiveLoadControlAsInteger();
25372546

2547+
/**
2548+
* Get the value for the key 'web.load.control'. <br>
2549+
* The value is, e.g. 100 <br>
2550+
* comment: CPU threshold (%) for web request load control. Returns 429 when CPU &gt;= this value. (100: disabled)
2551+
* @return The value of found property. (NotNull: if not found, exception but basically no way)
2552+
*/
2553+
String getWebLoadControl();
2554+
2555+
/**
2556+
* Get the value for the key 'web.load.control' as {@link Integer}. <br>
2557+
* The value is, e.g. 100 <br>
2558+
* comment: CPU threshold (%) for web request load control. Returns 429 when CPU &gt;= this value. (100: disabled)
2559+
* @return The value of found property. (NotNull: if not found, exception but basically no way)
2560+
* @throws NumberFormatException When the property is not integer.
2561+
*/
2562+
Integer getWebLoadControlAsInteger();
2563+
2564+
/**
2565+
* Get the value for the key 'api.load.control'. <br>
2566+
* The value is, e.g. 100 <br>
2567+
* comment: CPU threshold (%) for API request load control. Returns 429 when CPU &gt;= this value. (100: disabled)
2568+
* @return The value of found property. (NotNull: if not found, exception but basically no way)
2569+
*/
2570+
String getApiLoadControl();
2571+
2572+
/**
2573+
* Get the value for the key 'api.load.control' as {@link Integer}. <br>
2574+
* The value is, e.g. 100 <br>
2575+
* comment: CPU threshold (%) for API request load control. Returns 429 when CPU &gt;= this value. (100: disabled)
2576+
* @return The value of found property. (NotNull: if not found, exception but basically no way)
2577+
* @throws NumberFormatException When the property is not integer.
2578+
*/
2579+
Integer getApiLoadControlAsInteger();
2580+
2581+
/**
2582+
* Get the value for the key 'load.control.monitor.interval'. <br>
2583+
* The value is, e.g. 1 <br>
2584+
* comment: Interval (seconds) for monitoring OpenSearch CPU load.
2585+
* @return The value of found property. (NotNull: if not found, exception but basically no way)
2586+
*/
2587+
String getLoadControlMonitorInterval();
2588+
2589+
/**
2590+
* Get the value for the key 'load.control.monitor.interval' as {@link Integer}. <br>
2591+
* The value is, e.g. 1 <br>
2592+
* comment: Interval (seconds) for monitoring OpenSearch CPU load.
2593+
* @return The value of found property. (NotNull: if not found, exception but basically no way)
2594+
* @throws NumberFormatException When the property is not integer.
2595+
*/
2596+
Integer getLoadControlMonitorIntervalAsInteger();
2597+
25382598
/**
25392599
* Get the value for the key 'supported.uploaded.js.extentions'. <br>
25402600
* The value is, e.g. js <br>
@@ -9779,6 +9839,30 @@ public Integer getAdaptiveLoadControlAsInteger() {
97799839
return getAsInteger(FessConfig.ADAPTIVE_LOAD_CONTROL);
97809840
}
97819841

9842+
public String getWebLoadControl() {
9843+
return get(FessConfig.WEB_LOAD_CONTROL);
9844+
}
9845+
9846+
public Integer getWebLoadControlAsInteger() {
9847+
return getAsInteger(FessConfig.WEB_LOAD_CONTROL);
9848+
}
9849+
9850+
public String getApiLoadControl() {
9851+
return get(FessConfig.API_LOAD_CONTROL);
9852+
}
9853+
9854+
public Integer getApiLoadControlAsInteger() {
9855+
return getAsInteger(FessConfig.API_LOAD_CONTROL);
9856+
}
9857+
9858+
public String getLoadControlMonitorInterval() {
9859+
return get(FessConfig.LOAD_CONTROL_MONITOR_INTERVAL);
9860+
}
9861+
9862+
public Integer getLoadControlMonitorIntervalAsInteger() {
9863+
return getAsInteger(FessConfig.LOAD_CONTROL_MONITOR_INTERVAL);
9864+
}
9865+
97829866
public String getSupportedUploadedJsExtentions() {
97839867
return get(FessConfig.SUPPORTED_UPLOADED_JS_EXTENTIONS);
97849868
}
@@ -13188,6 +13272,9 @@ protected java.util.Map<String, String> prepareGeneratedDefaultMap() {
1318813272
defaultMap.put(FessConfig.USE_OWN_TMP_DIR, "true");
1318913273
defaultMap.put(FessConfig.MAX_LOG_OUTPUT_LENGTH, "4000");
1319013274
defaultMap.put(FessConfig.ADAPTIVE_LOAD_CONTROL, "50");
13275+
defaultMap.put(FessConfig.WEB_LOAD_CONTROL, "100");
13276+
defaultMap.put(FessConfig.API_LOAD_CONTROL, "100");
13277+
defaultMap.put(FessConfig.LOAD_CONTROL_MONITOR_INTERVAL, "1");
1319113278
defaultMap.put(FessConfig.SUPPORTED_UPLOADED_JS_EXTENTIONS, "js");
1319213279
defaultMap.put(FessConfig.SUPPORTED_UPLOADED_CSS_EXTENTIONS, "css");
1319313280
defaultMap.put(FessConfig.SUPPORTED_UPLOADED_MEDIA_EXTENTIONS, "jpg,jpeg,gif,png,swf");

0 commit comments

Comments
 (0)