Skip to content

Commit 3b34876

Browse files
marevolclaude
andcommitted
feat(filter): add CPU load-based request control with HTTP 429
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 3b34876

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)