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

tika-core only (for the upload gate)

tika-pipes-fork-parser, tika-parsers-standard-package, tika-serialization (plus whatever the parsers pull in: POI, PDFBox, …​)

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 + numClients x fork heap + off-heap for every fork, in one container limit

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 429 with Retry-After

PipesForkParserException with CLIENT_UNAVAILABLE_WITHIN_MS, or callers simply block

Version upgrades

Change an image tag

Rebuild and redeploy the service

Packaging trap

None

Forks reuse java.class.path, which is useless inside a Spring Boot fat jar (see The fat-jar trap)

Testing

MockRestServiceServer plus a Testcontainers apache/tika container

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).

sequenceDiagram autonumber actor C as Client participant Ctl as IntakeController participant G as UploadGate (tika-core) participant St as OriginalStore participant T as tika-server (or PipesForkParser) participant R as DocumentRepository participant I as SearchIndexer participant D as DeadLetterQueue C->>Ctl: POST /documents (multipart) or PUT /documents/{name} Note over Ctl: max-file-size enforced while the body is read Ctl->>G: check(tempFile, clientName) alt name, extension or detected type not allowed G-->>Ctl: UploadRejectedException Ctl-->>C: 415 ProblemDetail end Ctl->>St: keep(tempFile, uuid.ext) Ctl->>T: PUT /rmeta/markdown (streamed from disk) alt 429 Too Many Requests T-->>Ctl: Retry-After: 2 Note over Ctl: sleep Retry-After, retry up to maxAttempts Ctl-->>C: 503 + Retry-After when still busy else 503 TIMEOUT / OOM / UNSPECIFIED_CRASH T-->>Ctl: {"status":"TIMEOUT"} Ctl->>D: park(uuid, reason) Ctl-->>C: 202 QUARANTINED else 200 OK T-->>Ctl: JSON list, container first Ctl->>R: save(StoredDocument) Ctl->>I: index(StoredDocument) Ctl-->>C: 201 INDEXED end

Three rules drive the design:

  • Validate before parsing. The gate uses only tika-core detection 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. 429 means Tika is busy and the document is fine: retry. 503 means 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

spring.servlet.multipart.max-file-size / max-request-size

20MB / 21MB

The first size limit, enforced by the servlet container while the body is read; exceeding it raises MaxUploadSizeExceededException before your controller runs. Defaults are 1 MB and 10 MB.

spring.http.clients.imperative.factory

jdk

Pin the client to the JDK HttpClient. Without it Boot picks the first library it finds (Apache HttpClient 5, Jetty, Reactor Netty, then the JDK), and a transitive dependency could change it silently.

spring.http.clients.connect-timeout

5s

Fail fast when tika-server is down.

spring.http.clients.read-timeout

5m

Must exceed the slowest legitimate parse plus the time a request may wait for a fork on the server (pipes.maxWaitForClientMillis). A shorter read timeout abandons a parse the server keeps running.

spring.threads.virtual.enabled

true

Request threads that wait for tika-server (and sleep for Retry-After) become cheap virtual threads.

bookshelf.tika.max-attempts

4

Attempts after 429 before giving up.

bookshelf.tika.max-retry-wait

30s

Cap on a single Retry-After wait.

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 /documents with multipart/form-data and a file part, for browsers and form posts. Tomcat writes the part to a temporary file while reading the request (spring.servlet.multipart.file-size-threshold defaults to 0, so nothing stays in memory), and MultipartFile.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 an InputStream; 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 429 retry 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

201 Created with the IntakeResult

Tika failed on the document (dead-lettered)

202 Accepted, "status":"QUARANTINED"

The upload itself succeeded and the original is kept; processing is deferred.

Name, extension or detected type not allowed

415 Unsupported Media Type

Body over the limit

413 Content Too Large

HttpStatus.CONTENT_TOO_LARGE replaces the deprecated PAYLOAD_TOO_LARGE in Spring Framework 7.

tika-server still busy after maxAttempts

503 Service Unavailable with Retry-After

Back-pressure is passed on instead of being absorbed in a queue in memory.

tika-server unreachable or read timeout

502 Bad Gateway

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 own tk:content. Embedded documents (the attachments of publisher-email.eml, the entries of samples.zip) come back separately, a per-document exception stays inside its object and the request still answers 200. Markdown keeps headings and lists for chunking. The endpoint catalogue is in tika-server REST API.

  • PUT with the raw bytes, not multipart. tika-server accepts both; PUT avoids building a multipart body and lets the file name travel in Content-Disposition as 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 (JdkClientHttpRequest extends AbstractStreamingClientHttpRequest in Spring Framework 7.0.9) the bytes are not buffered in the heap, and the explicit contentLength sends a fixed-length body instead of chunked transfer encoding.

  • exchange instead of retrieve. exchange hands over the raw response, so the status decides the outcome without exceptions being thrown and caught for the normal 429 path. 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-by was an array for the DOCX and a string for an unknown binary), so the mapper below reads both.

  • 429 is retried, honouring Retry-After, capped by max-retry-wait. In 4.1.0 the header is the server’s maxWaitForClientMillis in 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.

  • 503 is 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 400 or 413 from 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 PipesForkParser per application. Each instance owns a pool of numClients forked JVMs; creating one per request would start a JVM per request. It is thread-safe: concurrent parse calls 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 infer close() for an AutoCloseable bean anyway; writing it down documents the intent. On SIGTERM the run showed Tomcat’s graceful shutdown (in-flight requests finish), then the context closed the bean, and the fork process was gone (ps counted one PipesServer before and none after).

  • Forks start lazily. The constructor does not start a JVM; the first parse does, and it took about 10 seconds in the run (loading all parsers in the fork). Send a small warm-up document from an ApplicationRunner if the first real upload must not pay for it, and make the readiness probe wait for it.

  • TikaLoader validates the configuration at startup. The fork parser reads your tika-config.json only when a fork starts. Loading the same file in the parent with TikaLoader.load(path) and calling loadAutoDetectParser(), loadParseContext() and loadMetadataFilters() 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; an output-limits.writeLimit in the user configuration file had no effect with PipesForkParser in 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 logged progressTimeoutMillis (120000) > totalTaskTimeoutMillis (60000) — the stall detector can never fire. The final version derives the progress timeout from the total.

  • Map results, not just exceptions. parse returns PipesForkResult for document problems (isProcessCrash() for TIMEOUT, OOM, UNSPECIFIED_CRASH) and throws PipesForkParserException for 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

OriginalStore

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.

DocumentRepository

A database table with the id, the original’s key, the detected type, the Tika metadata you need and a status column. Write a RECEIVED row before parsing so that a crash of the intake service itself (or a TikaBusyException) does not leave an orphaned original.

SearchIndexer

Solr, Elasticsearch or OpenSearch: Search Indexing with Lucene, Solr & Elasticsearch. For RAG, chunk the Markdown and embed it: RAG & LLM Ingestion.

DeadLetterQueue

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), never latest; 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 @ServiceConnection for Tika (Spring Boot has no Tika connection details), so @DynamicPropertySource sets the base URL.

  • disabledWithoutDocker = true skips 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-reader 2.0.1 POM declares tika-core and tika-parsers-standard-package 3.3.1 (2.1.0-M1 was reported to do the same; not re-checked here);

  • langchain4j-document-parser-apache-tika 1.22.0-beta32 resolves tika-core 3.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-bom 4.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 Document or LangChain4j Document yourself from the /rmeta/markdown result: one per embedded document, with tk:embedded-resource-path, dc:title and 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

ForkParser (removed in 4.x)

PipesForkParser (tika-pipes-fork-parser)

Configuration in the bean

new TikaConfig(path) with tika-config.xml

TikaLoader.load(path) with tika-config.json

Parser call in your code

parse(InputStream, ...)

parse(TikaInputStream, ...); any library compiled against 3.x breaks with NoSuchMethodError

tika-server failures

Plain text bodies such as Parse failed: TIMEOUT

JSON {"status":"TIMEOUT",...} with 503 and Retry-After; 429 for a busy pool

Metadata keys in /rmeta

X-TIKA:content, X-TIKA:embedded_resource_path, …​

tk:content, tk:embedded-resource-path, …​: update the mapper (see Metadata)

Spring HTTP client

RestTemplate, spring.http.client.*

RestClient, spring.http.clients.* (RestTemplate still works)

Health API

org.springframework.boot.actuate.health

org.springframework.boot.health.contributor

Web starter

spring-boot-starter-web

spring-boot-starter-webmvc (the old name still exists, deprecated)

Testcontainers JUnit 5 artifact

org.testcontainers:junit-jupiter (1.x)

org.testcontainers:testcontainers-junit-jupiter (2.x)

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 with ClassNotFoundException: PipesServer. Run the extracted layout.

  • Two logging back ends. tika-pipes-fork-parser brings 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-Type or file name. Detect from the bytes, store under a generated name.

  • Forgetting that the gate in architecture (a) sees application/x-tika-ooxml for every OOXML file. Compare the Content-Type that 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-bom 4.x. NoSuchMethodError at run time, not at compile time.

  • A new PipesForkParser (or a new TikaLoader) 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.yaml and the Maven dependencies on this page, in both architectures;

  • the multipart and raw-body uploads, the 415 and 413 paths, the 429 retry (success and exhaustion with 503 and Retry-After), the 503 dead-letter path in both architectures, the health indicator output;

  • the java.class.path fat-jar failure and the extracted-layout fix, the forks disappearing on shutdown;

  • tika-core-only detection results, and the Log4j dependencies of tika-pipes-fork-parser (mvn dependency:tree);

  • the MockRestServiceServer tests; the Testcontainers test body against the local server;

  • the Spring AI 2.0.1 and LangChain4j 1.22.0-beta32 NoSuchMethodError with tika-core 4.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 server section;

  • 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-1 image’s default configuration;

  • the Gradle build files;

  • the alternative fat-jar fixes (explicit -cp in 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