Tika gRPC

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-12. This page was written against Apache Tika 4.1.0 (the latest release on Maven Central on that date) with the 3.3.x differences noted, Java 21 and later for the examples (Tika 4.x needs Java 17), gRPC Java 1.84.0 (the version the Tika 4.1.0 parent POM pins) and the Docker image apache/tika-grpc:4.1.0. Spring Boot 4.x on Spring Framework 7 is the baseline for the rest of the section, but nothing on this page depends on it.

tika-grpc is the third way to run Tika as a service, next to the HTTP tika-server and the batch-oriented Tika Pipes. It is, in fact, Tika Pipes with a gRPC front end: a client names a fetcher and a fetch key, the server fetches the bytes itself, parses them in a forked JVM and returns the metadata and the extracted text as one protobuf message. That design has consequences this page spends most of its length on: the client never uploads bytes, the security exposure is larger than tika-server’s, the reply is a flat map rather than a list of documents, and large extracts need explicit settings on both sides.

Related pages: Configuration (the tika-config.json file the server reads), Tika Pipes (fetchers, emitters and the forked-worker pool underneath), Docker & Kubernetes Deployment, Robustness, Limits & Timeouts and Security.

How this page was checked:

  • The service definition was read from tika.proto at the 4.1.0, 4.0.0, 4.0.0-beta-1-rc1, 4.0.0-alpha-1 and 3.3.2 tags of the Tika repository; the command-line options, the capability flags, the concurrency rules and the status codes from TikaGrpcServer, TikaGrpcConfig and TikaGrpcServerImpl at 4.1.0; the image from tika-grpc/docker-build/Dockerfile and start-tika-grpc.sh at 4.1.0; and the 4.x changes from CHANGES-4.1.0.txt.

  • The server was run from the published org.apache.tika:tika-grpc:4.1.0 jar and its Maven dependencies (plus tika-parsers-standard-package 4.1.0 and the tika-pipes-file-system-4.1.0.zip plugin from downloads.apache.org) on Java 25, in plaintext, server-TLS and mutual-TLS mode. Every Java client on this page was compiled against the generated classes in that jar and run against that server; the outputs shown are from those runs. The sample files are the small Bookshelf stand-ins used across this section, plus a simpler catalogue-email.eml (an e-mail with one PDF attached) for the flattening examples.

  • Not verified: the published Docker image (the docker and Kubernetes snippets are derived from the Dockerfile and the 4.1.x documentation and were not run for this page), the Helm chart, gRPC server reflection with a tool such as grpcurl, the Apache Ignite config store and service-mesh mTLS.

What tika-grpc is

tika-grpc was added in 3.0.0-BETA2 (TIKA-4181) as a gRPC server over Tika Pipes. In 4.x it is a peer of tika-server: both run every parse in forked, crash-isolated Pipes workers, and both read the same JSON configuration format. What differs is the contract:

Aspect tika-grpc tika-server

Protocol

gRPC over HTTP/2, protobuf messages defined in tika.proto

REST over HTTP/1.1, plain text, XHTML, Markdown or JSON bodies

Where the bytes come from

The server fetches them with a configured fetcher (file system, HTTP, S3, GCS, Azure Blob, JDBC, …​). There is no RPC that takes document bytes.

The client uploads them in the request body (PUT /tika, /rmeta, …​), or names a fetcher through /pipes and /async when allowPipes is on

Output

One FetchAndParseReply per document: status, error message and a flat map<string, string> of metadata, with the text in tk:content

The response body, in the format the endpoint chooses

Streaming

Server-side and bidirectional streaming RPCs over one HTTP/2 connection

One request, one response

Default exposure

Plaintext, no authentication, FetchAndParse always on

Plaintext, no authentication, upload endpoints on, /pipes and /async off

Typical caller

A service in another language, or a pipeline that already stores documents where a fetcher can reach them

Anything that can send HTTP, including a browser-facing upload service

The second row is the one that decides whether tika-grpc fits. If your application holds the bytes in memory (an upload controller, a message consumer), tika-server’s PUT /tika is the simpler fit. If documents already sit in a bucket, a share or a database that the server can be given read access to, tika-grpc lets a client in any gRPC language ask the server to parse inbox/catalogue.pdf and receive the result, without moving the bytes through the client.

The flow of one FetchAndParse call, as the 4.1.0 source implements it:

sequenceDiagram participant C as Bookshelf client participant G as tika-grpc (TikaGrpcServerImpl) participant P as PipesParser pool participant W as Forked worker JVM participant S as Document store C->>G: FetchAndParse(fetcher_id, fetch_key, parse_context_json?) G->>G: capability check (PERMISSION_DENIED) G->>G: parse_context_json validation (INVALID_ARGUMENT) G->>P: FetchEmitTuple (wait up to maxWaitForClientMillis) alt no worker free in time P-->>G: CLIENT_UNAVAILABLE_WITHIN_MS else worker available P->>W: tuple over IPC W->>S: fetcher reads fetch_key S-->>W: bytes W->>W: detect, parse, metadata filters W-->>P: metadata list (PASSBACK) or emit P-->>G: PipesResult end G-->>C: FetchAndParseReply(status, fields, error_message)

The gRPC call can fail in two different ways, and a client has to handle both. A gRPC status (PERMISSION_DENIED, INVALID_ARGUMENT, UNKNOWN, DEADLINE_EXCEEDED, UNAVAILABLE, RESOURCE_EXHAUSTED) is thrown as a StatusRuntimeException and means the call itself did not complete. An in-band status in FetchAndParseReply.status (FETCH_EXCEPTION, TIMEOUT, OOM, CLIENT_UNAVAILABLE_WITHIN_MS, …​) means the call completed and the fetch or parse did not.

Module, artifact and image

Item What it is

Source module

tika-grpc in the Tika repository: tika.proto, TikaGrpcServer (the main class), TikaGrpcServerImpl (the service) and TikaGrpcConfig (the grpc section of the configuration)

Maven artifact

org.apache.tika:tika-grpc:4.1.0 on Maven Central: a thin jar (about 250 KB) with the server classes and the generated protobuf and gRPC classes. It is not a runnable distribution: no lib/, no plugins, and there is no tika-grpc zip on downloads.apache.org (TIKA-4723). Clients use this artifact for the generated classes.

Docker image

apache/tika-grpc, the supported way to run the server. Tags on Docker Hub on the baseline date: 4.1.0, 4.1.0-1, latest, 4.0.0, 4.0.0-1, plus -SNAPSHOT tags, all for linux/amd64 and linux/arm64. Pin <version>-<N>, as for the tika-server images.

Pipes plugins

Fetchers, emitters and pipes iterators are pf4j plugin zips (tika-pipes-file-system-4.1.0.zip, tika-pipes-s3-4.1.0.zip, tika-pipes-http-4.1.0.zip, …​). They are published on downloads.apache.org/tika/4.1.0/ and are already in the image under /tika/plugins. Without them the server starts, logs a warning and most calls fail with an unknown fetcher type.

The image

The 4.1.0 Dockerfile builds on Ubuntu, installs openjdk-21-jre-headless, Tesseract with the English, Italian, French, Spanish and German language packs, GDAL and a set of fonts (including fonts-noto-cjk and the Microsoft core fonts), and runs as UID/GID 35002, like the tika-server images. The layout is /tika/libs (the jar and its classpath), /tika/plugins (the plugin zips), /tika/config and /tika/bin/start-tika-grpc.sh, the entrypoint. It sets OMP_THREAD_LIMIT=1 so that Tesseract does not oversubscribe the CPU when several forked workers run OCR. The 4.1.0 CHANGES file also lists ffmpeg for this image; the Dockerfile at the 4.1.0 tag does not install it, and the published image was not inspected.

The entrypoint script reads two environment variables and appends any container arguments:

Variable Default Effect

TIKA_GRPC_PORT

9090

Passed as -p. The image EXPOSE`s `9090.

TIKA_CONFIG

unset

When set, passed as -c; when unset the server uses its bundled, empty default configuration.

container arguments

none

Appended after --plugin-roots /tika/plugins, so TLS flags go here.

Port 9090 or 50052? The server’s own default (TikaGrpcServer.TIKA_SERVER_GRPC_DEFAULT_PORT) is 50052, and that is what you get from java -jar or a classpath launch. The image overrides it: the 4.1.0 Dockerfile exposes 9090 and the entrypoint passes -p 9090 unless TIKA_GRPC_PORT says otherwise. The tika-grpc/README.md Docker and Kubernetes examples use 50052 throughout, which does not match the 4.1.0 image. Set TIKA_GRPC_PORT explicitly and use the same value in your port mappings, Services and probes.

The Dockerfile also sets TIKA_GRPC_MAX_INBOUND_MESSAGE_SIZE, TIKA_GRPC_MAX_OUTBOUND_MESSAGE_SIZE (both 104857600) and TIKA_GRPC_NUM_THREADS (4). Nothing in start-tika-grpc.sh, TikaGrpcServer or TikaGrpcServerImpl at the 4.1.0 tag reads them, so do not rely on them. The same goes for JAVA_OPTS, which the README’s StatefulSet example sets: the entrypoint starts java with a fixed argument list and does not expand JAVA_OPTS. Heap and JVM options for the workers belong in pipes.forkedJvmArgs, which is where the parsing happens.

A Bookshelf run of the image, with the configuration and the inbox mounted read-only (derived from the Dockerfile and entrypoint; not run for this page):

docker run --rm --name bookshelf-grpc \
  -p 127.0.0.1:9090:9090 \
  -e TIKA_CONFIG=/tika/config/bookshelf.json \
  -v "$PWD/tika-config.json:/tika/config/bookshelf.json:ro" \
  -v "$PWD/inbox:/srv/bookshelf/inbox:ro" \
  apache/tika-grpc:4.1.0-1

Binding the published port to 127.0.0.1 keeps the plaintext port off the network; see why tika-grpc is more exposed below. Image choice, sizing and probes are covered on Docker & Kubernetes Deployment.

Running it without Docker

Because the Maven artifact is thin, a non-Docker launch needs the jar’s dependencies, the parsers and a plugins directory. That is how the server was run for this page:

# classpath = tika-grpc 4.1.0 + its dependencies + tika-parsers-standard-package 4.1.0
mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt

mkdir -p plugins
curl -sO --output-dir plugins https://downloads.apache.org/tika/4.1.0/tika-pipes-file-system-4.1.0.zip

java -cp "$(cat cp.txt)" org.apache.tika.pipes.grpc.TikaGrpcServer \
  -c tika-config.json -p 9090 --plugin-roots "$PWD/plugins"
INFO  org.apache.tika.pipes.core.PipesParser Using per-client server mode with 2 clients
INFO  org.apache.tika.pipes.core.PerClientServerManager pipes-cpu-sizing: hostCores=8, numClients=2, parentReserved=2, autoCap=slice=3, heap=user-set in forkedJvmArgs
INFO  org.pf4j.DefaultPluginManager PF4J version 3.16.0 in 'deployment' mode
INFO  org.pf4j.AbstractPluginManager Plugin 'tika-pipes-file-system-plugin@4.1.0' resolved
INFO  org.apache.tika.pipes.core.config.ConfigStoreFactory Creating InMemoryConfigStore
INFO  org.apache.tika.pipes.grpc.TikaGrpcServer Server started, listening on 9090

The forked workers inherit the parent’s classpath, so the parser modules must be on it; leaving tika-parsers-standard-package out gives a server that starts but parses nothing useful. For local plugin development the source tree has run-dev.sh and a dev Maven profile that load plugins from their target/classes directories (tika.plugin.dev.mode=true).

Command-line options

These are the JCommander options of TikaGrpcServer in 4.1.0, printed by --help on the version used:

Usage: <main class> [options]
  Options:
    --cert-chain
      Certificate chain file. Example: server1.pem See: https://github.com/grpc/grpc-java/tree/b3ffb5078df361d7460786e134db7b5c00939246/examples/example-tls
    --client-auth-required
      Is Mutual TLS required? Implies --secure.
      Default: false
    -c, --config
      The tika config file
    -h, -H, --help
      Display help menu
      Default: false
    --plugin-roots
      Comma-separated list of plugin root directories (overrides config file)
    -l, --plugins
      The tika pipes plugins config file
    -p, --port
      The grpc server port
    --private-key
      Private key store. Example: server1.key See: https://github.com/grpc/grpc-java/tree/b3ffb5078df361d7460786e134db7b5c00939246/examples/example-tls
    --private-key-password
      Private key password, if needed
    -s, --secure
      Enable credentials required to access this grpc server
      Default: false
    --trust-cert-collection
      The trust certificate collection (root certs). Required, and must be a
      readable file, when --client-auth-required is set. Example: ca.pem See: https://github.com/grpc/grpc-java/tree/b3ffb5078df361d7460786e134db7b5c00939246/examples/example-tls
Option Default Notes

-p, --port

50052

The help text does not print the default. 0 asks the OS for a free port; the server logs the port it bound.

-c, --config

bundled empty config

A tika-config.json. Without it the server copies its bundled default-tika-config.json (an empty object) to a temp file: no fetchers, so nothing can be parsed until a fetcher is added at runtime, which is off by default.

--plugin-roots

from the config

Comma-separated directories of plugin zips. Overrides plugin-roots in the configuration; the Docker entrypoint always passes /tika/plugins. When neither is set, 4.1.0 falls back to a plugins directory resolved against the install layout (TIKA-4864/TIKA-4865).

-l, --plugins

none

Parsed but not used by TikaGrpcServer.start() in 4.1.0; use plugin-roots instead.

-s, --secure

false

Enable TLS with --cert-chain and --private-key.

--cert-chain, --private-key, --private-key-password

none

The server certificate chain and its PEM key. The key used for this page was an unencrypted PKCS #8 PEM file.

--trust-cert-collection

none

CA certificates used to verify client certificates.

--client-auth-required

false

Require client certificates (mutual TLS). Implies --secure; the server refuses to start if --trust-cert-collection is missing or unreadable.

There is no JSON key for any of the TLS settings, and no option to bind to a specific interface: gRPC Java’s Grpc.newServerBuilderForPort listens on all interfaces. Restrict reachability with the container port mapping, a firewall, or a Kubernetes NetworkPolicy.

The service: tika.proto

The proto package is tika and the service is Tika. The 4.1.0 service has eleven RPCs, in three groups:

RPC Gated by What it does

FetchAndParse

always on (per-request fields need allowPerRequestConfig)

Unary: fetch one key with one fetcher, parse it, return one FetchAndParseReply.

FetchAndParseServerSideStreaming

same

One request, a stream of replies. In 4.1.0 the server sends exactly one reply and completes the call (4.0.0 fixed a version that never completed it, TIKA-4804).

FetchAndParseBiDirectionalStreaming

same

A stream of requests, a stream of replies, one reply per request, in order, over one call.

SaveFetcher

allowComponentManagement

Create or overwrite a fetcher in the fetcher store.

DeleteFetcher

allowComponentManagement

Remove a fetcher; replies success.

GetFetcher

config returned only with allowComponentManagement

A fetcher’s id, type and, if allowed, its parameters.

ListFetchers

same

All fetchers. The page_number and num_fetchers_per_page fields exist, but the 4.1.0 implementation ignores them and returns every fetcher.

GetFetcherConfigJsonSchema

always on

The JSON Schema of a registered fetcher type’s configuration.

SavePipesIterator

allowComponentManagement

Store a pipes-iterator configuration in the shared config store.

GetPipesIterator

config returned only with allowComponentManagement

Read it back.

DeletePipesIterator

allowComponentManagement

Remove it.

The pipes-iterator RPCs only store iterator configurations; no RPC in 4.1.0 runs an iterator. They exist so that a co-deployed Pipes process reading the same config store can pick them up.

The request and reply of the parse RPCs:

message FetchAndParseRequest {
  string fetcher_id = 1;                    // a fetcher from the config or the fetcher store
  string fetch_key = 2;                     // e.g. a path relative to basePath, a URL, an object key
  string additional_fetch_config_json = 3;  // per-request fetcher overrides (allowPerRequestConfig)
  string emitter_id = 4;                    // see the note below
  string parse_context_json = 5;            // per-request ParseContext (allowPerRequestConfig)
}

message FetchAndParseReply {
  string fetch_key = 1;                     // echoes the request
  map<string, string> fields = 2;           // metadata, including tk:content
  string status = 3;                        // a PipesResult.RESULT_STATUS name
  string error_message = 4;                 // set for FETCH_EXCEPTION
}

Source: tika.proto at 4.1.0 (comments abridged).

Three details of the reply matter in practice:

  • emitter_id is not used in 4.1.0. The proto describes it as an optional emitter, but TikaGrpcServerImpl builds the FetchEmitTuple with an empty EmitKey and never reads the field. Results always come back in the reply, which is why the server must be configured to pass them back (next section).

  • fields is a flat map. With the default RMETA parse mode the worker returns one metadata object per document (the container and each attachment), and the server copies all of them into one map, so a key that occurs in an attachment overwrites the container’s value. Embedded documents and the flat map shows the effect on catalogue-email.eml.

  • error_message is only set for FETCH_EXCEPTION, and it contains the server-side exception with its stack trace, including absolute paths on the server. Log it; do not show it to end users.

Statuses

status is the name of a org.apache.tika.pipes.api.PipesResult.RESULT_STATUS constant. The ones a gRPC client meets most often:

Status Meaning for the client

PARSE_SUCCESS

Parsed; fields holds the result.

PARSE_SUCCESS_WITH_EXCEPTION

Parsed with a non-fatal exception, recorded in tk:exception:* fields.

CLIENT_UNAVAILABLE_WITHIN_MS

No worker became free within pipes.maxWaitForClientMillis: the server is at capacity, not failing. Retry with backoff.

FETCH_EXCEPTION

The fetcher could not read the key (for example a missing file); see error_message.

FETCHER_NOT_FOUND

The worker does not know the fetcher id; see runtime fetchers.

EMITTER_NOT_FOUND

The worker tried to emit instead of passing the result back; see the emit strategy below.

TIMEOUT, OOM, UNSPECIFIED_CRASH

The worker timed out, ran out of memory or exited; it is restarted. fields then holds only tk:pipes-result.

The full list (including PARTIAL_TIMEOUT, PAYLOAD_LIMIT_EXCEEDED, EMPTY_OUTPUT and the emit statuses) is in the PipesResult.RESULT_STATUS Javadoc. The timeout and crash statuses are explained on Robustness, Limits & Timeouts.

Configuring the server

tika-grpc reads the same tika-config.json as tika-server and Pipes (see Configuration). The sections it uses are plugin-roots, fetchers, pipes, parse-context, the parser and detector sections the workers load, and its own grpc section. This is the Bookshelf intake configuration the examples on this page ran against (the path is shortened):

{
  "plugin-roots": "/opt/bookshelf/plugins",
  "fetchers": {
    "bookshelf-inbox": {
      "file-system-fetcher": { "basePath": "/srv/bookshelf/inbox" }
    }
  },
  "pipes": {
    "numClients": 2,
    "forkedJvmArgs": ["-Xmx1g"],
    "emitStrategy": { "type": "PASSBACK_ALL" }
  },
  "parse-context": {
    "basic-content-handler-factory": { "type": "MARKDOWN", "writeLimit": 1000000 }
  },
  "grpc": {
    "allowPerRequestConfig": false,
    "allowComponentManagement": false
  }
}

Source: Apache Tika 4.1.x, Pipes Configuration and tika-grpc/src/test/resources/tika-pipes-test-config.json at 4.1.0, which uses the same PASSBACK_ALL emit strategy.

The keys that matter for gRPC:

Key Default Why it matters here

fetchers

none

The fetchers every call can use. The file-system fetcher refuses keys that resolve outside basePath; set allowAbsolutePaths only if you mean it.

pipes.numClients

CPU-derived, at most 4

How many forked workers parse at the same time.

pipes.maxWaitForClientMillis

60000

How long a call waits for a free worker before returning CLIENT_UNAVAILABLE_WITHIN_MS.

pipes.useSharedServer

false

When true the clients share one worker JVM instead of one JVM each (4.0.0 made this take effect for gRPC, TIKA-4815).

pipes.forkedJvmArgs

none

Heap and JVM options for the workers, for example -Xmx1g.

pipes.emitStrategy

DYNAMIC, threshold 100000 bytes

Set PASSBACK_ALL for tika-grpc. With DYNAMIC, an extract above the threshold is emitted from the worker instead of being passed back; since tika-grpc has no emitter, the call returns EMITTER_NOT_FOUND. A 9 MB text file did exactly that with the default, and parsed with PASSBACK_ALL.

pipes.parseMode

RMETA

RMETA (one metadata object per document, flattened into the reply), CONCATENATE (one object with all text), NO_PARSE (detection and digests only), CONTENT_ONLY, UNPACK.

pipes.configStoreType, pipes.configStoreParams

memory

Where runtime-saved fetchers live: memory, file ({"path": "..."}) or ignite (not shipped in the image).

parse-context

none

The server-side default ParseContext for every call: content handler type and write limit, timeout limits, PDF settings, exception-reporting.

grpc.allowPerRequestConfig

false

See capability flags.

grpc.allowComponentManagement

false

See capability flags.

Two observations from the test runs:

  • The default content handler is Markdown (tk:content-handler-type = MARKDOWN), as everywhere in 4.x. A basic-content-handler-factory entry in parse-context changes it for all calls.

  • basic-content-handler-factory.writeLimit capped the 9 MB extract at 999985 characters and set tk:exception:write-limit-reached = true. An output-limits entry with writeLimit in the server’s parse-context did not truncate the same file through tika-grpc in this test, so on the gRPC path set the write limit on the handler factory and check for truncation in the reply.

Concurrency model

Each FetchAndParse call (and each request message on a bidirectional stream) takes a client from the PipesParser pool, which admits pipes.numClients parses at a time, each in its own forked JVM. A call that cannot get a client within pipes.maxWaitForClientMillis does not queue forever: it returns CLIENT_UNAVAILABLE_WITHIN_MS in the reply. Before 4.0.0 all calls shared one single-threaded PipesClient, and concurrent calls could crash the worker; 4.0.0 routed fetchAndParse through the pool (TIKA-4815).

With numClients: 1 and maxWaitForClientMillis: 200, four concurrent calls for a 9 MB file produced:

CLIENT_UNAVAILABLE_WITHIN_MS fields=0 contentChars=0
CLIENT_UNAVAILABLE_WITHIN_MS fields=0 contentChars=0
CLIENT_UNAVAILABLE_WITHIN_MS fields=0 contentChars=0
PARSE_SUCCESS fields=16 contentChars=9073247

What follows from the model:

  • Throughput is numClients documents at a time per server, whatever the number of gRPC connections or streams. Scale with replicas rather than a large numClients on one host; each worker is a JVM with its own heap.

  • Treat CLIENT_UNAVAILABLE_WITHIN_MS as back-pressure: retry with exponential backoff, or lower the client’s own concurrency. It is the gRPC counterpart of tika-server’s HTTP 429.

  • A bidirectional stream is sequential. The 4.1.0 server parses each request message synchronously in onNext, so one stream processes one document at a time; it saves connection setup, not parsing time. For parallelism open several calls (up to numClients per replica).

  • An error on a stream ends the stream. If one request on a bidirectional stream is rejected (PERMISSION_DENIED, INVALID_ARGUMENT), the server calls onError and the remaining requests are not processed.

  • Workers are restarted, the parent is not. A crash, timeout or out-of-memory error in a worker becomes an in-band status and a new JVM; the gRPC server keeps serving. Idle workers shut down after the socket timeout (60 s by default) and are restarted on the next call, so the first call after a quiet period pays the JVM start-up.

Source: Apache Tika 4.1.x, Tika gRPC, Concurrency and CHANGES-4.1.0.txt (TIKA-4815).

Capability flags

Both flags live in the grpc section, are read by TikaGrpcConfig.load and default to false when the section or the key is missing. They arrived in 4.0.0 (TIKA-4764), together with tika-server’s allowPipes and allowPerRequestConfig; 3.x tika-grpc had no such gates.

Flag When true When false (default)

allowPerRequestConfig

Calls may set parse_context_json and additional_fetch_config_json, overriding the server’s defaults for that call. This can reconfigure any pipeline component the request names: the content handler, parser settings, timeouts, the fetcher.

A call that sets either field fails with PERMISSION_DENIED, before any fetch.

allowComponentManagement

SaveFetcher, DeleteFetcher, SavePipesIterator and DeletePipesIterator work, and GetFetcher, ListFetchers and GetPipesIterator return stored configurations, which can contain passwords, access keys and tokens.

The mutating RPCs fail with PERMISSION_DENIED; the read RPCs return only id and type, never the configuration.

The CHANGES entry for TIKA-4764 calls the second flag allowComponentModifications. The 4.1.0 code, its error messages and the 4.1.x documentation all use allowComponentManagement, which is the key that worked in the test runs; the grpc section is deserialised strictly, so a misspelt key fails at start-up (tested: allowComponentManagment gave UnrecognizedPropertyException: Unrecognized field "allowComponentManagment" (class org.apache.tika.pipes.grpc.TikaGrpcConfig)) instead of being ignored.

The denial, as a client sees it:

PERMISSION_DENIED: Per-request configuration is disabled. Set 'allowPerRequestConfig' to true in the 'grpc' section of
your tika-config to allow additional_fetch_config_json / parse_context_json. Understand the security implications first.
PERMISSION_DENIED: Runtime component management is disabled. Set 'allowComponentManagement' to true in the 'grpc'
section of your tika-config to allow SaveFetcher/DeleteFetcher/SavePipesIterator/DeletePipesIterator. Understand the
security implications first.

The Tika documentation is explicit that these flags are defence in depth, not security boundaries. With both off, a caller that reaches the port can still parse anything the configured fetchers can read. Keep them off unless a trusted, authenticated caller needs them, and then prefer a separate, internal-only tika-grpc deployment for that caller.

Per-request ParseContext

With allowPerRequestConfig: true, parse_context_json overrides the server’s ParseContext for one call. The JSON is an object whose keys are parse-context component names (the same names as in the parse-context section of tika-config.json) and whose values are their configurations. The server deserialises it in restricted mode and resolves it before handing the tuple to a worker, so a bad request is a client error and not a worker restart:

  • A component that is operator policy, such as exception-reporting, is refused with INVALID_ARGUMENT.

  • An unknown or misspelt component name is refused with INVALID_ARGUMENT, instead of being silently ignored.

additional_fetch_config_json is checked the same way: it requires fetcher_id to be a registered, wire-allowed component name.

package demo;

import java.util.concurrent.TimeUnit;

import io.grpc.Grpc;
import io.grpc.InsecureChannelCredentials;
import io.grpc.ManagedChannel;
import io.grpc.StatusRuntimeException;

import org.apache.tika.pipes.grpc.proto.FetchAndParseReply;
import org.apache.tika.pipes.grpc.proto.FetchAndParseRequest;
import org.apache.tika.pipes.grpc.proto.TikaGrpc;

public class PerRequestContextClient {

    public static void main(String[] args) throws InterruptedException {
        ManagedChannel channel = Grpc.newChannelBuilder(args[0], InsecureChannelCredentials.create())
                .maxInboundMessageSize(32 * 1024 * 1024)
                .build();
        TikaGrpc.TikaBlockingStub tika = TikaGrpc.newBlockingStub(channel);
        String[] contexts = {
            "{\"parse-mode\": \"CONCATENATE\", \"basic-content-handler-factory\": {\"type\": \"TEXT\"}}",
            "{\"parse-mode\": \"NO_PARSE\"}",
            "{\"exception-reporting\": {\"level\": \"FULL\"}}",
            "{\"basic-content-handler-factroy\": {\"type\": \"TEXT\"}}"
        };
        try {
            for (String json : contexts) {
                try {
                    FetchAndParseReply reply = tika.withDeadlineAfter(2, TimeUnit.MINUTES)
                            .fetchAndParse(FetchAndParseRequest.newBuilder()
                                    .setFetcherId("bookshelf-inbox")
                                    .setFetchKey("catalogue-email.eml")
                                    .setParseContextJson(json)
                                    .build());
                    System.out.println(reply.getStatus() + " Content-Type=" + reply.getFieldsMap().get("Content-Type")
                            + " handler=" + reply.getFieldsMap().get("tk:content-handler-type")
                            + " fields=" + reply.getFieldsCount());
                } catch (StatusRuntimeException e) {
                    System.out.println(e.getStatus().getCode() + ": " + e.getStatus().getDescription());
                }
            }
        } finally {
            channel.shutdown().awaitTermination(10, TimeUnit.SECONDS);
        }
    }
}

Against a server with allowPerRequestConfig: true:

PARSE_SUCCESS Content-Type=message/rfc822 handler=TEXT fields=23
PARSE_SUCCESS Content-Type=message/rfc822 handler=null fields=6
INVALID_ARGUMENT: Invalid parse_context_json: Component 'exception-reporting' (context key org.apache.tika.config.ExceptionReporting) may not be supplied via a request parseContext. Components of this type must be configured server-side in the Tika config, not at request time.
INVALID_ARGUMENT: Invalid parse_context_json: Unrecognized parse-context entry 'basic-content-handler-factroy'. Check for a typo; it does not match any registered component.

Against the default server, all four calls fail with PERMISSION_DENIED. The component names a request may use are listed in META-INF/tika/parse-context.idx inside the Tika jars, generated from the @TikaComponent annotations.

A Java client

Dependencies

A client needs the generated classes, which are only published inside the tika-grpc jar, and a gRPC transport. tika-grpc depends on tika-core, tika-pipes-core and other server-side modules; a client does not need them, so exclude every org.apache.tika transitive dependency. The client classpath for this page then had 34 jars instead of 131.

<dependencies>
  <dependency>
    <groupId>org.apache.tika</groupId>
    <artifactId>tika-grpc</artifactId>
    <version>4.1.0</version>
    <exclusions>
      <exclusion>
        <groupId>org.apache.tika</groupId>
        <artifactId>*</artifactId>
      </exclusion>
    </exclusions>
  </dependency>
  <dependency>
    <groupId>io.grpc</groupId>
    <artifactId>grpc-netty-shaded</artifactId>
    <version>1.84.0</version>
    <scope>runtime</scope>
  </dependency>
</dependencies>
dependencies {
    implementation("org.apache.tika:tika-grpc:4.1.0") {
        exclude(group = "org.apache.tika")
    }
    runtimeOnly("io.grpc:grpc-netty-shaded:1.84.0")
}

tika-grpc 4.1.0 brings grpc-stub, grpc-protobuf, grpc-services (for the health-check stubs) and protobuf-java 3.25.9. Keep the transport (grpc-netty-shaded) at the gRPC version those modules use; 4.0.0 stopped shading gRPC inside tika-grpc (TIKA-4709), so version clashes with other gRPC users in the same application now go through normal dependency management.

The alternative, and the only option outside the JVM, is to generate stubs from tika.proto with protoc and the gRPC plugin for your language. The proto package (tika) and the service name (Tika) are the wire contract; the java_package option only affects generated Java code. The 4.1.0 release artefacts contain no generated clients for other languages.

Unary calls with health check

The first Bookshelf client checks health, then parses manuscript.docx, catalogue.pdf and a missing file with the blocking stub:

package demo;

import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import io.grpc.Grpc;
import io.grpc.InsecureChannelCredentials;
import io.grpc.ManagedChannel;
import io.grpc.StatusRuntimeException;
import io.grpc.health.v1.HealthCheckRequest;
import io.grpc.health.v1.HealthCheckResponse;
import io.grpc.health.v1.HealthGrpc;

import org.apache.tika.pipes.grpc.proto.FetchAndParseReply;
import org.apache.tika.pipes.grpc.proto.FetchAndParseRequest;
import org.apache.tika.pipes.grpc.proto.TikaGrpc;

public class BookshelfGrpcClient {

    private static final String FETCHER_ID = "bookshelf-inbox";

    public static void main(String[] args) throws InterruptedException {
        String target = args.length > 0 ? args[0] : "localhost:9090";
        ManagedChannel channel = Grpc.newChannelBuilder(target, InsecureChannelCredentials.create())
                .maxInboundMessageSize(32 * 1024 * 1024) // replies carry the whole extract
                .build();
        try {
            HealthCheckResponse health = HealthGrpc.newBlockingStub(channel)
                    .check(HealthCheckRequest.newBuilder().setService("TikaGrpcServer").build());
            System.out.println("health: " + health.getStatus());

            TikaGrpc.TikaBlockingStub tika = TikaGrpc.newBlockingStub(channel);
            for (String fetchKey : List.of("manuscript.docx", "catalogue.pdf", "missing.pdf")) {
                try {
                    FetchAndParseReply reply = tika
                            .withDeadlineAfter(2, TimeUnit.MINUTES)
                            .fetchAndParse(FetchAndParseRequest.newBuilder()
                                    .setFetcherId(FETCHER_ID)
                                    .setFetchKey(fetchKey)
                                    .build());
                    print(reply);
                } catch (StatusRuntimeException e) {
                    // the call itself failed: transport, deadline, PERMISSION_DENIED, ...
                    System.out.println(fetchKey + " -> gRPC " + e.getStatus().getCode());
                }
            }
        } finally {
            channel.shutdown().awaitTermination(10, TimeUnit.SECONDS);
        }
    }

    private static void print(FetchAndParseReply reply) {
        System.out.println(reply.getFetchKey() + " -> " + reply.getStatus());
        if (!reply.getStatus().startsWith("PARSE_SUCCESS")) {
            // in-band failure: the call succeeded, the fetch or parse did not
            String error = reply.getErrorMessage();
            System.out.println("  error: " + error.lines().findFirst().orElse(""));
            return;
        }
        Map<String, String> fields = reply.getFieldsMap();
        System.out.println("  Content-Type: " + fields.get("Content-Type"));
        System.out.println("  dc:title:     " + fields.get("dc:title"));
        System.out.println("  handler:      " + fields.get("tk:content-handler-type"));
        String content = fields.getOrDefault("tk:content", "").strip();
        System.out.println("  tk:content:   " + content.lines().findFirst().orElse(""));
        System.out.println("  fields:       " + fields.size());
    }
}

Output against the configuration above (the server path is shortened):

health: SERVING
manuscript.docx -> PARSE_SUCCESS
  Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
  dc:title:     The Lighthouse Keeper
  handler:      MARKDOWN
  tk:content:   The Lighthouse Keeper
  fields:       15
catalogue.pdf -> PARSE_SUCCESS
  Content-Type: application/pdf
  dc:title:     Spring catalogue
  handler:      MARKDOWN
  tk:content:   Spring catalogue
  fields:       45
missing.pdf -> FETCH_EXCEPTION
  error: java.io.FileNotFoundException: /srv/bookshelf/inbox/missing.pdf

Source: TikaGrpcServerTest.java at 4.1.0 (the official client usage, with an in-process channel) and gRPC, Health Checking.

Points worth copying from it:

  • Set maxInboundMessageSize on the channel. gRPC Java’s default is 4 MiB, and the whole extract travels in one reply. A 9 MB text file failed with RESOURCE_EXHAUSTED: gRPC message exceeds maximum size 4194304: 9073972 until the limit was raised. Pair it with a server-side write limit so that a reply has a known upper bound.

  • Set a deadline on every call (withDeadlineAfter); without one a call waits as long as the server lets the parse run.

  • Use one ManagedChannel per target for the life of the application. Channels are thread-safe, multiplex calls over HTTP/2 and are expensive to create; shut them down on exit.

  • Check status before reading fields. A non-success reply is not an exception.

The generated stubs also include TikaFutureStub (returns ListenableFuture), TikaStub (asynchronous, required for the streaming RPCs) and TikaBlockingV2Stub.

Bidirectional streaming

package demo;

import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

import io.grpc.Grpc;
import io.grpc.InsecureChannelCredentials;
import io.grpc.ManagedChannel;
import io.grpc.stub.StreamObserver;

import org.apache.tika.pipes.grpc.proto.FetchAndParseReply;
import org.apache.tika.pipes.grpc.proto.FetchAndParseRequest;
import org.apache.tika.pipes.grpc.proto.TikaGrpc;

public class BookshelfStreamingClient {

    public static void main(String[] args) throws InterruptedException {
        String target = args.length > 0 ? args[0] : "localhost:9090";
        ManagedChannel channel = Grpc.newChannelBuilder(target, InsecureChannelCredentials.create())
                .maxInboundMessageSize(32 * 1024 * 1024)
                .build();
        try {
            CountDownLatch finished = new CountDownLatch(1);
            StreamObserver<FetchAndParseRequest> requests = TikaGrpc.newStub(channel)
                    .withDeadlineAfter(10, TimeUnit.MINUTES)
                    .fetchAndParseBiDirectionalStreaming(new StreamObserver<>() {
                        @Override
                        public void onNext(FetchAndParseReply reply) {
                            System.out.printf("%-20s %-14s %s%n", reply.getFetchKey(),
                                    reply.getStatus(), reply.getFieldsMap().get("Content-Type"));
                        }

                        @Override
                        public void onError(Throwable t) {
                            System.out.println("stream failed: " + t);
                            finished.countDown();
                        }

                        @Override
                        public void onCompleted() {
                            finished.countDown();
                        }
                    });
            for (String fetchKey : List.of("manuscript.docx", "catalogue.pdf", "catalogue-email.eml")) {
                requests.onNext(FetchAndParseRequest.newBuilder()
                        .setFetcherId("bookshelf-inbox")
                        .setFetchKey(fetchKey)
                        .build());
            }
            requests.onCompleted();
            finished.await(10, TimeUnit.MINUTES);
        } finally {
            channel.shutdown().awaitTermination(10, TimeUnit.SECONDS);
        }
    }
}
manuscript.docx      PARSE_SUCCESS  application/vnd.openxmlformats-officedocument.wordprocessingml.document
catalogue.pdf        PARSE_SUCCESS  application/pdf
catalogue-email.eml  PARSE_SUCCESS  application/pdf

The third line is not a mistake in the client: catalogue-email.eml is reported as application/pdf. The next section explains why.

Embedded documents and the flat map

catalogue-email.eml is a simpler stand-in for this page: an e-mail with only catalogue.pdf attached (the section’s publisher-email.eml, with its nested samples.zip, would flatten five embedded documents into the map instead of one). With the default RMETA parse mode the worker returns two metadata objects, the e-mail and the PDF, and TikaGrpcServerImpl puts the fields of both into the one fields map, in order. Keys that occur in both take the attachment’s value. Part of the 62-field reply:

  Content-Type = application/pdf
  dc:title = Spring catalogue
  dc:subject = Spring catalogue and manuscript
  message:from = Marta Ruiz <marta@example-publisher.test>
  tk:content = Spring catalogue  ||Bookshelf spring catalogue Forty titles from independent publishers.|
  tk:content-type-parser-override = message/rfc822
  tk:embedded-depth = 1
  tk:embedded-resource-path = /catalogue.pdf
  tk:resource-name = catalogue.pdf
  tk:source-path = catalogue-email.eml

The e-mail’s own message:* keys survive because the PDF has none, but its Content-Type, title, resource name and text were replaced by the attachment’s. The reply mixes two documents, and the e-mail body is lost. There is no field in the proto for a list of documents. Choose instead:

Approach Result for catalogue-email.eml

pipes.parseMode: CONCATENATE on the server (or {"parse-mode": "CONCATENATE"} per request)

One object: Content-Type = message/rfc822, the e-mail’s title, and tk:content holding the body followed by the attachment’s text. 23 fields. Verified.

NO_PARSE

Detection only: Content-Type = message/rfc822, tk:resource-name, 6 fields. Verified.

tika-server /rmeta or Tika Pipes with an emitter

The full per-document list, when you need each attachment separately. See Embedded Documents & Recursive Parsing.

For a gRPC search or RAG intake, CONCATENATE is usually what you want: one document in, one text and one set of container metadata out.

Runtime fetchers and the config store

With allowComponentManagement: true, a trusted admin client can add fetchers without restarting the server. The GetFetcherConfigJsonSchema RPC returns the JSON Schema for a fetcher type, which tells you which keys fetcher_config_json accepts. Fetcher ids may contain only letters, digits, ., _ and -; the type is the short factory name used in tika-config.json (file-system-fetcher, http-fetcher, s3-fetcher, …​), not a class name.

package demo;

import java.util.concurrent.TimeUnit;

import io.grpc.Grpc;
import io.grpc.InsecureChannelCredentials;
import io.grpc.ManagedChannel;

import org.apache.tika.pipes.grpc.proto.FetchAndParseReply;
import org.apache.tika.pipes.grpc.proto.FetchAndParseRequest;
import org.apache.tika.pipes.grpc.proto.GetFetcherConfigJsonSchemaRequest;
import org.apache.tika.pipes.grpc.proto.GetFetcherReply;
import org.apache.tika.pipes.grpc.proto.GetFetcherRequest;
import org.apache.tika.pipes.grpc.proto.ListFetchersRequest;
import org.apache.tika.pipes.grpc.proto.SaveFetcherRequest;
import org.apache.tika.pipes.grpc.proto.TikaGrpc;

public class FetcherAdminClient {

    public static void main(String[] args) throws InterruptedException {
        String target = args[0];
        String uploadsDir = args[1]; // a directory the server can read
        ManagedChannel channel = Grpc.newChannelBuilder(target, InsecureChannelCredentials.create()).build();
        try {
            TikaGrpc.TikaBlockingStub tika = TikaGrpc.newBlockingStub(channel)
                    .withDeadlineAfter(2, TimeUnit.MINUTES);

            String schema = tika.getFetcherConfigJsonSchema(GetFetcherConfigJsonSchemaRequest.newBuilder()
                    .setFetcherType("file-system-fetcher").build()).getFetcherConfigJsonSchema();
            System.out.println("schema: " + schema.replaceAll("\\s+", " "));

            tika.saveFetcher(SaveFetcherRequest.newBuilder()
                    .setFetcherId("bookshelf-uploads")
                    .setFetcherType("file-system-fetcher")
                    .setFetcherConfigJson("{\"basePath\": \"" + uploadsDir + "\"}")
                    .build());

            GetFetcherReply saved = tika.getFetcher(GetFetcherRequest.newBuilder()
                    .setFetcherId("bookshelf-uploads").build());
            System.out.println("saved: " + saved.getFetcherId() + " " + saved.getFetcherType()
                    + " " + saved.getParamsMap().keySet());

            tika.listFetchers(ListFetchersRequest.newBuilder().setNumFetchersPerPage(50).build())
                    .getGetFetcherRepliesList()
                    .forEach(f -> System.out.println("listed: " + f.getFetcherId()));

            FetchAndParseReply reply = tika.fetchAndParse(FetchAndParseRequest.newBuilder()
                    .setFetcherId("bookshelf-uploads").setFetchKey("catalogue.pdf").build());
            System.out.println("parse: " + reply.getStatus());
        } finally {
            channel.shutdown().awaitTermination(10, TimeUnit.SECONDS);
        }
    }
}

Against a server with allowComponentManagement: true and a file config store:

schema: { "type" : "object", "id" : "urn:jsonschema:org:apache:tika:pipes:fetcher:fs:FileSystemFetcherConfig", "properties" : { "basePath" : { "type" : "string" }, "extractFileSystemMetadata" : { "type" : "boolean" }, "allowAbsolutePaths" : { "type" : "boolean" } } }
saved: bookshelf-uploads file-system-fetcher [basePath]
listed: bookshelf-inbox
listed: bookshelf-uploads
parse: PARSE_SUCCESS

Against the default server, GetFetcherConfigJsonSchema still works and SaveFetcher fails with PERMISSION_DENIED.

Source: TikaGrpcServerTest.java at 4.1.0 (testFetcherCrud).

With the default memory config store, a fetcher saved at runtime is not visible to the workers. In the test run, SaveFetcher and GetFetcher succeeded, but every FetchAndParse with the new id returned FETCHER_NOT_FOUND, and the worker logged Fetcher 'bookshelf-uploads' not found. The configured FetcherManager supports: [bookshelf-inbox]. The in-memory store lives in the gRPC JVM, and the forked workers only see the configuration file. Switching to the file-based store, which the forked workers read too, made the same calls succeed:

{
  "pipes": {
    "configStoreType": "file",
    "configStoreParams": "{\"path\": \"/var/lib/bookshelf-grpc/config-store.json\"}"
  }
}

configStoreParams is a JSON string, not an object. The store file holds every fetcher configuration, including credentials, in plain text: give it the same protection as the secrets it contains. The ignite store described in the README needs tika-pipes-config-store-ignite and its dependencies, which neither the jar nor the image ships.

If your fetchers are known in advance, which is the common case, declare them in tika-config.json, take the credentials from environment variables with ${env:NAME} (see Configuration) and leave allowComponentManagement off.

Health checks and reflection

TikaGrpcServer registers two standard gRPC services next to Tika:

  • Health (grpc.health.v1.Health), through HealthStatusManager. Both the overall service (the empty name "") and TikaGrpcServer report SERVING; tika.Tika returns NOT_FOUND. The status is set once at start-up and does not reflect the state of the worker pool, so it is a liveness signal, not proof that parsing works.

  • Server reflection (ProtoReflectionServiceV1), which lets tools such as grpcurl and Postman discover the service without the proto file. Not exercised for this page. Reflection also tells anyone who can reach the port exactly which RPCs exist.

Kubernetes has native gRPC probes, which call the health service directly; a readiness probe that parses a tiny known document through a fetcher is a stronger signal. Probe configuration is on Docker & Kubernetes Deployment.

Transport security: TLS and mutual TLS

TLS is configured only on the command line; there are three modes:

Mode Server flags Client side

Plaintext (default, and the Docker image’s default)

none

InsecureChannelCredentials. Trusted networks only.

Server TLS

--secure --cert-chain server.pem --private-key server.key

TlsChannelCredentials with the CA that signed server.pem. Clients are not authenticated.

Mutual TLS

add --trust-cert-collection ca.pem --client-auth-required

TlsChannelCredentials with the CA and a client certificate and key signed by ca.pem

java -cp "$(cat cp.txt)" org.apache.tika.pipes.grpc.TikaGrpcServer -c tika-config.json -p 9443 \
  --client-auth-required \
  --cert-chain certs/server.pem --private-key certs/server.key \
  --trust-cert-collection certs/ca.pem
INFO  org.apache.tika.pipes.grpc.TikaGrpcServer --client-auth-required implies --secure; enabling TLS.
INFO  org.apache.tika.pipes.grpc.TikaGrpcServer Server started, listening on 9443

With Docker, append the same flags to the container command and mount the certificate files. The matching Java client:

package demo;

import java.io.File;
import java.io.IOException;
import java.util.concurrent.TimeUnit;

import io.grpc.ChannelCredentials;
import io.grpc.Grpc;
import io.grpc.ManagedChannel;
import io.grpc.StatusRuntimeException;
import io.grpc.TlsChannelCredentials;

import org.apache.tika.pipes.grpc.proto.FetchAndParseReply;
import org.apache.tika.pipes.grpc.proto.FetchAndParseRequest;
import org.apache.tika.pipes.grpc.proto.TikaGrpc;

public class BookshelfTlsClient {

    // args: target ca.pem [client.pem client.key]
    public static void main(String[] args) throws IOException, InterruptedException {
        TlsChannelCredentials.Builder tls = TlsChannelCredentials.newBuilder()
                .trustManager(new File(args[1]));
        if (args.length > 3) {
            tls.keyManager(new File(args[2]), new File(args[3]));
        }
        ChannelCredentials credentials = tls.build();
        ManagedChannel channel = Grpc.newChannelBuilder(args[0], credentials).build();
        try {
            FetchAndParseReply reply = TikaGrpc.newBlockingStub(channel)
                    .withDeadlineAfter(1, TimeUnit.MINUTES)
                    .fetchAndParse(FetchAndParseRequest.newBuilder()
                            .setFetcherId("bookshelf-inbox").setFetchKey("catalogue.pdf").build());
            System.out.println(reply.getStatus() + " " + reply.getFieldsMap().get("dc:title"));
        } catch (StatusRuntimeException e) {
            System.out.println("failed: " + e.getStatus().getCode() + " " + e.getStatus().getDescription());
        } finally {
            channel.shutdownNow().awaitTermination(10, TimeUnit.SECONDS);
        }
    }
}

Results against the mutual-TLS server, with test certificates from a throwaway CA (server certificate with subjectAltName=DNS:localhost):

# with client.pem and client.key
PARSE_SUCCESS Spring catalogue
# with the CA only, no client certificate
failed: UNAVAILABLE ssl exception
# plaintext client (InsecureChannelCredentials)
io.grpc.StatusRuntimeException: UNAVAILABLE: Network closed for unknown reason

Source: Apache Tika 4.1.x, Tika gRPC, Transport security (TLS), gRPC, Authentication and TikaGrpcServerTlsTest.java at 4.1.0.

Mutual TLS authenticates the caller’s certificate, but tika-grpc has no per-caller authorisation: every client with a certificate from ca.pem can call every enabled RPC. Use a CA dedicated to tika-grpc clients, not your organisation’s general CA. Certificate rotation needs a restart, since the files are read at start-up.

Kubernetes and service meshes

Pod networking is flat by default: any pod can reach any port, unencrypted. Two controls matter, and neither is applied automatically:

  • Transport. A service mesh with sidecar mTLS (Istio, Linkerd) encrypts and authenticates pod-to-pod traffic. Where the mesh does this, tika-grpc can run without --secure and leave transport security to the mesh. Mesh mTLS authenticates who opened the connection; it does not authorise what that caller may do. Mesh authorisation policies (for example an Istio AuthorizationPolicy naming the intake service’s identity) can add that, outside Tika.

  • Reachability. A NetworkPolicy that admits only the trusted client pods to the tika-grpc port. The Tika documentation calls this required, not optional: because tika-grpc has no per-caller authorisation, the set of pods that can reach the port is the set of pods that can use it.

A minimal policy for the Bookshelf intake service (port 9090, as in the image; not applied to a cluster for this page):

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: tika-grpc-from-intake-only
spec:
  podSelector:
    matchLabels:
      app: tika-grpc
  policyTypes: ["Ingress"]
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: bookshelf-intake
      ports:
        - protocol: TCP
          port: 9090

Why tika-grpc is "more exposed by default" than tika-server

The 4.1.x documentation’s primary rule for tika-grpc is trusted callers only, and it calls tika-grpc "even more exposed by default than tika-server". The reasons, all visible in the 4.1.0 source:

  1. No transport security or authentication by default, in the jar and in the image.

  2. The core surface is the dangerous one. tika-server’s always-on endpoints parse bytes the caller sends; its fetcher-based /pipes and /async endpoints are off unless allowPipes is set. In tika-grpc, FetchAndParse is the fetcher-based surface and is always on: anyone who can reach the port can make the server read anything its fetchers can reach, with the server’s credentials. An http-fetcher turns that into server-side request forgery towards internal hosts; an s3-fetcher into reading any key in the bucket.

  3. No per-caller authorisation, even with mutual TLS.

  4. Error messages and logs leak detail. error_message carries server-side stack traces and absolute paths.

  5. Reflection advertises the API to anyone who connects.

  6. The component-management flag turns configuration into an attack surface: a new fetcher can escape a base path or point at an internal host, and the read RPCs return stored secrets.

Some protections hold even so. The file-system fetcher rejects keys that resolve outside basePath: /etc/hosts and ../../other/manuscript.docx both came back as UNSPECIFIED_CRASH, with java.lang.SecurityException: fetchKey must resolve to be a descendant of the 'basePath' in the server log. Note that each such request crashed and restarted a worker, so repeated path-traversal attempts cost restarts as well as log noise.

What to do, in order of importance:

  • Put tika-grpc on a private network segment, bind or map its port to 127.0.0.1 on single hosts, and restrict it with a NetworkPolicy in Kubernetes.

  • Use mutual TLS (or mesh mTLS plus mesh authorisation) with a dedicated client CA.

  • Give each fetcher the narrowest scope: a dedicated read-only directory, bucket prefix or database user. Do not configure an http-fetcher on an instance reachable by anything you do not fully trust.

  • Leave allowPerRequestConfig and allowComponentManagement off, or enable them only on a separate internal instance.

  • Set exception-reporting in the server’s parse-context to bound and redact the exception text that ends up in metadata, and never forward error_message to end users.

  • Keep the workers bounded with timeouts, write limits and heap settings (Robustness, Limits & Timeouts).

The wider threat model, including what is and is not a Tika vulnerability, is on Security.

Changes from 3.x to 4.x

tika-grpc did not exist in Tika 1.x (the version Tika in Action describes) or 2.x; it first shipped in 3.0.0-BETA2.

Area 3.x (3.3.2) 4.x (4.1.0)

Java package of the generated classes

org.apache.tika (org.apache.tika.TikaGrpc, org.apache.tika.FetchAndParseRequest, …​)

org.apache.tika.pipes.grpc.proto (since 4.0.0, TIKA-4808). Proto package tika and service Tika unchanged, so the wire protocol is the same and non-Java clients are unaffected; Java clients must change their imports.

Fetcher identification in messages

fetcher_class, a fully qualified class name

fetcher_type, the short factory name (file-system-fetcher)

FetchAndParseRequest

fetcher_id, fetch_key, additional_fetch_config_json

adds emitter_id (unused in 4.1.0) and parse_context_json

Pipes-iterator RPCs

none

SavePipesIterator, GetPipesIterator, DeletePipesIterator

Capability flags

none: every RPC open to every caller

grpc.allowPerRequestConfig and grpc.allowComponentManagement, both default-deny (TIKA-4764)

Concurrency

one shared single-threaded PipesClient

PipesParser pool sized by pipes.numClients, CLIENT_UNAVAILABLE_WITHIN_MS on saturation, useSharedServer honoured (TIKA-4815)

Fetchers, emitters, iterators

classes on the classpath, XML configuration

pf4j plugin zips under plugin-roots, JSON configuration

Shading

gRPC shaded inside the jar

not shaded (TIKA-4709)

Distribution

jar on Maven Central

thin jar on Maven Central plus the Docker image; no zip (TIKA-4723)

--client-auth-required

did not imply --secure, and was skipped when the trust collection was missing

implies --secure, and refuses to start without a readable --trust-cert-collection

Server configuration

tika-config.xml

tika-config.json, with Markdown as the default content handler

Migrating a 3.x Java client is mostly a search-and-replace of imports, plus setFetcherClass(...) becoming setFetcherType(...), plus the capability flags on the server if the client manages fetchers. The tk: metadata keys in fields change too; see Metadata.

Pitfalls

  • Expecting to upload bytes. There is no such RPC. The server must be able to reach the document through a fetcher.

  • Wrong port. 50052 for the jar, 9090 for the image; the README’s examples say 50052 for the image.

  • EMITTER_NOT_FOUND on large documents. Set pipes.emitStrategy to PASSBACK_ALL.

  • RESOURCE_EXHAUSTED on large documents. Raise the client’s maxInboundMessageSize and cap the extract with the handler factory’s writeLimit.

  • Wrong content for documents with attachments. The flat fields map takes the last document’s value; use CONCATENATE.

  • Runtime fetchers that do not work. With the memory config store the workers never see them (FETCHER_NOT_FOUND); use the file store or declare fetchers in the configuration.

  • Treating a reply as success. Check status; FETCH_EXCEPTION, TIMEOUT and the crash statuses arrive as normal replies.

  • One stream for throughput. A bidirectional stream parses its documents one at a time.

  • Relying on image environment variables (TIKA_GRPC_MAX_INBOUND_MESSAGE_SIZE, TIKA_GRPC_NUM_THREADS, JAVA_OPTS) that the 4.1.0 entrypoint does not use.

  • Copying 3.x client code. The generated classes moved to org.apache.tika.pipes.grpc.proto.

  • Exposing the port. tika-grpc is not a security boundary; see above.