Spring Boot Integration
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Baseline date: 2026-10-13. This page was written against Apache Tika 4.1.0 (tika-server and the libraries) with the
3.3.x differences noted, Java 21 and later (Tika 4.x needs Java 17), Spring Boot 4.1.1 (the latest GA release on Maven
Central on that date) on Spring Framework 7.0.9, and the Docker image apache/tika:4.1.0-1.
There is no official Spring Boot starter for Apache Tika, and no Spring-maintained integration either: neither the Tika
project nor Spring Boot ships auto-configuration, @ConfigurationProperties or a health indicator for Tika. Every piece
of wiring on this page is ordinary application code that you own. Spring AI’s TikaDocumentReader and LangChain4j’s
ApacheTikaDocumentParser exist, but they are document readers for RAG pipelines, not Spring Boot integrations, and in
their current releases they are built on Tika 3.x (see Spring AI and LangChain4j readers: Tika 3.x only).
The page builds one service, the Bookshelf intake service, in two architectures that share the same controller, upload gate and persistence:
(a) Call tika-server. The Spring Boot service validates the upload, keeps the original and sends the bytes to a
separate tika-server over HTTP with RestClient. Parsing never runs in the service’s JVM. This is the default
recommendation.
(b) Embed with PipesForkParser. The service parses in-process through a PipesForkParser bean, which runs every
document in a forked JVM. No second service to deploy, but the forks live inside your container and share its memory.
Related pages: the HTTP API is in tika-server REST API, the fork
model in Tika Pipes and
Robustness, Limits & Timeouts, tika-config.json in
Configuration, the upload controls in
Security and the container in
Docker & Kubernetes Deployment. Generic Spring material is
not repeated here: controllers and ProblemDetail are in Spring Boot REST APIs,
storing the originals in an object store is in
File Storage & Object Stores and Testcontainers itself is in
Testcontainers Fundamentals.
What was run. The complete project below was built with Maven against Spring Boot 4.1.1 and Tika 4.1.0 and run on
Java 25 (compiled with --release 21). Architecture (a) was run against a local tika-server-standard-4.1.0 bound to
127.0.0.1, architecture (b) with the forks on the same machine. The 429, 503, 413 and 415 paths were provoked
deliberately and the outputs below are from those runs (UUIDs differ on every run). The MockRestServiceServer tests
passed; the Testcontainers test compiled but was skipped because no Docker daemon was available, and the same
assertions were run instead against the local 4.1.0 server. See What was verified and what was not.
Choosing an architecture
| Concern | (a) Spring Boot calls tika-server | (b) PipesForkParser bean in the service |
|---|---|---|
Dependencies in the service |
|
|
Where parsing runs |
Another container, scaled and restarted independently |
Forked JVMs started by the service, inside its container |
Crash isolation |
Process and container boundary |
Process boundary; the parent survives an OOM or a hang in a fork |
Memory to budget |
Service heap only |
Service heap + |
Start-up |
About 3 s in the run below |
About 18 s: the parent loads the parsers to validate the configuration, then the first fork takes another 10 s or so |
Back-pressure signal |
HTTP |
|
Version upgrades |
Change an image tag |
Rebuild and redeploy the service |
Packaging trap |
None |
Forks reuse |
Testing |
|
Real forks in the test JVM; slow but no Docker needed |
Good for |
Most services, several services sharing one extraction tier, Kubernetes |
A single self-contained service, batch jobs, places where a second service is not an option |
In-process parsing with AutoDetectParser directly in a request thread is deliberately absent from the table. A
hostile or merely broken document can exhaust the heap or loop forever and take the whole web application down with it
(Security explains why Tika is not a security boundary). Use it only for trusted
input, and even then prefer one of the two options above.
The request flow
The flow is the same in both architectures; only step 3 changes (an HTTP call versus a call into the fork pool).
Three rules drive the design:
-
Validate before parsing. The gate uses only
tika-coredetection on the bytes, in-process and in milliseconds. A renamed file, an absurd size or a forbidden type never reaches a parser. -
Keep the original before parsing. If the parse fails, the bytes are already stored under a generated name and can be re-processed later (after an upgrade, with a larger heap) from the dead-letter queue.
-
Tell the two failure classes apart.
429means Tika is busy and the document is fine: retry.503means a fork died on this document: do not loop on it, park it.
Project setup
Dependencies for architecture (a)
Spring Boot 4 split the web starter: spring-boot-starter-webmvc brings Spring MVC and Tomcat, and
spring-boot-starter-restclient brings the auto-configured RestClient.Builder and the spring.http.clients.*
properties. Tika contributes only tika-core, imported through tika-bom.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<tika.version>4.1.0</tika.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-bom</artifactId>
<version>${tika.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- upload gate: byte-level detection only, no parsers -->
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
plugins {
java
id("org.springframework.boot") version "4.1.1"
id("io.spring.dependency-management") version "1.1.7"
}
java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
dependencies {
implementation(platform("org.apache.tika:tika-bom:4.1.0"))
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.boot:spring-boot-starter-restclient")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.apache.tika:tika-core")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:testcontainers-junit-jupiter")
}
Spring Boot 4.1.1 manages Testcontainers 2.0.5, whose JUnit 5 artifact is testcontainers-junit-jupiter (the 1.x name
junit-jupiter is gone; see Testcontainers Fundamentals). The Maven
build was run; the Gradle file was not, and the dependency-management plugin version in it is an assumption to check
against the Spring Boot Gradle plugin documentation.
Additional dependencies for architecture (b)
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-pipes-fork-parser</artifactId>
<exclusions>
<!-- Spring Boot logs through Logback; keep a single SLF4J provider -->
<exclusion>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j2-impl</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parsers-standard-package</artifactId>
<type>pom</type>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-serialization</artifactId>
</dependency>
dependencies {
implementation("org.apache.tika:tika-pipes-fork-parser") {
exclude(group = "org.apache.logging.log4j", module = "log4j-slf4j2-impl")
exclude(group = "org.apache.logging.log4j", module = "log4j-core")
}
implementation("org.apache.tika:tika-parsers-standard-package")
implementation("org.apache.tika:tika-serialization")
}
The exclusions matter. mvn dependency:tree showed that tika-pipes-fork-parser 4.1.0 brings log4j-core and
log4j-slf4j2-impl as compile dependencies, while spring-boot-starter-logging brings Logback and log4j-to-slf4j.
Two SLF4J providers, plus a Log4j API routed to SLF4J and back, is the classic logging loop. With the exclusions, the
parent logs through Logback as usual and the forks (which inherit the class path) fall back to Logback’s default
console format: their lines look like 09:56:13.339 [main] INFO org.apache.tika.pipes.core.server.PipesServer -- ...
in the service’s stdout, which is a convenient way to tell them apart. Logging setup in general is in
Setup & Installation.
The project used for this page carried both sets of dependencies and switched with a property; in production pick one architecture and keep only its dependencies.
Configuration properties
Nothing binds bookshelf.tika.* for you, so the service declares its own record:
package org.bookshelf.intake;
import java.net.URI;
import java.nio.file.Path;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/** Everything the intake service needs to know about Tika, bound from bookshelf.tika.* */
@ConfigurationProperties("bookshelf.tika")
public record TikaProperties(
@DefaultValue("server") Mode mode,
@DefaultValue("http://127.0.0.1:9998") URI baseUrl,
@DefaultValue("4") int maxAttempts,
@DefaultValue("30s") Duration maxRetryWait,
Path configFile,
@DefaultValue("-Xmx512m") String forkHeap,
@DefaultValue("2") int numClients,
@DefaultValue("60s") Duration parseTimeout) {
public enum Mode { SERVER, EMBEDDED }
}
The application class enables scanning with @ConfigurationPropertiesScan:
package org.bookshelf.intake;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class BookshelfIntakeApplication {
public static void main(String[] args) {
SpringApplication.run(BookshelfIntakeApplication.class, args);
}
}
And application.yaml:
spring:
application:
name: bookshelf-intake
servlet:
multipart:
max-file-size: 20MB
max-request-size: 21MB
http:
clients:
imperative:
factory: jdk
connect-timeout: 5s
read-timeout: 5m
threads:
virtual:
enabled: true
bookshelf:
tika:
mode: server
base-url: ${TIKA_URL:http://127.0.0.1:9998}
max-attempts: 4
max-retry-wait: 30s
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
show-details: always
| Property | Value | Why |
|---|---|---|
|
|
The first size limit, enforced by the servlet container while the body is read; exceeding it raises |
|
|
Pin the client to the JDK |
|
|
Fail fast when tika-server is down. |
|
|
Must exceed the slowest legitimate parse plus the time a request may wait for a fork on the server ( |
|
|
Request threads that wait for tika-server (and sleep for |
|
|
Attempts after |
|
|
Cap on a single |
The spring.http.clients.* names are the Spring Boot 4.1 ones, read from the spring-boot-http-client 4.1.1
configuration metadata; Boot 3.x used spring.http.client.* (singular), which 4.1 still lists as deprecated aliases.
The shared port: TextExtractor
Both architectures implement one interface, so the controller, the service and the tests do not know which one runs:
package org.bookshelf.intake;
import java.nio.file.Path;
/** One port, two adapters: tika-server over HTTP, or PipesForkParser in-process. */
public interface TextExtractor {
/**
* @throws TikaBusyException every attempt found Tika busy: retry later, the document is fine
* @throws DocumentFailedException Tika crashed, ran out of memory or timed out on this document
*/
Extraction extract(Path file, String fileName);
}
package org.bookshelf.intake;
import java.util.List;
/** What the intake service keeps from one parse: the container plus all embedded documents. */
public record Extraction(
String contentType,
String title,
String content,
int documentCount,
List<String> warnings) {
}
The three exceptions carry the decisions of the flow diagram. TikaBusyException keeps the last Retry-After so it can
be passed on to the caller; DocumentFailedException keeps the Tika status (TIMEOUT, OOM, UNSPECIFIED_CRASH) for the
dead-letter record:
package org.bookshelf.intake;
import java.time.Duration;
/** Back-pressure from Tika (HTTP 429): nothing is wrong with the document. */
public class TikaBusyException extends RuntimeException {
private final Duration retryAfter;
public TikaBusyException(Duration retryAfter) {
super("Tika is busy, retry after " + retryAfter.toSeconds() + "s");
this.retryAfter = retryAfter;
}
public Duration retryAfter() {
return retryAfter;
}
}
package org.bookshelf.intake;
/** Tika failed on this document (HTTP 503 or a crashed fork): quarantine it, do not loop on it. */
public class DocumentFailedException extends RuntimeException {
private final String status;
public DocumentFailedException(String status, String message) {
super(status + ": " + message);
this.status = status;
}
public String status() {
return status;
}
}
package org.bookshelf.intake;
public class UploadRejectedException extends RuntimeException {
public UploadRejectedException(String reason) {
super(reason);
}
}
Content-type validation with tika-core
The upload gate is the in-process part that both architectures share. It applies the OWASP order (name, extension
allow-list, type detected from the bytes) and uses a single Detector bean:
package org.bookshelf.intake;
import org.apache.tika.detect.DefaultDetector;
import org.apache.tika.detect.Detector;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class TikaDetectionConfiguration {
/**
* The MIME magic and glob database from tika-core plus every Detector found
* through SPI on the class path (container detectors come with parser modules).
* Thread-safe: create once, share.
*/
@Bean
Detector tikaDetector() {
return new DefaultDetector();
}
}
package org.bookshelf.intake;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.apache.tika.detect.Detector;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
import org.springframework.stereotype.Component;
/** Cheap, in-process checks before any parser sees the upload. */
@Component
public class UploadGate {
private static final Pattern SAFE_NAME = Pattern.compile("[\\p{L}\\p{N} ._-]{1,120}");
/** Extension allow-list, each with the media types its bytes may have. */
private static final Map<String, Set<String>> ALLOWED = Map.of(
"pdf", Set.of("application/pdf"),
// with tika-core alone a DOCX is application/x-tika-ooxml; the exact subtype needs
// the OOXML container detector from tika-parser-microsoft-module
"docx", Set.of("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/x-tika-ooxml"),
"epub", Set.of("application/epub+zip"),
"jpg", Set.of("image/jpeg"),
"png", Set.of("image/png"),
"txt", Set.of("text/plain"),
"eml", Set.of("message/rfc822"));
private final Detector detector;
public UploadGate(Detector detector) {
this.detector = detector;
}
/** Returns the detected media type, or throws UploadRejectedException. */
public String check(Path upload, String clientName) {
if (clientName == null || !SAFE_NAME.matcher(clientName).matches() || clientName.startsWith(".")) {
throw new UploadRejectedException("file name rejected");
}
int dot = clientName.lastIndexOf('.');
String ext = dot < 0 ? "" : clientName.substring(dot + 1).toLowerCase(Locale.ROOT);
Set<String> expected = ALLOWED.get(ext);
if (expected == null) {
throw new UploadRejectedException("extension '" + ext + "' is not accepted");
}
MediaType detected;
// bytes only: no file name and no client Content-Type in the Metadata
try (TikaInputStream tis = TikaInputStream.get(upload)) {
detected = detector.detect(tis, new Metadata(), new ParseContext());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
String base = detected.getBaseType().toString();
if (!expected.contains(base)) {
throw new UploadRejectedException("content is " + base + ", not ." + ext);
}
return base;
}
}
Two things here come from running it, not from the documentation.
With tika-core alone, a DOCX is application/x-tika-ooxml. DefaultDetector combines the MIME magic of
tika-mimetypes.xml with every Detector found through SPI. In tika-core the magic recognises an OOXML package
(a ZIP whose first entry is [Content_Types].xml) but not which one, and the container detector that opens the package
and reads the main part (OPCPackageDetector) lives in tika-parser-microsoft-module. A small program with only
tika-core, slf4j-api and commons-io on the class path printed:
manuscript.docx -> application/x-tika-ooxml
novel.epub -> application/epub+zip
catalogue.pdf -> application/pdf
publisher-email.eml -> message/rfc822
book-cover.jpg -> image/jpeg
legacy-catalogue.txt -> text/plain
Adding tika-parser-zip-commons did not change the DOCX result (its ZIP container detectors cover other formats). With
the full tika-parsers-standard-package on the class path, as in architecture (b), the same file was detected as
application/vnd.openxmlformats-officedocument.wordprocessingml.document. The gate therefore accepts both for .docx.
The cost is that an .xlsx renamed to .docx also passes the gate in architecture (a); the parse on tika-server then
reports the real type in Content-Type, and you can compare it there, or call tika-server’s /detect endpoint as a
second step if the exact subtype must be known before storing. Adding tika-parser-microsoft-module to the service would
pull Apache POI back into it, which defeats the point of architecture (a).
Detection gets no hints. The Metadata passed to detect is empty: no file name and no client Content-Type. With a
name, the glob can win for ambiguous content; the gate is about what the bytes are. A zero-byte file is detected as
application/octet-stream and is therefore rejected as "not .txt"; add an explicit size check if you want a clearer
message.
DefaultDetector is thread-safe and expensive enough to build (it loads the MIME registry) that it should be a
singleton. In architecture (b) you could take the detectors from the TikaLoader bean instead
(loadDetectors()), which honours a detectors section in your tika-config.json.
Source: OWASP File Upload Cheat Sheet and
DefaultDetector, Javadoc 4.1.0. The
detection mechanics are in Media Types & Detection and the full
OWASP mapping in Security: Upload validation per OWASP.
Upload endpoints: MultipartFile and a streamed body
The controller offers the two upload styles a document intake usually needs:
-
POST /documentswithmultipart/form-dataand afilepart, for browsers and form posts. Tomcat writes the part to a temporary file while reading the request (spring.servlet.multipart.file-size-thresholddefaults to 0, so nothing stays in memory), andMultipartFile.transferTo(Path)moves or copies it. -
PUT /documents/{fileName}with the raw bytes as the body, for scripts and other services. Spring MVC hands the body over as anInputStream; the controller copies it to a temporary file with its own size cap, because the multipart limits do not apply to a raw body.
package org.bookshelf.intake;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.bookshelf.intake.IntakeService.IntakeResult;
import org.bookshelf.intake.IntakeService.Status;
import org.bookshelf.intake.IntakeStores.DeadLetter;
import org.bookshelf.intake.IntakeStores.DeadLetterQueue;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.unit.DataSize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/documents")
public class IntakeController {
private final IntakeService intake;
private final DeadLetterQueue deadLetters;
private final long maxBytes;
public IntakeController(IntakeService intake, DeadLetterQueue deadLetters,
@Value("${spring.servlet.multipart.max-file-size}") DataSize maxFileSize) {
this.intake = intake;
this.deadLetters = deadLetters;
this.maxBytes = maxFileSize.toBytes();
}
/** Browser-style upload: multipart/form-data with a "file" part. */
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<IntakeResult> upload(@RequestPart("file") MultipartFile file) throws IOException {
Path tmp = Files.createTempFile("bookshelf-", ".upload");
try {
file.transferTo(tmp); // moves the container's temp file when it can
return respond(intake.intake(tmp, file.getOriginalFilename()));
} finally {
Files.deleteIfExists(tmp);
}
}
/** Raw body upload: PUT /documents/manuscript.docx, streamed to disk with a size cap. */
@PutMapping(path = "/{fileName}")
public ResponseEntity<IntakeResult> put(@PathVariable String fileName, InputStream body) throws IOException {
Path tmp = Files.createTempFile("bookshelf-", ".upload");
try {
copyAtMost(body, tmp, maxBytes);
return respond(intake.intake(tmp, fileName));
} finally {
Files.deleteIfExists(tmp);
}
}
@GetMapping("/dead-letters")
public List<DeadLetter> deadLetters() {
return deadLetters.all();
}
private static ResponseEntity<IntakeResult> respond(IntakeResult result) {
HttpStatus status = result.status() == Status.INDEXED ? HttpStatus.CREATED : HttpStatus.ACCEPTED;
return ResponseEntity.status(status).body(result);
}
private static void copyAtMost(InputStream in, Path target, long max) throws IOException {
byte[] buffer = new byte[64 * 1024];
long total = 0;
try (OutputStream out = Files.newOutputStream(target)) {
for (int n; (n = in.read(buffer)) != -1; ) {
total += n;
if (total > max) {
throw new UploadTooLargeException(max);
}
out.write(buffer, 0, n);
}
}
}
public static class UploadTooLargeException extends RuntimeException {
public UploadTooLargeException(long max) {
super("upload larger than " + max + " bytes");
}
}
}
Why spool to a temporary file instead of piping the request body straight into tika-server:
-
the gate reads the first bytes for detection, and the extractor reads the whole file again;
-
a
429retry needs the bytes a second time, which a consumed request stream cannot give; -
the original has to be kept anyway (see Persistence, index and dead letters).
A pure pass-through proxy (request body in, tika.put().body(out -> request.getInputStream().transferTo(out)) out) is
possible and saves the disk write, but it gives up validation, retries and the stored original. It suits an internal
endpoint whose caller does its own retries, not an intake service.
The orchestration lives in a service:
package org.bookshelf.intake;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.bookshelf.intake.IntakeStores.DeadLetter;
import org.bookshelf.intake.IntakeStores.DeadLetterQueue;
import org.bookshelf.intake.IntakeStores.DocumentRepository;
import org.bookshelf.intake.IntakeStores.OriginalStore;
import org.bookshelf.intake.IntakeStores.SearchIndexer;
import org.bookshelf.intake.IntakeStores.StoredDocument;
import org.springframework.stereotype.Service;
@Service
public class IntakeService {
public enum Status { INDEXED, QUARANTINED }
public record IntakeResult(String id, Status status, String detectedType, String title,
int documents, int characters, List<String> warnings) { }
private final UploadGate gate;
private final TextExtractor extractor;
private final OriginalStore originals;
private final DocumentRepository repository;
private final SearchIndexer indexer;
private final DeadLetterQueue deadLetters;
public IntakeService(UploadGate gate, TextExtractor extractor, OriginalStore originals,
DocumentRepository repository, SearchIndexer indexer, DeadLetterQueue deadLetters) {
this.gate = gate;
this.extractor = extractor;
this.originals = originals;
this.repository = repository;
this.indexer = indexer;
this.deadLetters = deadLetters;
}
/** upload is a temporary file the caller owns; it is moved away on success. */
public IntakeResult intake(Path upload, String clientName) {
String detected = gate.check(upload, clientName); // 1. validate, in-process
String id = UUID.randomUUID().toString();
String ext = clientName.substring(clientName.lastIndexOf('.') + 1).toLowerCase();
Path original = originals.keep(upload, id, ext); // 2. keep the original
try {
Extraction extraction = extractor.extract(original, clientName); // 3. parse, out of process
StoredDocument doc = new StoredDocument(id, clientName, detected, extraction, Instant.now());
repository.save(doc); // 4. persist
indexer.index(doc); // 5. index
return new IntakeResult(id, Status.INDEXED, detected, extraction.title(),
extraction.documentCount(), extraction.content().length(), extraction.warnings());
} catch (DocumentFailedException e) {
deadLetters.park(new DeadLetter(id, clientName, e.status(), Instant.now()));
return new IntakeResult(id, Status.QUARANTINED, detected, null, 0, 0, List.of(e.status()));
}
// TikaBusyException propagates: the client is told to come back later
}
}
And the error mapping in a @RestControllerAdvice that answers with RFC 9457 ProblemDetail bodies:
package org.bookshelf.intake;
import org.bookshelf.intake.IntakeController.UploadTooLargeException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
@RestControllerAdvice
public class IntakeExceptionHandler {
@ExceptionHandler(UploadRejectedException.class)
ProblemDetail rejected(UploadRejectedException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.UNSUPPORTED_MEDIA_TYPE, e.getMessage());
}
@ExceptionHandler({MaxUploadSizeExceededException.class, UploadTooLargeException.class})
ProblemDetail tooLarge(Exception e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONTENT_TOO_LARGE, e.getMessage());
}
/** Pass tika-server's back-pressure on to our own client instead of queueing in memory. */
@ExceptionHandler(TikaBusyException.class)
ResponseEntity<ProblemDetail> busy(TikaBusyException e) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(Math.max(1, e.retryAfter().toSeconds())))
.body(ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE, e.getMessage()));
}
/** Connection refused or read timeout: tika-server is down or too slow. */
@ExceptionHandler(ResourceAccessException.class)
ProblemDetail unreachable(ResourceAccessException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_GATEWAY, "text extraction unavailable");
}
}
| Situation | Response of the intake service | Reason |
|---|---|---|
Indexed |
|
|
Tika failed on the document (dead-lettered) |
|
The upload itself succeeded and the original is kept; processing is deferred. |
Name, extension or detected type not allowed |
|
|
Body over the limit |
|
|
tika-server still busy after |
|
Back-pressure is passed on instead of being absorbed in a queue in memory. |
tika-server unreachable or read timeout |
|
Source: Multipart Resolver,
Spring Framework reference and
Error Responses, Spring
Framework reference. Controller and ProblemDetail basics: Spring Boot REST APIs.
Architecture (a): calling tika-server with RestClient
The client
package org.bookshelf.intake;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
/** Architecture (a): the service sends the bytes to tika-server and reads /rmeta/markdown. */
@Component
@ConditionalOnProperty(name = "bookshelf.tika.mode", havingValue = "server", matchIfMissing = true)
public class TikaServerExtractor implements TextExtractor {
private static final Logger log = LoggerFactory.getLogger(TikaServerExtractor.class);
private static final ParameterizedTypeReference<List<Map<String, Object>>> RMETA =
new ParameterizedTypeReference<>() { };
private static final ParameterizedTypeReference<Map<String, Object>> FAILURE =
new ParameterizedTypeReference<>() { };
private final RestClient tika;
private final TikaProperties properties;
public TikaServerExtractor(RestClient.Builder builder, TikaProperties properties) {
// timeouts come from spring.http.clients.* (see application.yaml)
this.tika = builder.baseUrl(properties.baseUrl().toString()).build();
this.properties = properties;
}
@Override
public Extraction extract(Path file, String fileName) {
Duration retryAfter = Duration.ZERO;
for (int attempt = 1; attempt <= properties.maxAttempts(); attempt++) {
if (!retryAfter.isZero()) {
sleep(retryAfter);
}
Outcome outcome = putOnce(file, fileName);
if (outcome.extraction() != null) {
return outcome.extraction();
}
retryAfter = min(outcome.retryAfter(), properties.maxRetryWait());
log.info("tika-server busy for {} (attempt {} of {}), Retry-After {}s",
fileName, attempt, properties.maxAttempts(), retryAfter.toSeconds());
}
throw new TikaBusyException(retryAfter);
}
private record Outcome(Extraction extraction, Duration retryAfter) { }
private Outcome putOnce(Path file, String fileName) {
long length = size(file);
return tika.put()
.uri("/rmeta/markdown")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(length)
// the file name is a detection hint; it has already been validated by the UploadGate
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(fileName).build().toString())
.body(out -> Files.copy(file, out)) // streamed from disk, never buffered
.exchange((request, response) -> {
int status = response.getStatusCode().value();
return switch (status) {
case 200 -> new Outcome(RmetaMapper.toExtraction(response.bodyTo(RMETA)), null);
case 429 -> new Outcome(null, retryAfter(response.getHeaders()));
case 503 -> {
// {"status":"TIMEOUT"|"OOM"|"UNSPECIFIED_CRASH","message":...}
Map<String, Object> failure = response.bodyTo(FAILURE);
throw new DocumentFailedException(String.valueOf(failure.get("status")),
"tika-server fork failed on " + fileName);
}
default -> throw new IllegalStateException(
"tika-server answered " + status + ": " + response.bodyTo(String.class));
};
});
}
private static Duration retryAfter(HttpHeaders headers) {
String value = headers.getFirst(HttpHeaders.RETRY_AFTER);
try {
return Duration.ofSeconds(value == null ? 2 : Long.parseLong(value.trim()));
} catch (NumberFormatException httpDate) {
return Duration.ofSeconds(2);
}
}
private static Duration min(Duration a, Duration b) {
return a.compareTo(b) <= 0 ? a : b;
}
private static long size(Path file) {
try {
return Files.size(file);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static void sleep(Duration d) {
try {
Thread.sleep(d);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted while waiting for tika-server", e);
}
}
}
Notes on the choices in this class:
-
/rmeta/markdown. One JSON object per document, container first, each with its owntk:content. Embedded documents (the attachments ofpublisher-email.eml, the entries ofsamples.zip) come back separately, a per-document exception stays inside its object and the request still answers200. Markdown keeps headings and lists for chunking. The endpoint catalogue is in tika-server REST API. -
PUTwith the raw bytes, not multipart. tika-server accepts both;PUTavoids building a multipart body and lets the file name travel inContent-Dispositionas a detection hint. The name was validated by the gate first, so it cannot inject header characters. -
Streaming.
body(StreamingHttpOutputMessage.Body)writes the file straight into the request; with the JDK client (JdkClientHttpRequestextendsAbstractStreamingClientHttpRequestin Spring Framework 7.0.9) the bytes are not buffered in the heap, and the explicitcontentLengthsends a fixed-length body instead of chunked transfer encoding. -
exchangeinstead ofretrieve.exchangehands over the raw response, so the status decides the outcome without exceptions being thrown and caught for the normal429path.response.bodyTo(...)uses the message converters of the builder (Jackson 3 in Spring Boot 4). -
Multi-valued metadata. tika-server writes a single value as a JSON string and several values as an array (
tk:parsed-bywas an array for the DOCX and a string for an unknown binary), so the mapper below reads both. -
429is retried, honouringRetry-After, capped bymax-retry-wait. In 4.1.0 the header is the server’smaxWaitForClientMillisin seconds. Each attempt holds the request thread for up to that wait on the server plus the sleep on the client, so four attempts with a 2-second wait took 14 seconds in the run below; with virtual threads that is cheap, with platform threads it ties up Tomcat’s pool. -
503is never retried in the request thread. The fork already died once on this document; a second attempt would likely kill the next fork too. The document is parked, and a background job can retry it later with more memory or a longer timeout. -
Everything else throws. A
400or413from tika-server is a bug or a misconfiguration in the calling service, not a property of the document.
Spring Framework 7 also ships retry support in the core (RetryTemplate in org.springframework.core.retry and
@Retryable enabled with @EnableResilientMethods). Its back-off policies are fixed or exponential delays; they do not
read Retry-After, which is why the loop above is hand-written. @ConcurrencyLimit from the same package is a simple way
to stop a single instance from sending more concurrent requests than the server has forks.
package org.bookshelf.intake;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/** Turns the /rmeta JSON list (container first, embedded documents after) into an Extraction. */
final class RmetaMapper {
private RmetaMapper() {
}
static Extraction toExtraction(List<Map<String, Object>> documents) {
if (documents == null || documents.isEmpty()) {
return new Extraction("application/octet-stream", null, "", 0, List.of());
}
Map<String, Object> container = documents.getFirst();
List<String> warnings = new ArrayList<>();
for (Map<String, Object> doc : documents) {
String path = Objects.requireNonNullElse(first(doc.get("tk:embedded-resource-path")), "(container)");
if (doc.containsKey("tk:exception:container-exception")) {
warnings.add(path + ": container exception");
}
if (doc.containsKey("tk:exception:embedded-exception")) {
warnings.add(path + ": embedded exception");
}
if (doc.containsKey("tk:exception:task-deadline-reached")) {
warnings.add(path + ": deadline reached, content incomplete");
}
}
String content = documents.stream()
.map(d -> first(d.get("tk:content")))
.filter(Objects::nonNull)
.map(String::strip)
.filter(s -> !s.isEmpty())
.collect(Collectors.joining("\n\n"));
return new Extraction(first(container.get("Content-Type")), first(container.get("dc:title")),
content, documents.size(), List.copyOf(warnings));
}
/** Tika writes a single value as a string and several values as an array. */
static String first(Object value) {
if (value instanceof Collection<?> c) {
return c.isEmpty() ? null : String.valueOf(c.iterator().next());
}
return value == null ? null : value.toString();
}
}
The tika-server side
The server used for the runs was the 4.1.0 zip distribution, bound to the loopback interface with one fork, so that it was easy to make busy:
{
"server": {
"host": "127.0.0.1",
"port": 9998,
"endpoints": ["tika", "rmeta", "detect", "version"]
},
"pipes": {
"numClients": 1,
"maxWaitForClientMillis": 2000,
"forkedJvmArgs": ["-Xmx512m"]
}
}
java -jar tika-server-standard-4.1.0.jar -c tika-config.json
tika-server 4.1.0 refuses to start without the server section: a file containing only {"pipes":{"numClients":1}}
failed with TikaConfigException: Couldn't find 'server' element. In Docker the image passes -h 0.0.0.0 itself; the
server must then be reachable only from the intake service (a private network, a Kubernetes NetworkPolicy), never
published to the internet, because tika-server has no authentication. See
Docker & Kubernetes Deployment and
Security.
Run: the happy path and the gate
The service was started against that server and the samples were sent with curl:
TIKA_URL=http://127.0.0.1:9998 java -jar target/bookshelf-intake-1.0.0-SNAPSHOT.jar
curl -F "file=@manuscript.docx" http://localhost:8080/documents
curl -F "file=@publisher-email.eml" http://localhost:8080/documents
curl -F "file=@renamed-invoice.png" http://localhost:8080/documents
curl -T scanned-loan-form.pdf http://localhost:8080/documents/scanned-loan-form.pdf
curl -T samples.zip http://localhost:8080/documents/samples.zip
{"id":"037cc55a-00f1-4fe0-b5d2-5967753854cf","status":"INDEXED","detectedType":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","title":"The Lighthouse Keeper","documents":1,"characters":75,"warnings":[]} [201]
{"id":"8e9b488d-2ae0-4f82-8c41-d96149718e42","status":"INDEXED","detectedType":"message/rfc822","title":"Spring submissions: samples attached","documents":6,"characters":490,"warnings":[]} [201]
{"detail":"content is application/pdf, not .png","instance":"/documents","status":415,"title":"Unsupported Media Type"} [415]
{"id":"2afb0e42-b8c4-4e4e-8bb3-61f6862c218c","status":"INDEXED","detectedType":"application/pdf","title":"Inter-library loan form","documents":1,"characters":130,"warnings":[]} [201]
{"detail":"extension 'zip' is not accepted","instance":"/documents/samples.zip","status":415,"title":"Unsupported Media Type"} [415]
(The trailing status code was printed with curl’s -w ' [%{http_code}]\n' option.) catalogue.pdf, novel.epub and
legacy-catalogue.txt were indexed in the same way. The e-mail counted six documents: the message, samples.zip
and the four files inside it. renamed-invoice.png never reached tika-server.
The size limits, with a 25 MB text file, once as multipart and once as a raw body:
{"detail":"Maximum upload size exceeded","instance":"/documents","status":413,"title":"Content Too Large"} [413]
{"detail":"upload larger than 20971520 bytes","instance":"/documents/big-catalogue.txt","status":413,"title":"Content Too Large"} [413]
Run: 429 becomes a retry
Three shell loops kept the single fork busy by sending a 90 MB text file straight to tika-server. An upload of
catalogue.pdf that arrived during a short burst was turned away once and then succeeded:
INFO o.bookshelf.intake.TikaServerExtractor : tika-server busy for catalogue.pdf (attempt 1 of 4), Retry-After 2s
{"id":"a1fe07fb-21c9-4c95-9bfe-f514c131c79c","status":"INDEXED","detectedType":"application/pdf","title":"Spring catalogue","documents":1,"characters":88,"warnings":[]} [201]
During a 14-second burst every attempt was refused, and the service passed the back-pressure on:
HTTP/1.1 503
Retry-After: 2
{"detail":"Tika is busy, retry after 2s","instance":"/documents","status":503,"title":"Service Unavailable"}
The request took 14.1 seconds: four attempts of about 2 seconds of server-side waiting each, plus three 2-second sleeps. A sustained rate of these is a capacity signal (more forks, more tika-server replicas), not something to retry harder.
Run: 503 becomes a dead letter
tika-server was restarted with a one-second budget (parse-context.timeout-limits with totalTaskTimeoutMillis and
progressTimeoutMillis both 1000, and exception-reporting set to MESSAGE_REDACTED) and the service with a
100 MB upload limit. The 90 MB text file timed out in the fork; the next upload was served by the replacement fork:
{"id":"3e0f9f62-708f-45f9-b097-e85b19d8c14e","status":"INDEXED","detectedType":"application/pdf","title":"Spring catalogue","documents":1,"characters":88,"warnings":[]} [201]
{"id":"0a10283f-9164-494e-b207-9d31f8adaeb4","status":"QUARANTINED","detectedType":"text/plain","title":null,"documents":0,"characters":0,"warnings":["TIMEOUT"]} [202]
{"id":"938fda78-9c62-4b99-a068-5aaca02aea42","status":"INDEXED","detectedType":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","title":"The Lighthouse Keeper","documents":1,"characters":75,"warnings":[]} [201]
curl http://localhost:8080/documents/dead-letters
[{"id":"0a10283f-9164-494e-b207-9d31f8adaeb4","fileName":"huge-catalogue.txt","reason":"TIMEOUT","at":"2026-10-13T07:54:11.013695Z"}]
PARTIAL_TIMEOUT is different: it answers 200 with what was extracted so far and tk:exception:task-deadline-reached
on the container, which RmetaMapper turns into a warning. Decide whether your index accepts incomplete documents; the
timeout model is in Robustness, Limits & Timeouts.
A health indicator for tika-server
package org.bookshelf.intake;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.health.contributor.AbstractHealthIndicator;
import org.springframework.boot.health.contributor.Health;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
/** Shows up as "tikaServer" under /actuator/health. */
@Component("tikaServer")
@ConditionalOnProperty(name = "bookshelf.tika.mode", havingValue = "server", matchIfMissing = true)
public class TikaServerHealthIndicator extends AbstractHealthIndicator {
private final RestClient tika;
public TikaServerHealthIndicator(RestClient.Builder builder, TikaProperties properties) {
this.tika = builder.baseUrl(properties.baseUrl().toString()).build();
}
@Override
protected void doHealthCheck(Health.Builder builder) {
String version = tika.get().uri("/version").retrieve().body(String.class);
builder.up().withDetail("version", version == null ? "" : version.strip());
}
}
In Spring Boot 4 the health API moved to the spring-boot-health module: HealthIndicator, AbstractHealthIndicator
and Health are in org.springframework.boot.health.contributor (Boot 3.x had them in
org.springframework.boot.actuate.health). The run showed it under /actuator/health:
{"details":{"version":"Apache Tika 4.1.0"},"status":"UP"}
/version is cheap and does not need a fork. Keep this indicator out of the liveness group: if tika-server is down,
restarting the intake service does not fix it. Adding it to readiness (with
management.endpoint.health.group.readiness.include=readinessState,tikaServer) stops traffic to an instance that cannot
extract, which may or may not be what you want when uploads could still be accepted and processed later.
Architecture (b): PipesForkParser as a Spring bean
The configuration class
package org.bookshelf.intake;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.apache.tika.config.TimeoutLimits;
import org.apache.tika.config.loader.TikaLoader;
import org.apache.tika.exception.TikaConfigException;
import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.pipes.api.ParseMode;
import org.apache.tika.pipes.fork.PipesForkParser;
import org.apache.tika.pipes.fork.PipesForkParserConfig;
import org.apache.tika.pipes.fork.PipesForkParserException;
import org.apache.tika.pipes.fork.PipesForkResult;
import org.apache.tika.sax.BasicContentHandlerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Architecture (b): parse inside this service, each document in a forked JVM. */
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(name = "bookshelf.tika.mode", havingValue = "embedded")
public class EmbeddedTikaConfiguration {
/** Validates tika-config.json at startup: a typo stops the service before the first upload. */
@Bean
TikaLoader tikaLoader(TikaProperties properties) throws Exception {
if (properties.configFile() == null) {
return TikaLoader.loadDefault();
}
TikaLoader loader = TikaLoader.load(properties.configFile());
loader.loadAutoDetectParser();
loader.loadParseContext();
loader.loadMetadataFilters();
return loader;
}
/** One instance for the whole application: it owns a pool of forked JVMs. */
@Bean(destroyMethod = "close")
PipesForkParser pipesForkParser(TikaProperties properties, TikaLoader validated)
throws IOException, TikaConfigException {
PipesForkParserConfig config = new PipesForkParserConfig()
.setHandlerType(BasicContentHandlerFactory.HANDLER_TYPE.MARKDOWN)
.setParseMode(ParseMode.RMETA)
.setWriteLimit(5_000_000) // output-limits.writeLimit is not read here
.setTimeoutLimits(new TimeoutLimits( // total budget, stall detector
properties.parseTimeout().toMillis(), properties.parseTimeout().toMillis() / 2))
.addJvmArg(properties.forkHeap())
.setNumClients(properties.numClients())
.setMaxFilesPerProcess(1_000);
if (properties.configFile() != null) {
config.setUserConfigPath(properties.configFile());
}
return new PipesForkParser(config);
}
@Bean
TextExtractor forkExtractor(PipesForkParser parser) {
return (Path file, String fileName) -> {
PipesForkResult result;
try {
result = parser.parse(file);
} catch (PipesForkParserException e) {
// FAILED_TO_INITIALIZE, CLIENT_UNAVAILABLE_WITHIN_MS, ...: not this document's fault
throw new IllegalStateException("PipesForkParser unavailable: " + e.getStatus(), e);
} catch (Exception e) {
throw new IllegalStateException("parse of " + fileName + " interrupted", e);
}
if (result.isProcessCrash()) {
throw new DocumentFailedException(result.getStatus().name(), "fork crashed on " + fileName);
}
if (!result.isSuccess()) {
throw new DocumentFailedException(result.getStatus().name(), result.getMessage());
}
return toExtraction(result.getMetadataList());
};
}
private static Extraction toExtraction(List<Metadata> list) {
Metadata container = list.getFirst();
List<String> warnings = new ArrayList<>();
StringBuilder content = new StringBuilder();
for (Metadata m : list) {
String text = m.get(TikaCoreProperties.TIKA_CONTENT);
if (text != null && !text.isBlank()) {
content.append(content.isEmpty() ? "" : "\n\n").append(text.strip());
}
if (m.get(TikaCoreProperties.EMBEDDED_EXCEPTION) != null) {
warnings.add(m.get(TikaCoreProperties.EMBEDDED_RESOURCE_PATH) + ": embedded exception");
}
}
return new Extraction(container.get(HttpHeaders.CONTENT_TYPE), container.get(TikaCoreProperties.TITLE),
content.toString(), list.size(), List.copyOf(warnings));
}
}
Lifecycle and configuration rules this class follows:
-
One
PipesForkParserper application. Each instance owns a pool ofnumClientsforked JVMs; creating one per request would start a JVM per request. It is thread-safe: concurrentparsecalls are spread over the forks, and a call waits for a free fork. -
destroyMethod = "close". Closing kills the forks and deletes the generated configuration file. Spring would inferclose()for anAutoCloseablebean anyway; writing it down documents the intent. OnSIGTERMthe run showed Tomcat’s graceful shutdown (in-flight requests finish), then the context closed the bean, and the fork process was gone (pscounted onePipesServerbefore and none after). -
Forks start lazily. The constructor does not start a JVM; the first
parsedoes, and it took about 10 seconds in the run (loading all parsers in the fork). Send a small warm-up document from anApplicationRunnerif the first real upload must not pay for it, and make the readiness probe wait for it. -
TikaLoadervalidates the configuration at startup. The fork parser reads yourtika-config.jsononly when a fork starts. Loading the same file in the parent withTikaLoader.load(path)and callingloadAutoDetectParser(),loadParseContext()andloadMetadataFilters()makes a typo fail the application start instead of the first upload. It costs start-up time (the run went from 3 to about 18 seconds, most of it building the parser list in the parent). -
Set the write limit on the config object.
PipesForkParserConfig.setWriteLimit(int)is what limits the content; anoutput-limits.writeLimitin the user configuration file had no effect withPipesForkParserin 4.1.0 (measured on Robustness, Limits & Timeouts). -
Give the stall detector room.
TimeoutLimits(total, progress)takes the total budget and the progress (stall) timeout. The first run used the default progress timeout of 120 s with a 60 s total, and every fork loggedprogressTimeoutMillis (120000) > totalTaskTimeoutMillis (60000) — the stall detector can never fire. The final version derives the progress timeout from the total. -
Map results, not just exceptions.
parsereturnsPipesForkResultfor document problems (isProcessCrash()forTIMEOUT,OOM,UNSPECIFIED_CRASH) and throwsPipesForkParserExceptionfor problems of the parser itself (FAILED_TO_INITIALIZE,CLIENT_UNAVAILABLE_WITHIN_MS, …). Only the former is the document’s fault.
The fork configuration used in the run was deliberately small:
{
"parse-context": {
"embedded-limits": {
"maxDepth": 5,
"maxCount": 200
}
}
}
All PipesForkParserConfig methods are listed in
Tika Pipes:
PipesForkParser; the TikaLoader API is in Configuration.
The fat-jar trap
The first run of architecture (b) used the usual java -jar target/bookshelf-intake-1.0.0-SNAPSHOT.jar. The application
started, and every upload failed after about 8 seconds with 500:
Error: Could not find or load main class org.apache.tika.pipes.core.server.PipesServer
Caused by: java.lang.ClassNotFoundException: org.apache.tika.pipes.core.server.PipesServer
ERROR o.a.t.pipes.core.PerClientServerManager : clientId=0: Process exited with code 1 before connecting to socket
...
org.apache.tika.pipes.fork.PipesForkParserException: Failed to initialize parser: org.apache.tika.pipes.core.ServerInitializationException: couldn't connect to server after 5 attempts
The reason is in PerClientServerManager (4.1.0 source): unless forkedJvmArgs contains -cp, the fork is started with
-cp set to the parent’s System.getProperty("java.class.path"). For an executable Spring Boot jar that is the jar
itself, whose dependencies are nested under BOOT-INF/lib and readable only by Spring Boot’s own launcher class loader. A
plain java -cp app.jar finds none of them.
The fix that was run is Spring Boot’s extracted layout, which produces a plain jar whose manifest Class-Path lists the
dependencies in lib/. The JVM honours a manifest Class-Path for jars on -cp, so the forks see every dependency:
java -Djarmode=tools -jar target/bookshelf-intake-1.0.0-SNAPSHOT.jar extract --destination app
java -jar app/bookshelf-intake-1.0.0-SNAPSHOT.jar --bookshelf.tika.mode=embedded \
--bookshelf.tika.config-file=/etc/bookshelf/fork-tika-config.json
Other ways that follow from the same source, not run for this page: start the application with an explicit class path
(java -cp "classes:lib/*" org.bookshelf.intake.BookshelfIntakeApplication, which is what IDEs and
mvn spring-boot:run do, which is why the problem never shows up in development), or put an explicit -cp into the
fork’s JVM arguments (PipesForkParserConfig.setJvmArgs) pointing at a directory of the same Tika version. The
extracted layout is also what Spring Boot recommends for container images (and for CDS), so the Dockerfile for
architecture (b) should run the extracted jar.
Source: Efficient Deployments, Spring Boot
reference and the 4.1.0 PerClientServerManager source (writeArgFile).
Run
With the extracted layout, the same uploads as in architecture (a):
{"id":"e6b02365-7354-4389-8c53-df88df7b932f","status":"INDEXED","detectedType":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","title":"The Lighthouse Keeper","documents":1,"characters":75,"warnings":[]} [201]
real 10.32
{"id":"b50aa6a8-050e-43a6-8743-2bbd8105e209","status":"INDEXED","detectedType":"message/rfc822","title":"Spring submissions: samples attached","documents":6,"characters":490,"warnings":[]} [201]
real 1.53
{"id":"aa5747fe-7fec-41c7-a7b6-8c007f9007e4","status":"INDEXED","detectedType":"application/pdf","title":"Inter-library loan form","documents":1,"characters":130,"warnings":[]} [201]
real 0.10
{"id":"d3093233-1494-4133-91bc-48fd530f6e35","status":"INDEXED","detectedType":"application/epub+zip","title":"The Lighthouse Keeper","documents":1,"characters":58,"warnings":[]} [201]
real 0.14
The first call paid for the fork start (10.3 s), later ones took 0.1 to 1.5 s. The results match architecture (a)
character for character. With --bookshelf.tika.parse-timeout=2s the 90 MB text file ended the same way as with
tika-server:
ERROR org.apache.tika.pipes.core.server.PipesServer -- TIMEOUT: /tmp/bookshelf-originals/7b7ca445-e9ef-4128-b71d-bc499c2e1b5d.txt
java.lang.RuntimeException: Server-side progress timeout: no progress for 1058ms (limit: 1000ms)
INFO o.a.t.pipes.core.PerClientServerManager : clientId=0: marking server for restart (TIMEOUT)
{"id":"7b7ca445-e9ef-4128-b71d-bc499c2e1b5d","status":"QUARANTINED","detectedType":"text/plain","title":null,"documents":0,"characters":0,"warnings":["TIMEOUT"]} [202]
The fork log also warns no plugins directory found at start-up. PipesForkParser does not need pipes plugins for its
internal file-system fetcher, so the warning is harmless; setPluginsDir silences it.
Memory and CPU inside one container
In architecture (b) the container limit has to hold the Spring Boot heap, numClients fork heaps (-Xmx512m each
here), the metaspace and native memory of every JVM, and any external tool the parsers start (Tesseract). Two forks of
512 MB plus a 512 MB parent need a container limit of roughly 2.5 GB, not 1 GB. The fork manager already caps each fork’s
CPU view (pipes-cpu-sizing: hostCores=8, numClients=2, parentReserved=2, autoCap=slice=3 in the start-up log).
Sizing is covered in Robustness, Limits & Timeouts and
Performance & Scaling.
Persistence, index and dead letters
The stores in the project are in-memory stand-ins so the example runs on its own; each is a seam where the real technology plugs in:
package org.bookshelf.intake;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/** Stand-ins for the real stores: an object store, a database table, a search index and a dead-letter queue. */
public final class IntakeStores {
private IntakeStores() {
}
public record StoredDocument(String id, String fileName, String detectedType, Extraction extraction,
Instant receivedAt) { }
public record DeadLetter(String id, String fileName, String reason, Instant at) { }
@Component
public static class OriginalStore {
private final Path root;
public OriginalStore(@Value("${bookshelf.storage.dir:${java.io.tmpdir}/bookshelf-originals}") Path root)
throws IOException {
this.root = Files.createDirectories(root);
}
/** Moves the validated upload under a generated name; the client's name is never a path. */
public Path keep(Path upload, String id, String extension) {
try {
return Files.move(upload, root.resolve(id + "." + extension), StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
@Component
public static class DocumentRepository {
private final Map<String, StoredDocument> rows = new ConcurrentHashMap<>();
public void save(StoredDocument doc) {
rows.put(doc.id(), doc);
}
public StoredDocument find(String id) {
return rows.get(id);
}
}
@Component
public static class SearchIndexer {
private static final Logger log = LoggerFactory.getLogger(SearchIndexer.class);
public void index(StoredDocument doc) {
log.info("indexed {} ({}, {} documents, {} chars)", doc.id(), doc.detectedType(),
doc.extraction().documentCount(), doc.extraction().content().length());
}
}
@Component
public static class DeadLetterQueue {
private final List<DeadLetter> letters = new CopyOnWriteArrayList<>();
public void park(DeadLetter letter) {
letters.add(letter);
}
public List<DeadLetter> all() {
return List.copyOf(letters);
}
}
}
| Stand-in | In production |
|---|---|
|
An object store (S3, GCS, Azure Blob) or a shared volume: File Storage & Object Stores. Never a path derived from the client’s file name. |
|
A database table with the id, the original’s key, the detected type, the Tika metadata you need and a status column. Write a |
|
Solr, Elasticsearch or OpenSearch: Search Indexing with Lucene, Solr & Elasticsearch. For RAG, chunk the Markdown and embed it: RAG & LLM Ingestion. |
|
A queue with a retry consumer (for example a RabbitMQ dead-letter exchange, see RabbitMQ Reliability, Dead Lettering & TTL), or a status column polled by a scheduled job. Retry with more memory or a longer timeout, and give up after a fixed number of attempts. |
For larger volumes, decouple upload from extraction: the controller stores the original, writes the RECEIVED row,
publishes a message and answers 202; a consumer calls Tika. Then a 429 simply delays the message, and the request
thread never waits. At that point Tika Pipes itself (an emitter straight into Solr or Elasticsearch, see
Tika Pipes) may replace the hand-written consumer.
Testing
The HTTP contract with MockRestServiceServer
The status handling is the part most likely to regress, and it needs no tika-server. MockRestServiceServer binds to a
RestClient.Builder and answers the requests the extractor makes:
package org.bookshelf.intake;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.ExpectedCount;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
class TikaServerExtractorTest {
private static final String RMETA = """
[{"Content-Type":"application/pdf","dc:title":"Spring catalogue",
"tk:content":"Bookshelf spring catalogue","tk:embedded-depth":"0"}]""";
@TempDir
Path dir;
private MockRestServiceServer tika;
private TikaServerExtractor extractor;
private Path pdf;
@BeforeEach
void setUp() throws Exception {
RestClient.Builder builder = RestClient.builder();
tika = MockRestServiceServer.bindTo(builder).build();
TikaProperties props = new TikaProperties(TikaProperties.Mode.SERVER, URI.create("http://tika:9998"),
3, Duration.ofSeconds(1), null, "-Xmx512m", 1, Duration.ofSeconds(60));
extractor = new TikaServerExtractor(builder, props);
pdf = Files.writeString(dir.resolve("catalogue.pdf"), "%PDF-1.7 stand-in");
}
@Test
void retriesAfter429ThenSucceeds() {
tika.expect(requestTo("http://tika:9998/rmeta/markdown"))
.andExpect(method(HttpMethod.PUT))
.andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS).header(HttpHeaders.RETRY_AFTER, "1"));
tika.expect(requestTo("http://tika:9998/rmeta/markdown"))
.andExpect(header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"catalogue.pdf\""))
.andRespond(withSuccess(RMETA, MediaType.APPLICATION_JSON));
Extraction e = extractor.extract(pdf, "catalogue.pdf");
assertThat(e.title()).isEqualTo("Spring catalogue");
assertThat(e.documentCount()).isEqualTo(1);
tika.verify();
}
@Test
void givesUpWhenStillBusy() {
tika.expect(ExpectedCount.times(3), requestTo("http://tika:9998/rmeta/markdown"))
.andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS).header(HttpHeaders.RETRY_AFTER, "1"));
assertThatThrownBy(() -> extractor.extract(pdf, "catalogue.pdf"))
.isInstanceOf(TikaBusyException.class);
tika.verify();
}
@Test
void forkCrashIsNotRetried() {
tika.expect(ExpectedCount.once(), requestTo("http://tika:9998/rmeta/markdown"))
.andRespond(withStatus(HttpStatus.SERVICE_UNAVAILABLE)
.header(HttpHeaders.RETRY_AFTER, "5")
.contentType(MediaType.APPLICATION_JSON)
.body("{\"status\":\"OOM\"}"));
assertThatThrownBy(() -> extractor.extract(pdf, "catalogue.pdf"))
.isInstanceOf(DocumentFailedException.class)
.hasMessageStartingWith("OOM");
tika.verify();
}
}
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.574 s -- in org.bookshelf.intake.TikaServerExtractorTest
Bind the mock before the extractor builds its client, and do not set a request factory inside the extractor (that is
why the timeouts live in spring.http.clients.* rather than in a JdkClientHttpRequestFactory built in the
constructor): a factory set after bindTo replaces the mock. The test takes about 3 seconds because the Retry-After
sleeps are real; inject a Clock or a sleeper if that matters.
The real server with Testcontainers
package org.bookshelf.intake;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
@SpringBootTest
@Testcontainers(disabledWithoutDocker = true)
class IntakeTikaContainerTest {
@Container
static final GenericContainer<?> TIKA = new GenericContainer<>(DockerImageName.parse("apache/tika:4.1.0-1"))
.withExposedPorts(9998)
.waitingFor(Wait.forHttp("/version").forStatusCode(200));
@DynamicPropertySource
static void tikaUrl(DynamicPropertyRegistry registry) {
registry.add("bookshelf.tika.mode", () -> "server");
registry.add("bookshelf.tika.base-url",
() -> "http://" + TIKA.getHost() + ":" + TIKA.getMappedPort(9998));
}
@Autowired
TextExtractor extractor;
@Test
void extractsTheManuscript() {
Path docx = Path.of("src/test/resources/bookshelf/manuscript.docx");
Extraction e = extractor.extract(docx, "manuscript.docx");
assertThat(e.contentType())
.isEqualTo("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
assertThat(e.title()).isEqualTo("The Lighthouse Keeper");
assertThat(e.content()).contains("Chapter one begins at dusk.");
}
}
-
Pin the image to a build tag (
4.1.0-1), neverlatest; the tags are explained in Docker & Kubernetes Deployment. -
Wait.forHttp("/version")waits until the server answers, not until the first fork is up; the first parse in the test still pays for the fork start (several seconds), so do not give the test a tight timeout. -
There is no
@ServiceConnectionfor Tika (Spring Boot has no Tika connection details), so@DynamicPropertySourcesets the base URL. -
disabledWithoutDocker = trueskips the class when no Docker daemon is available instead of failing the build.
On the machine used for this page no Docker daemon was running, so the class was compiled and reported as skipped
(Tests run: 1, Failures: 0, Errors: 0, Skipped: 1). The same test body was then run against the local 4.1.0 server by
replacing the container with @SpringBootTest(properties = "bookshelf.tika.base-url=http://127.0.0.1:..."), and passed.
The container start itself (image pull, the image’s default configuration, the wait strategy) was therefore not
verified here. More test strategies (golden files, MockParser, extraction-quality checks) are in
Testing Tika Integrations and
tika-eval & Extraction Quality.
Spring AI and LangChain4j readers: Tika 3.x only
Spring AI’s spring-ai-tika-document-reader (class org.springframework.ai.reader.tika.TikaDocumentReader) and
LangChain4j’s langchain4j-document-parser-apache-tika (ApacheTikaDocumentParser) wrap Tika for RAG ingestion. They
are third-party to Tika and are not Spring Boot integrations of it, and, as of this page, they are built on Tika 3.x:
-
the
spring-ai-tika-document-reader2.0.1 POM declarestika-coreandtika-parsers-standard-package3.3.1 (2.1.0-M1 was reported to do the same; not re-checked here); -
langchain4j-document-parser-apache-tika1.22.0-beta32 resolvestika-core3.2.3.
Both call Parser.parse(InputStream, ContentHandler, Metadata, ParseContext), the 3.x signature. Tika 4.x changed the
first parameter to TikaInputStream (see Parser API &
AutoDetectParser). With tika-bom 4.1.0 imported, which forces tika-core to 4.1.0, both readers failed on the first
document, compiled fine and failed at run time:
spring-ai manuscript.docx FAILED java.lang.NoSuchMethodError: 'void org.apache.tika.parser.AutoDetectParser.parse(java.io.InputStream, org.xml.sax.ContentHandler, org.apache.tika.metadata.Metadata, org.apache.tika.parser.ParseContext)'
langchain4j manuscript.docx FAILED java.lang.NoSuchMethodError: 'void org.apache.tika.parser.Parser.parse(java.io.InputStream, org.xml.sax.ContentHandler, org.apache.tika.metadata.Metadata, org.apache.tika.parser.ParseContext)'
So you cannot have these readers and Tika 4.x in the same class path. The options:
-
Keep the reader on its own Tika 3.x and do not import
tika-bom4.x into that application (3.x is supported until early 2027; see What’s Changed & Migration). Note that the two readers do not even agree on a 3.x version. -
Call tika-server 4.x over HTTP (architecture (a)) and build the Spring AI
Documentor LangChain4jDocumentyourself from the/rmeta/markdownresult: one per embedded document, withtk:embedded-resource-path,dc:titleand the language as metadata. This is what the RAG page of this section recommends: RAG & LLM Ingestion. -
Wait for releases built on Tika 4.x; check the reader’s POM before upgrading.
The reader APIs themselves are shown, with Tika 3.x, in Document Parsing and Ingestion Pipelines & Freshness.
Source: ETL Pipeline, Spring AI reference, RAG, LangChain4j documentation and the published POMs of both artifacts on Maven Central.
From 3.x (and earlier) to 4.x
| Topic | Tika 3.x (and Spring Boot 3) | Tika 4.x with Spring Boot 4 |
|---|---|---|
In-process isolation |
|
|
Configuration in the bean |
|
|
Parser call in your code |
|
|
tika-server failures |
Plain text bodies such as |
JSON |
Metadata keys in |
|
|
Spring HTTP client |
|
|
Health API |
|
|
Web starter |
|
|
Testcontainers JUnit 5 artifact |
|
|
Java |
Tika 3.x needs 11, Spring Boot 3 needs 17 |
Tika 4.x needs 17, Spring Boot 4 needs 17; the examples use 21 |
Tika in Action (2011, Tika 1.x) predates Spring Boot and does not cover it; its advice to keep parsing away from the application process still holds and is what both architectures implement. The full migration list is in What’s Changed & Migration.
Pitfalls
-
Expecting a starter. Neither Apache Tika nor Spring publishes one. Own the few classes of wiring instead of depending on a third-party starter whose Tika version you do not control.
-
Parsing in the request thread with
AutoDetectParser. One bad file can take down every request the instance is serving. -
The fat jar with
PipesForkParser. Works in the IDE, fails in production withClassNotFoundException: PipesServer. Run the extracted layout. -
Two logging back ends.
tika-pipes-fork-parserbrings Log4j; exclude it in a Spring Boot application. -
Read timeout shorter than the server’s patience. The client gives up while tika-server keeps a fork busy on a parse nobody will read; the next request then finds the pool full.
-
Retrying
503. The same document kills the next fork too. Park it. -
Ignoring
Retry-After. Retrying immediately just re-queues; capping the wait (max-retry-wait) protects you from a misconfigured server. -
Trusting the client’s
Content-Typeor file name. Detect from the bytes, store under a generated name. -
Forgetting that the gate in architecture (a) sees
application/x-tika-ooxmlfor every OOXML file. Compare theContent-Typethat tika-server reports after the parse if the subtype matters. -
Exposing tika-server. It has no authentication; keep it on a private network and never let browsers reach it.
-
Mixing Spring AI or LangChain4j Tika readers with
tika-bom4.x.NoSuchMethodErrorat run time, not at compile time. -
A new
PipesForkParser(or a newTikaLoader) per request. Both are application-wide singletons.
What was verified and what was not
Verified by building and running the project (Spring Boot 4.1.1, Spring Framework 7.0.9, Tika 4.1.0, Java 25 with
--release 21, macOS, tika-server 4.1.0 zip on 127.0.0.1):
-
every Java class,
application.yamland the Maven dependencies on this page, in both architectures; -
the multipart and raw-body uploads, the
415and413paths, the429retry (success and exhaustion with503andRetry-After), the503dead-letter path in both architectures, the health indicator output; -
the
java.class.pathfat-jar failure and the extracted-layout fix, the forks disappearing on shutdown; -
tika-core-only detection results, and the Log4j dependencies oftika-pipes-fork-parser(mvn dependency:tree); -
the
MockRestServiceServertests; the Testcontainers test body against the local server; -
the Spring AI 2.0.1 and LangChain4j 1.22.0-beta32
NoSuchMethodErrorwithtika-core4.1.0 (a separate test project), and the Spring AI POM’s Tika 3.3.1 dependency; -
the tika-server start-up failure without a
serversection; -
class and property names against the 4.1.0 and Spring Boot 4.1.1 jars and configuration metadata.
Not verified:
-
the Testcontainers container start (no Docker daemon was available), including the
apache/tika:4.1.0-1image’s default configuration; -
the Gradle build files;
-
the alternative fat-jar fixes (explicit
-cpin the fork arguments,java -cp lib/*); -
Spring AI 2.1.0-M1’s Tika version;
-
behaviour under real concurrency and on Kubernetes; the memory figure for architecture (b) is an estimate from the configured heaps, not a measurement.
References
-
Apache Tika, Tika Server (4.1.x)
-
Apache Tika, Tika Pipes from Java (4.1.x)
-
Apache Tika, Migrating to Tika 4.x
-
Apache Tika Javadoc 4.1.0:
PipesForkParser,DefaultDetector -
Apache Tika, PipesForkParserExample.java (
tika-example) -
Spring Boot reference: Calling REST Services, Servlet Web Applications, Efficient Deployments, Actuator Endpoints, Testcontainers, Common Application Properties
-
Spring Framework reference: REST Clients, Multipart Resolver, Error Responses, Resilience Features, Testing Client Applications
-
Spring AI reference, ETL Pipeline (
TikaDocumentReader) -
LangChain4j, RAG tutorial (
ApacheTikaDocumentParser) -
Testcontainers for Java, Creating a container
-
OWASP, File Upload Cheat Sheet
-
Chris A. Mattmann and Jukka L. Zitting, Tika in Action, Manning, 2011: chapter 2 (getting started) and chapter 9 (the Tika server and remote parsing); the book covers Tika 1.x and no Spring integration.