This is an automated email from the ASF dual-hosted git repository.
tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git
The following commit(s) were added to refs/heads/main by this push:
new 9ef8bfade7 TIKA-4991: apply read deadline only to reads that would
wait on the c… (#3404)
9ef8bfade7 is described below
commit 9ef8bfade74caedde4089d83a74ae13ec33b72c4
Author: Tim Allison <[email protected]>
AuthorDate: Fri Oct 23 11:50:24 2026 -0400
TIKA-4991: apply read deadline only to reads that would wait on the c…
(#3404)
* TIKA-4991: apply read deadline only to reads that would wait on the client
CXF reads the multipart closing boundary after the resource returns, so a
/form parse longer than maxRequestReadMillis was answered 408 and its
result
discarded. Skip the deadline when the servlet input is finished or has
bytes
buffered; drop the redundant entity-level deadline.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
CHANGES.txt | 4 +-
.../ROOT/pages/using-tika/server/index.adoc | 2 +-
.../tika/server/core/MaxRequestSizeFilter.java | 52 ++++++++++++----------
.../core/RequestReadDeadlineInterceptor.java | 3 --
.../apache/tika/server/core/TikaServerConfig.java | 5 ++-
.../apache/tika/server/core/TikaServerProcess.java | 3 +-
.../standard/MaxRequestSizeIntegrationTest.java | 23 ++++++++++
7 files changed, 58 insertions(+), 34 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 27646c9141..71cc4dac73 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -55,8 +55,8 @@ Release 4.2.0 - 10/22/2026
not SizeGatedEnricher (TIKA-4972).
* tika-server adds transfer limits: maxRequestReadMillis (408) and
- maxResponseWriteMillis, both 5 min; idleTimeoutMillis (120s); and
- maxInFlightRequestBytes (429). Raise the deadlines for very large or very
slow
+ maxResponseWriteMillis, both 5 min; idleTimeoutMillis (120s, was CXF's
fixed
+ 200s); and maxInFlightRequestBytes (429). Raise the deadlines for very
large or very slow
transfers; cut-offs are logged at INFO by the TransferLimitLog logger
(TIKA-4991).
* tika-server accepts cleartext HTTP/2 (h2c) only when allowHttp2 is set;
it had
diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc
b/docs/modules/ROOT/pages/using-tika/server/index.adoc
index fcb41e95b3..743596ee47 100644
--- a/docs/modules/ROOT/pages/using-tika/server/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc
@@ -720,7 +720,7 @@ independent forked-process groups you run — see
|`idleTimeoutMillis`
|`120000` (2 min)
-|How long a connection may go without any bytes moving before it is closed.
This is what ends a client that stops sending or reading altogether, which the
read and write deadlines cannot. A request waiting on its parse is not
affected, however long the parse takes. Must be positive: it cannot be
disabled. Every request cut off by this or by the size and deadline limits
above is logged once, at `INFO`, by the
`org.apache.tika.server.core.TransferLimitLog` logger, so these client-caused
ev [...]
+|How long a connection may go without any bytes moving before it is closed.
This is what ends a client that stops sending or reading altogether, which the
read and write deadlines cannot. A request waiting on its parse is not
affected, however long the parse takes: Jetty applies the idle timeout only to
a read or write that is waiting on the wire, so no setting here ends a parse;
the xref:pipes/timeouts.adoc[task timeouts] do that. Must be positive: it
cannot be disabled. Every request c [...]
|`allowHttp2`
|`false`
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/MaxRequestSizeFilter.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/MaxRequestSizeFilter.java
index 97db65b9fa..983da171e2 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/MaxRequestSizeFilter.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/MaxRequestSizeFilter.java
@@ -19,9 +19,9 @@ package org.apache.tika.server.core;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
-import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
+import jakarta.servlet.ServletInputStream;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.core.HttpHeaders;
@@ -30,7 +30,6 @@ import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import org.apache.cxf.jaxrs.utils.JAXRSUtils;
-import org.apache.cxf.message.Message;
/**
* Rejects request bodies larger than {@code maxRequestSizeBytes} (413), still
arriving
@@ -40,8 +39,8 @@ import org.apache.cxf.message.Message;
* A declared Content-Length over the limit is refused before the body is
read. Requests
* without a usable Content-Length -- chunked transfer encoding, in particular
-- are
* counted as they are consumed, so the limit holds whether or not the client
is honest
- * about the size. The wire under the entity is bounded by
- * {@link RequestReadDeadlineInterceptor}, whose start time this deadline
shares.
+ * about the size. The 408 comes from {@link RequestReadDeadlineInterceptor},
which puts
+ * the deadline on the wire under this stream.
*/
@Provider
public class MaxRequestSizeFilter implements ContainerRequestFilter {
@@ -54,25 +53,21 @@ public class MaxRequestSizeFilter implements
ContainerRequestFilter {
private static final long RETRY_AFTER_SECONDS = 1;
private final long maxRequestSizeBytes;
- private final long maxRequestReadMillis;
private final RequestBodyBudget budget;
/**
* @param maxRequestSizeBytes maximum request body in bytes; negative
disables the limit
*/
public MaxRequestSizeFilter(long maxRequestSizeBytes) {
- this(maxRequestSizeBytes, -1, null);
+ this(maxRequestSizeBytes, null);
}
/**
* @param maxRequestSizeBytes maximum request body in bytes; negative
disables the limit
- * @param maxRequestReadMillis how long reading the body may take;
negative disables it
* @param budget bytes all requests may hold at once; null disables it
*/
- public MaxRequestSizeFilter(long maxRequestSizeBytes, long
maxRequestReadMillis,
- RequestBodyBudget budget) {
+ public MaxRequestSizeFilter(long maxRequestSizeBytes, RequestBodyBudget
budget) {
this.maxRequestSizeBytes = maxRequestSizeBytes;
- this.maxRequestReadMillis = maxRequestReadMillis;
this.budget = budget;
}
@@ -84,19 +79,11 @@ public class MaxRequestSizeFilter implements
ContainerRequestFilter {
requestContext.abortWith(tooLarge());
return;
}
- long maxReadNanos = maxRequestReadMillis < 0 ? -1 :
TimeUnit.MILLISECONDS.toNanos(maxRequestReadMillis);
RequestBodyBudget.Reservation reservation =
budget == null ? null :
budget.open(JAXRSUtils.getCurrentMessage());
// Entity bytes: after any Content-Encoding is undone.
- requestContext.setEntityStream(new
BoundedInputStream(requestContext.getEntityStream(),
- maxRequestSizeBytes, startNanos(), maxReadNanos,
maxRequestReadMillis, reservation));
- }
-
- private static long startNanos() {
- Message message = JAXRSUtils.getCurrentMessage();
- Object start = message == null || message.getExchange() == null
- ? null :
message.getExchange().get(RequestReadDeadlineInterceptor.START_NANOS);
- return start instanceof Long l ? l : System.nanoTime();
+ requestContext.setEntityStream(
+ new BoundedInputStream(requestContext.getEntityStream(),
maxRequestSizeBytes, reservation));
}
private static Response tooLarge() {
@@ -110,8 +97,10 @@ public class MaxRequestSizeFilter implements
ContainerRequestFilter {
/**
* Throws once more than {@code limit} bytes have been read, or on a read
after the
* deadline. Deliberately not silent truncation: a caller that sent too
much must not
- * receive a 200 describing a prefix of their document. A client that
sends nothing at
- * all is left to the connector's idle timeout.
+ * receive a 200 describing a prefix of their document. The deadline
applies only to a
+ * read that would wait on the client: bytes it already sent, such as a
multipart closing
+ * boundary CXF reads after a long parse, are not late. A client that
sends nothing at all
+ * is left to the connector's idle timeout.
*/
static final class BoundedInputStream extends FilterInputStream {
@@ -122,6 +111,11 @@ public class MaxRequestSizeFilter implements
ContainerRequestFilter {
private final RequestBodyBudget.Reservation reservation;
private long count;
+ /** Size and budget only; the deadline lives on the wire stream under
this one. */
+ BoundedInputStream(InputStream in, long limit,
RequestBodyBudget.Reservation reservation) {
+ this(in, limit, -1, -1, -1, reservation);
+ }
+
BoundedInputStream(InputStream in, long limit, long startNanos, long
maxReadNanos,
long maxReadMillis, RequestBodyBudget.Reservation
reservation) {
super(in);
@@ -190,12 +184,22 @@ public class MaxRequestSizeFilter implements
ContainerRequestFilter {
return skipped;
}
- private void checkDeadline() {
- if (maxReadNanos >= 0 && System.nanoTime() - startNanos >
maxReadNanos) {
+ private void checkDeadline() throws IOException {
+ if (maxReadNanos >= 0 && System.nanoTime() - startNanos >
maxReadNanos && wouldBlock()) {
throw new RequestTimeoutException(TOO_SLOW_MESSAGE + " (" +
maxReadMillis + " ms)");
}
}
+ // On Jetty's servlet stream in blocking mode, available() pulls the
next chunk without
+ // blocking and isReady() is defined as available() > 0; in async mode
isReady() would
+ // register read demand as a side effect, so it is not used here.
+ private boolean wouldBlock() throws IOException {
+ if (in instanceof ServletInputStream servletIn &&
servletIn.isFinished()) {
+ return false;
+ }
+ return in.available() == 0;
+ }
+
private void add(int n) {
count += n;
if (limit >= 0 && count > limit) {
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/RequestReadDeadlineInterceptor.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/RequestReadDeadlineInterceptor.java
index cf20dd3fe4..40747d5a3e 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/RequestReadDeadlineInterceptor.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/RequestReadDeadlineInterceptor.java
@@ -33,8 +33,6 @@ import org.apache.cxf.phase.Phase;
*/
public class RequestReadDeadlineInterceptor extends
AbstractPhaseInterceptor<Message> {
- static final String START_NANOS =
RequestReadDeadlineInterceptor.class.getName() + ".startNanos";
-
// must be on the exchange: unset there, CXF's OutgoingChainInterceptor
sets it true on the
// in-message and the unread body of a 2xx response is spooled to disk
private static final String CXF_CACHE_INPUT = "cxf.io.cacheinput";
@@ -57,7 +55,6 @@ public class RequestReadDeadlineInterceptor extends
AbstractPhaseInterceptor<Mes
}
long startNanos = System.nanoTime();
exchange.put(CXF_CACHE_INPUT, Boolean.FALSE);
- exchange.put(START_NANOS, startNanos);
DelegatingInputStream wire =
message.getContent(DelegatingInputStream.class);
if (maxRequestReadMillis >= 0 && wire != null) {
// deadline only: a size cap here would cut off a client that
reads only after sending
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
index 16513ef886..f9a5a9f910 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
@@ -421,8 +421,9 @@ public class TikaServerConfig {
/**
* How long a connection may go without any bytes moving before it is
closed; defaults to
- * {@link #DEFAULT_IDLE_TIMEOUT_MILLIS} (2 minutes). Must be positive:
Jetty treats zero
- * as "use its own default", not "never".
+ * {@link #DEFAULT_IDLE_TIMEOUT_MILLIS} (2 minutes). Jetty applies it only
to a read or
+ * write waiting on the wire, so a request waiting on its parse is never
ended by it.
+ * Must be positive: Jetty treats zero as "use its own default", not
"never".
*/
public int getIdleTimeoutMillis() {
return idleTimeoutMillis;
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
index dbc9f0ac12..e1bd47206b 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
@@ -493,8 +493,7 @@ public class TikaServerProcess {
// Add ConfigEndpointSecurityFilter to gate /config endpoints
writers.add(new
ConfigEndpointSecurityFilter(tikaServerConfig.isAllowPerRequestConfig()));
- writers.add(new
MaxRequestSizeFilter(tikaServerConfig.getMaxRequestSizeBytes(),
- tikaServerConfig.getMaxRequestReadMillis(), bodyBudget));
+ writers.add(new
MaxRequestSizeFilter(tikaServerConfig.getMaxRequestSizeBytes(), bodyBudget));
// 413 for over-limit chunked bodies, which bypass the Content-Length
pre-check.
writers.add(new MaxRequestSizeFilter.RequestTooLargeExceptionMapper());
writers.add(new MaxRequestSizeFilter.RequestTimeoutExceptionMapper());
diff --git
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/MaxRequestSizeIntegrationTest.java
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/MaxRequestSizeIntegrationTest.java
index 82f2625557..0aba6d18dc 100644
---
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/MaxRequestSizeIntegrationTest.java
+++
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/MaxRequestSizeIntegrationTest.java
@@ -215,6 +215,29 @@ public class MaxRequestSizeIntegrationTest extends
IntegrationTestBase {
}
}
+ /**
+ * CXF reads the multipart closing boundary only after the resource
returns; a parse that
+ * outlasts the read deadline must not turn into a 408 there. Load can
only make the mock's
+ * hang longer, so timing cannot produce a false failure here.
+ */
+ @Test
+ public void testParseOutlastingReadDeadlineMultipart() throws Exception {
+ String mock = "<mock><write element=\"p\">slow parse done</write><hang
millis=\""
+ + 2 * MAX_READ_MILLIS + "\" heavy=\"false\"
pulse_millis=\"100\"/></mock>";
+ String body = "--" + BOUNDARY + "\r\nContent-Disposition: form-data;
name=\"file\"; "
+ + "filename=\"mock.xml\"\r\nContent-Type:
application/mock+xml\r\n\r\n"
+ + mock + "\r\n--" + BOUNDARY + "--\r\n";
+ HttpResponse<String> response =
HttpClient.newHttpClient().send(HttpRequest
+ .newBuilder(URI.create("http://localhost:" +
integrationTestPort + "/rmeta/form"))
+ .header("Content-Type", "multipart/form-data; boundary=" +
BOUNDARY)
+ .header("Accept", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(body, UTF_8))
+ .build(), HttpResponse.BodyHandlers.ofString(UTF_8));
+ assertEquals(200, response.statusCode(), response.body());
+ // proves the mock parser ran, so the parse really did outlast the
deadline
+ assertTrue(response.body().contains("slow parse done"),
response.body());
+ }
+
private static void assertWithinReadDeadline(String expected, String[]
reply) {
assertEquals(expected, reply[0], "after " + reply[1] + " ms");
// the idle timeout alone would answer only once the drip ends