Skip to content

Commit 327eda1

Browse files
tballisonCopilot
andauthored
TIKA-4753 - improve oom/timeout/crash msg (#2870)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent d1e81e3 commit 327eda1

6 files changed

Lines changed: 142 additions & 27 deletions

File tree

docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,29 @@ The separate `/config` endpoints have been removed. Configuration is now handled
8989
9090
**Migration:** Use `POST /tika` or `POST /tika/json` with a `config` part in your multipart request.
9191
92+
=== Error Response Bodies Are Now JSON
93+
94+
In 3.x, error responses from `/tika`, `/rmeta`, and `/unpack` returned a plain-text
95+
body such as "Parse failed: TIMEOUT". In 4.x these endpoints return a JSON body with
96+
at least a `status` field:
97+
98+
[source,json]
99+
----
100+
{"status": "TIMEOUT"}
101+
----
102+
103+
When the server is configured with `returnStackTrace=true`, a `message` field is also
104+
included (it may contain a server-side stack trace), e.g. `{"status": "TIMEOUT", "message": "Task timed out after 60000ms"}`.
105+
106+
The HTTP status codes are also more precise:
107+
108+
* `UNSPECIFIED_CRASH` changed from `500` to `503` — it is a transient process failure
109+
in the same category as `TIMEOUT` and `OOM`, not a server misconfiguration.
110+
111+
**Migration:** Clients that parse plain-text error bodies must switch to JSON. Clients
112+
that branch only on HTTP status code are unaffected unless they were treating
113+
`UNSPECIFIED_CRASH` as a `500`.
114+
92115
=== Accept Header Routing Removed
93116
94117
The `/tika` endpoint no longer routes based on `Accept` headers. Use explicit paths instead:

docs/modules/ROOT/pages/using-tika/server/index.adoc

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,45 @@ curl -T document.pdf http://localhost:9998/meta/Content-Type # single field
156156
* `/translate/all/\{translator}/\{src}/\{dest}` — translation
157157
* `/pipes`, `/async` — Pipes-based bulk processing
158158

159+
== Error Responses
160+
161+
When parsing fails due to a process-level problem — the forked child process timed out,
162+
ran out of memory, or crashed unexpectedly — the server returns an HTTP error with a
163+
JSON body whose shape matches the `PipesResult` status:
164+
165+
[source,json]
166+
----
167+
{"status": "TIMEOUT"}
168+
----
169+
170+
The `status` field is the `PipesResult.RESULT_STATUS` enum name. By default the body
171+
carries only the `status`. When the server is configured with `returnStackTrace=true`,
172+
a `message` field is also included (it often contains a server-side stack trace), e.g.
173+
`{"status": "TIMEOUT", "message": "Task timed out after 60000ms"}`.
174+
175+
[cols="1,1,3"]
176+
|===
177+
|HTTP status |`status` values |Meaning
178+
179+
|`503 Service Unavailable`
180+
|`TIMEOUT`, `OOM`, `UNSPECIFIED_CRASH`, `CLIENT_UNAVAILABLE_WITHIN_MS`
181+
|The forked parse process failed, or no parse client became available within the
182+
configured wait time (`CLIENT_UNAVAILABLE_WITHIN_MS`). The server is still healthy;
183+
the client may retry.
184+
185+
|`500 Internal Server Error`
186+
|`FAILED_TO_INITIALIZE`, `FETCH_EXCEPTION`, `EMIT_EXCEPTION`,
187+
`FETCHER_NOT_FOUND`, `EMITTER_NOT_FOUND`,
188+
`FETCHER_INITIALIZATION_EXCEPTION`, `EMITTER_INITIALIZATION_EXCEPTION`
189+
|Server misconfiguration or a task-level infrastructure error. Retrying the same
190+
document on the same server is unlikely to succeed without a configuration fix.
191+
|===
192+
193+
NOTE: A successful parse that encountered internal parser errors (e.g. a truncated
194+
embedded document) still returns `200 OK`. The partial-parse exception is surfaced
195+
in the `X-TIKA:EXCEPTION:container_exception` metadata field of the response, not as an
196+
HTTP error code.
197+
159198
== Configuration
160199

161200
Server behavior beyond host/port is controlled by a JSON config file passed via

tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ private static PipesParsingHelper initPipesParsingHelper(TikaServerConfig tikaSe
510510

511511
// Create and return the helper
512512
PipesParsingHelper helper = new PipesParsingHelper(pipesParser, pipesConfig,
513-
inputTempDirectory, unpackTempDirectory);
513+
inputTempDirectory, unpackTempDirectory, tikaServerConfig.isReturnStackTrace());
514514

515515
// Register shutdown hook to clean up PipesParser and temp directories
516516
final Path inputDirToClean = inputTempDirectory;

tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@
2424
import java.util.List;
2525
import java.util.UUID;
2626

27+
import com.fasterxml.jackson.databind.ObjectMapper;
28+
import com.fasterxml.jackson.databind.node.ObjectNode;
2729
import jakarta.ws.rs.WebApplicationException;
30+
import jakarta.ws.rs.core.MediaType;
2831
import jakarta.ws.rs.core.Response;
2932
import org.slf4j.Logger;
3033
import org.slf4j.LoggerFactory;
@@ -67,6 +70,7 @@ public class PipesParsingHelper {
6770
private final PipesConfig pipesConfig;
6871
private final Path inputTempDirectory;
6972
private final Path unpackEmitterBasePath;
73+
private final boolean returnStackTrace;
7074

7175
/**
7276
* Creates a PipesParsingHelper.
@@ -78,13 +82,19 @@ public class PipesParsingHelper {
7882
* @param unpackEmitterBasePath the basePath where the unpack-emitter writes files.
7983
* This is where the server will find the zip files created
8084
* by UNPACK mode. May be null if UNPACK mode won't be used.
85+
* @param returnStackTrace whether failure responses may include the (potentially
86+
* stack-trace-bearing) {@code PipesResult} message. When false
87+
* (the default), error bodies carry only the status. Mirrors
88+
* {@code TikaServerConfig.isReturnStackTrace()}.
8189
*/
8290
public PipesParsingHelper(PipesParser pipesParser, PipesConfig pipesConfig,
83-
Path inputTempDirectory, Path unpackEmitterBasePath) {
91+
Path inputTempDirectory, Path unpackEmitterBasePath,
92+
boolean returnStackTrace) {
8493
this.pipesParser = pipesParser;
8594
this.pipesConfig = pipesConfig;
8695
this.inputTempDirectory = inputTempDirectory;
8796
this.unpackEmitterBasePath = unpackEmitterBasePath;
97+
this.returnStackTrace = returnStackTrace;
8898

8999
if (inputTempDirectory == null || !Files.isDirectory(inputTempDirectory)) {
90100
throw new IllegalArgumentException(
@@ -184,33 +194,60 @@ private String getSuffix(Metadata metadata) {
184194
return ".tmp";
185195
}
186196

197+
/**
198+
* Builds a JSON error response carrying a subset of the {@code PipesResult}
199+
* serialization. By default the body is just {@code {"status": "TIMEOUT"}}. The
200+
* {@code PipesResult} message frequently contains a server-side stack trace
201+
* (e.g. for {@code *_EXCEPTION} statuses), so the {@code message} field is included
202+
* only when {@code returnStackTrace} is enabled — matching the legacy
203+
* {@code TikaServerParseExceptionMapper}, which gates stack traces the same way.
204+
* Successful-parse fields such as {@code emitData} are never part of an error body.
205+
* <p>
206+
* This allows clients to distinguish failure modes (TIMEOUT, OOM, UNSPECIFIED_CRASH, …)
207+
* without parsing plain-text bodies or inspecting custom headers.
208+
*/
209+
private Response buildProcessFailureResponse(PipesResult result) {
210+
ObjectMapper mapper = new ObjectMapper();
211+
ObjectNode node = mapper.createObjectNode();
212+
node.put("status", result.status().name());
213+
if (returnStackTrace && result.message() != null && !result.message().isBlank()) {
214+
node.put("message", result.message());
215+
}
216+
String json;
217+
try {
218+
json = mapper.writeValueAsString(node);
219+
} catch (Exception e) {
220+
LOG.warn("Failed to serialize PipesResult error response as JSON; falling back to status-only body", e);
221+
json = "{\"status\":\"" + result.status().name() + "\"}";
222+
}
223+
return Response.status(mapStatusToHttpResponse(result.status()))
224+
.entity(json)
225+
.type(MediaType.APPLICATION_JSON)
226+
.build();
227+
}
228+
187229
/**
188230
* Processes the PipesResult and returns the metadata list.
189231
*/
190232
private List<Metadata> processResult(PipesResult result) {
191233
if (result.isProcessCrash()) {
192-
// Process crashed (OOM, timeout, etc.) - return 503
234+
// Process crashed (OOM, timeout, unspecified crash) — 503 with JSON status body
193235
LOG.warn("Parse process crashed: {}", result.status());
194-
throw new WebApplicationException(
195-
"Parse failed: " + result.status(),
196-
mapStatusToHttpResponse(result.status()));
236+
throw new WebApplicationException(buildProcessFailureResponse(result));
197237
}
198238

199239
if (result.isFatal() || result.isInitializationFailure()) {
200-
// Fatal or initialization error - return 500
240+
// Initialization/fatal error — JSON status body, HTTP status per mapStatusToHttpResponse
241+
// (500, or 503 for CLIENT_UNAVAILABLE_WITHIN_MS)
201242
LOG.error("Parse initialization/fatal error: {} - {}",
202243
result.status(), result.message());
203-
throw new WebApplicationException(
204-
"Parse failed: " + result.status(),
205-
mapStatusToHttpResponse(result.status()));
244+
throw new WebApplicationException(buildProcessFailureResponse(result));
206245
}
207246

208247
if (result.isTaskException()) {
209-
// Task-level exception (fetch/emit error) - return 500
248+
// Task-level exception (fetch/emit error) 500 with JSON status body
210249
LOG.warn("Parse task exception: {} - {}", result.status(), result.message());
211-
throw new WebApplicationException(
212-
"Parse failed: " + result.status(),
213-
Response.Status.INTERNAL_SERVER_ERROR);
250+
throw new WebApplicationException(buildProcessFailureResponse(result));
214251
}
215252

216253
// Get metadata from result
@@ -241,9 +278,9 @@ public static Response.Status mapStatusToHttpResponse(PipesResult.RESULT_STATUS
241278
EMIT_SUCCESS, EMIT_SUCCESS_PARSE_EXCEPTION, EMIT_SUCCESS_PASSBACK,
242279
PARSE_EXCEPTION_NO_EMIT ->
243280
Response.Status.OK;
244-
case TIMEOUT, OOM, CLIENT_UNAVAILABLE_WITHIN_MS ->
281+
case TIMEOUT, OOM, UNSPECIFIED_CRASH, CLIENT_UNAVAILABLE_WITHIN_MS ->
245282
Response.Status.SERVICE_UNAVAILABLE;
246-
case UNSPECIFIED_CRASH, FETCH_EXCEPTION, EMIT_EXCEPTION,
283+
case FETCH_EXCEPTION, EMIT_EXCEPTION,
247284
FETCHER_NOT_FOUND, EMITTER_NOT_FOUND,
248285
FETCHER_INITIALIZATION_EXCEPTION, EMITTER_INITIALIZATION_EXCEPTION,
249286
FAILED_TO_INITIALIZE ->
@@ -359,16 +396,12 @@ public UnpackResult parseUnpack(TikaInputStream tis, Metadata metadata,
359396
// Check for errors
360397
if (result.isProcessCrash() || result.isFatal() || result.isInitializationFailure()) {
361398
LOG.warn("UNPACK parse failed: {} - {}", result.status(), result.message());
362-
throw new WebApplicationException(
363-
"Parse failed: " + result.status(),
364-
mapStatusToHttpResponse(result.status()));
399+
throw new WebApplicationException(buildProcessFailureResponse(result));
365400
}
366401

367402
if (result.isTaskException()) {
368403
LOG.warn("UNPACK task exception: {} - {}", result.status(), result.message());
369-
throw new WebApplicationException(
370-
"Parse failed: " + result.message(),
371-
Response.Status.INTERNAL_SERVER_ERROR);
404+
throw new WebApplicationException(buildProcessFailureResponse(result));
372405
}
373406

374407
// Get metadata list from result

tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ public void setUp() throws Exception {
214214
pipesConfig.setEmitStrategy(new EmitStrategyConfig(EmitStrategy.PASSBACK_ALL));
215215
this.pipesParser = PipesParser.load(tikaJsonConfig, pipesConfig, this.pipesConfigPath);
216216
PipesParsingHelper pipesParsingHelper = new PipesParsingHelper(this.pipesParser, pipesConfig,
217-
inputTempDirectory, getUnpackEmitterBasePath());
217+
inputTempDirectory, getUnpackEmitterBasePath(), false);
218218

219219
TikaResource.init(tika, new ServerStatus(), pipesParsingHelper, isEnableUnsecureFeatures());
220220
} finally {

tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerIntegrationTest.java

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
import java.security.GeneralSecurityException;
3636
import java.util.List;
3737

38+
import com.fasterxml.jackson.databind.JsonNode;
39+
import com.fasterxml.jackson.databind.ObjectMapper;
3840
import jakarta.ws.rs.ProcessingException;
3941
import jakarta.ws.rs.core.Response;
4042
import org.apache.commons.io.IOUtils;
@@ -136,6 +138,7 @@ public void testOOM() throws Exception {
136138

137139
// Server should return 503 (Service Unavailable) for OOM, not crash
138140
assertEquals(503, response.getStatus());
141+
assertErrorResponseStatus(response, "OOM");
139142

140143
// Server should still be running - verify with a successful request
141144
testBaseline();
@@ -155,6 +158,7 @@ public void testOOMWithPipes() throws Exception {
155158

156159
// Server should return 503 (Service Unavailable) for OOM, not crash
157160
assertEquals(503, response.getStatus());
161+
assertErrorResponseStatus(response, "OOM");
158162

159163
// Server should still be running - verify with a successful request
160164
testBaseline();
@@ -172,8 +176,9 @@ public void testSystemExit() throws Exception {
172176
.accept("application/json")
173177
.put(ClassLoader.getSystemResourceAsStream(TEST_SYSTEM_EXIT));
174178

175-
// Server should return 500 (Internal Server Error) for unspecified crash
176-
assertEquals(500, response.getStatus());
179+
// UNSPECIFIED_CRASH is a transient process failure — 503, same category as OOM/TIMEOUT
180+
assertEquals(503, response.getStatus());
181+
assertErrorResponseStatus(response, "UNSPECIFIED_CRASH");
177182

178183
// Server should still be running - verify with a successful request
179184
testBaseline();
@@ -191,8 +196,9 @@ public void testSystemExitWithPipes() throws Exception {
191196
.accept("application/json")
192197
.put(ClassLoader.getSystemResourceAsStream(TEST_SYSTEM_EXIT));
193198

194-
// Server should return 500 (Internal Server Error) for unspecified crash
195-
assertEquals(500, response.getStatus());
199+
// UNSPECIFIED_CRASH is a transient process failure — 503, same category as OOM/TIMEOUT
200+
assertEquals(503, response.getStatus());
201+
assertErrorResponseStatus(response, "UNSPECIFIED_CRASH");
196202

197203
// Server should still be running - verify with a successful request
198204
testBaseline();
@@ -212,11 +218,25 @@ public void testTimeout() throws Exception {
212218

213219
// Server should return 503 (Service Unavailable) for timeout
214220
assertEquals(503, response.getStatus());
221+
assertErrorResponseStatus(response, "TIMEOUT");
215222

216223
// Server should still be running - verify with a successful request
217224
testBaseline();
218225
}
219226

227+
/**
228+
* Asserts that an error response body is JSON with a {@code status} field matching
229+
* {@code expectedStatus} (a {@code PipesResult.RESULT_STATUS} enum name).
230+
*/
231+
private void assertErrorResponseStatus(Response response, String expectedStatus) throws IOException {
232+
try (InputStream is = (InputStream) response.getEntity()) {
233+
String body = IOUtils.toString(is, UTF_8);
234+
JsonNode node = new ObjectMapper().readTree(body);
235+
assertEquals(expectedStatus, node.path("status").asText(null),
236+
"Expected JSON error body with status=" + expectedStatus + " but got: " + body);
237+
}
238+
}
239+
220240

221241
private String getConfig(String configName) {
222242
try {

0 commit comments

Comments
 (0)