This is an automated email from the ASF dual-hosted git repository.
tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git
The following commit(s) were added to refs/heads/main by this push:
new 3f81aeec5f TIKA-4912: retry a 429/502/503/504 answer from a hosted
inference engine (#3204)
3f81aeec5f is described below
commit 3f81aeec5fb911da664c24677a1514aad8f83804
Author: Tim Allison <[email protected]>
AuthorDate: Fri Sep 18 16:20:53 2026 -0400
TIKA-4912: retry a 429/502/503/504 answer from a hosted inference engine
(#3204)
---
CHANGES.txt | 3 +
.../ROOT/pages/configuration/inference.adoc | 24 +++-
.../pages/configuration/parsers/vlm-parsers.adoc | 8 +-
.../java/org/apache/tika/http/TikaHttpClient.java | 138 +++++++++++++++++++--
.../org/apache/tika/http/TikaHttpClientTest.java | 102 +++++++++++++++
.../org/apache/tika/http/TikaTestHttpServer.java | 22 +++-
.../tika/inference/OpenAIEmbeddingEngine.java | 17 ++-
.../apache/tika/parser/vlm/AbstractVLMParser.java | 7 +-
.../org/apache/tika/parser/vlm/VLMOCRConfig.java | 19 ++-
9 files changed, 318 insertions(+), 22 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 7ed0b8a415..8bd6c9aed9 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,8 @@
Release 4.1.0 - unreleased
+ * Calls to a hosted inference engine retry a 429, 502, 503 or 504 answer
+ with a jittered backoff (TIKA-4912).
+
* Handle inference exceptions more robustly in PDFParser (TIKA-4911).
* Add ffmpeg to tika-server's "full" Docker image (TIKA-4910).
diff --git a/docs/modules/ROOT/pages/configuration/inference.adoc
b/docs/modules/ROOT/pages/configuration/inference.adoc
index 8bc792c5d4..9947d2d834 100644
--- a/docs/modules/ROOT/pages/configuration/inference.adoc
+++ b/docs/modules/ROOT/pages/configuration/inference.adoc
@@ -50,8 +50,21 @@ different models. Secrets and paths live here and only here.
`openai-embedding-engine` posts to `/v1/embeddings` in the OpenAI shape and
sends up to
`maxBatchSize` images per request. Options: `baseUrl`, `model`, `apiKey`,
`timeoutMillis`
-(120000), `maxBatchSize` (32), `embeddingsPath`, `apiKeyHeaderName`,
`apiKeyPrefix`,
-`requestParameters`, `imageInput`, `mediaInput` (see <<media>>).
+(120000), `maxBatchSize` (32), `maxRetries` (4), `embeddingsPath`,
`apiKeyHeaderName`,
+`apiKeyPrefix`, `requestParameters`, `imageInput`, `mediaInput` (see
<<media>>).
+
+[#concurrency]
+=== Hosted engines and concurrency
+
+A hosted engine caps the requests in flight per key and answers the excess
with 429 at once,
+usually with no `Retry-After`; a saturated one answers 502, 503 or 504. Those
four answers are
+retried, `maxRetries` times (4 unless set, 0 to fail at once), with a short
jittered backoff
+from 250 ms doubling to 4 s, or the `Retry-After` the server sends, and never
past the budget
+the parse granted the call. Any other answer, a 401 or a 400, fails at once.
The retries cover
+collisions between a few parse workers; they do not turn more workers than the
key allows
+into throughput. Keep `numClients` (tika-server, Pipes) at or below the key's
concurrency,
+counting every engine the workers call, and raise the key's tier before
raising the workers.
+The VLM parsers take the same `maxRetries`.
=== Vendor keys: `requestParameters`
@@ -419,6 +432,13 @@ MEDIA bindings.
|Items per request across modalities; the segment cuts are 100-500 KB each as
base64, so
keep the request well under Jina's body limit (start at 8).
+|`maxRetries`
+|Jina's limit is concurrency per key, not requests per minute: 2 requests in
flight on a free
+key (1 on the OCR endpoint), 50 on a paid key, 500 on premium; the excess gets
a 429 at once
+with no `Retry-After`, and the limit clears as soon as an in-flight request
returns. The
+default 4 retries absorb the collisions of two or three workers; more workers
than the key's
+concurrency need a bigger key, not more retries (see <<concurrency>>).
+
|`requestParameters`
|`task`: `retrieval.passage` for indexing (default nothing is sent; set it),
`retrieval.query`
for queries, `text-matching`, `classification`, `separation`. `dimensions`: a
Matryoshka
diff --git a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
index b9c394a510..a9e5e4e721 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
@@ -195,6 +195,12 @@ All three parsers share the same configuration POJO
(`VLMOCRConfig`):
|`120000`
|HTTP read timeout in milliseconds.
+|`maxRetries`
+|`4`
+|Retries of a 429, 502, 503 or 504 answer, with a jittered backoff inside the
parse budget;
+`0` fails at once. A hosted service caps requests in flight per key, so see
+xref:configuration/inference.adoc#concurrency[Hosted engines and concurrency].
+
|`apiKey`
|`""` (empty)
|API key. Format depends on the parser (Bearer header, x-api-key header,
@@ -290,7 +296,7 @@ rejects cost- and security-sensitive changes with an
exception:
|May be lowered, never raised above the initialization-time value — that
ceiling is what bounds
cost on a paid endpoint.
-|`maxImagePixels`, `allowRuntimePrompt`
+|`maxImagePixels`, `maxRetries`, `allowRuntimePrompt`
|Always blocked.
|`prompt`, `textRecognizer`
diff --git
a/tika-parsers/tika-http-jdk/src/main/java/org/apache/tika/http/TikaHttpClient.java
b/tika-parsers/tika-http-jdk/src/main/java/org/apache/tika/http/TikaHttpClient.java
index af431ac896..f15da1652d 100644
---
a/tika-parsers/tika-http-jdk/src/main/java/org/apache/tika/http/TikaHttpClient.java
+++
b/tika-parsers/tika-http-jdk/src/main/java/org/apache/tika/http/TikaHttpClient.java
@@ -25,11 +25,16 @@ import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import org.apache.tika.config.ParseTimeout;
@@ -49,12 +54,28 @@ import org.apache.tika.parser.ParseContext;
* Obtain an instance via {@link #build(int)} and close it when done to release
* the underlying executor.
*
+ * <p>
+ * A hosted inference engine caps the requests in flight per key and answers
the excess with
+ * 429 at once, usually without a Retry-After; a saturated one answers 502,
503 or 504. Those
+ * four statuses are retried up to {@code maxRetries} times with a short
jittered backoff
+ * (250 ms doubling to 4 s), or the Retry-After the server sends, never
sleeping past the
+ * budget the parse granted the call. Any other status fails at once. The
retries cover
+ * collisions between a few parse workers, not a sustained excess of workers
over the key's
+ * concurrency.
* @since Apache Tika 4.0
*/
public class TikaHttpClient implements Closeable {
private static final String JSON_CONTENT_TYPE = "application/json;
charset=utf-8";
+ /** Retries of a 429/502/503/504 answer unless {@link #build(int, int)}
says otherwise. */
+ public static final int DEFAULT_MAX_RETRIES = 4;
+ private static final Set<Integer> RETRYABLE = Set.of(429, 502, 503, 504);
+ private static final long INITIAL_BACKOFF_MILLIS = 250;
+ private static final long MAX_BACKOFF_MILLIS = 4000;
+ /** A Retry-After longer than this is treated as "not now": the call fails
instead. */
+ private static final long MAX_RETRY_AFTER_MILLIS = 30_000;
+
// How often a bounded HTTP wait checkpoints the task's ParseTimeout -- see
// org.apache.tika.utils.ProcessUtils.HEARTBEAT_INTERVAL_MILLIS for the
same rationale.
private static final long HEARTBEAT_INTERVAL_MILLIS = 1000;
@@ -62,12 +83,14 @@ public class TikaHttpClient implements Closeable {
private final HttpClient httpClient;
private final ExecutorService executor;
private final int defaultTimeoutSeconds;
+ private final int maxRetries;
private TikaHttpClient(HttpClient httpClient, ExecutorService executor,
- int defaultTimeoutSeconds) {
+ int defaultTimeoutSeconds, int maxRetries) {
this.httpClient = httpClient;
this.executor = executor;
this.defaultTimeoutSeconds = defaultTimeoutSeconds;
+ this.maxRetries = maxRetries;
}
/**
@@ -76,6 +99,20 @@ public class TikaHttpClient implements Closeable {
* @param connectTimeoutSeconds TCP connection timeout in seconds
*/
public static TikaHttpClient build(int connectTimeoutSeconds) {
+ return build(connectTimeoutSeconds, DEFAULT_MAX_RETRIES);
+ }
+
+ /**
+ * Create a new {@code TikaHttpClient} with a daemon-thread executor.
+ *
+ * @param connectTimeoutSeconds TCP connection timeout in seconds
+ * @param maxRetries how many times a 429, 502, 503 or 504
answer is retried;
+ * 0 fails on the first one
+ */
+ public static TikaHttpClient build(int connectTimeoutSeconds, int
maxRetries) {
+ if (maxRetries < 0) {
+ throw new IllegalArgumentException("maxRetries must be >= 0, not "
+ maxRetries);
+ }
ExecutorService executor = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "tika-http-jdk");
t.setDaemon(true);
@@ -87,7 +124,7 @@ public class TikaHttpClient implements Closeable {
.followRedirects(HttpClient.Redirect.NORMAL)
.version(HttpClient.Version.HTTP_1_1)
.build();
- return new TikaHttpClient(client, executor, connectTimeoutSeconds);
+ return new TikaHttpClient(client, executor, connectTimeoutSeconds,
maxRetries);
}
/**
@@ -223,18 +260,49 @@ public class TikaHttpClient implements Closeable {
}
}
+ /**
+ * One budget for every attempt: each retry is sent with what is left of
+ * {@code grantedMillis}, and a backoff that would run past it fails the
call with the
+ * last answer instead.
+ */
private String send(HttpRequest request, ParseContext context, long
requestedMillis, long grantedMillis)
throws IOException, TikaException {
+ long startNanos = System.nanoTime();
+ for (int attempt = 0; ; attempt++) {
+ long remaining = grantedMillis - (System.nanoTime() - startNanos)
/ 1_000_000L;
+ if (remaining <= 0) {
+ throw new TikaTimeoutException("HTTP request to " +
request.uri() + " timed out",
+ requestedMillis, grantedMillis);
+ }
+ HttpRequest attemptRequest = attempt == 0 ? request
+ : HttpRequest.newBuilder(request, (name, value) -> true)
+ .timeout(Duration.ofMillis(remaining)).build();
+ HttpResponse<String> response = sendOnce(attemptRequest, context,
requestedMillis, remaining);
+ int status = response.statusCode();
+ if (status >= 200 && status < 300) {
+ return response.body();
+ }
+ TikaException failure = new TikaException("HTTP " + status
+ + " from " + request.uri() + ": " + response.body());
+ if (!RETRYABLE.contains(status) || attempt >= maxRetries) {
+ throw failure;
+ }
+ long delay = retryDelayMillis(response, attempt);
+ long left = grantedMillis - (System.nanoTime() - startNanos) /
1_000_000L;
+ if (delay < 0 || delay >= left) {
+ throw failure;
+ }
+ sleepWithHeartbeat(delay, context);
+ }
+ }
+
+ private HttpResponse<String> sendOnce(HttpRequest request, ParseContext
context,
+ long requestedMillis, long
grantedMillis)
+ throws IOException, TikaException {
CompletableFuture<HttpResponse<String>> future = httpClient.sendAsync(
request,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
try {
- HttpResponse<String> response = waitWithHeartbeat(future, context,
grantedMillis,
- request.uri(), requestedMillis);
- if (response.statusCode() < 200 || response.statusCode() >= 300) {
- throw new TikaException("HTTP " + response.statusCode()
- + " from " + request.uri() + ": " + response.body());
- }
- return response.body();
+ return waitWithHeartbeat(future, context, grantedMillis,
request.uri(), requestedMillis);
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof HttpTimeoutException) {
@@ -254,6 +322,58 @@ public class TikaHttpClient implements Closeable {
}
}
+ /** The server's Retry-After (delta seconds or an HTTP date) when usable,
else backoff. */
+ static long retryDelayMillis(HttpResponse<String> response, int attempt) {
+ String retryAfter =
response.headers().firstValue("Retry-After").orElse(null);
+ if (retryAfter != null) {
+ long millis = parseRetryAfterMillis(retryAfter.trim());
+ if (millis >= 0) {
+ return millis > MAX_RETRY_AFTER_MILLIS ? -1 : millis;
+ }
+ }
+ return backoffMillis(attempt);
+ }
+
+ /** {@code 250 ms * 2^attempt}, capped at 4 s, with 25% jitter so workers
do not retry in step. */
+ static long backoffMillis(int attempt) {
+ long base = Math.min(MAX_BACKOFF_MILLIS, INITIAL_BACKOFF_MILLIS <<
Math.min(attempt, 20));
+ long jitter = base / 4;
+ return base - jitter + ThreadLocalRandom.current().nextLong(2 * jitter
+ 1);
+ }
+
+ /** Millis to wait, or -1 when the header is neither a delta nor an HTTP
date. */
+ static long parseRetryAfterMillis(String value) {
+ try {
+ return Long.parseLong(value) * 1000L;
+ } catch (NumberFormatException e) {
+ // an HTTP date, then
+ }
+ try {
+ ZonedDateTime at = ZonedDateTime.parse(value,
DateTimeFormatter.RFC_1123_DATE_TIME);
+ return Math.max(0, at.toInstant().toEpochMilli() -
System.currentTimeMillis());
+ } catch (DateTimeParseException e) {
+ return -1;
+ }
+ }
+
+ /** Sleeps in heartbeat slices so the stall detector sees the wait as
progress. */
+ private static void sleepWithHeartbeat(long millis, ParseContext context)
throws IOException {
+ long deadline = System.nanoTime() + millis * 1_000_000L;
+ try {
+ while (true) {
+ long left = (deadline - System.nanoTime()) / 1_000_000L;
+ if (left <= 0) {
+ return;
+ }
+ Thread.sleep(Math.min(left, HEARTBEAT_INTERVAL_MILLIS));
+ ParseTimeout.checkpoint(context);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("HTTP retry interrupted", e);
+ }
+ }
+
/**
* Waits for the future to complete, polling in up to {@value
#HEARTBEAT_INTERVAL_MILLIS}
* ms increments and checkpointing {@code context}'s {@link ParseTimeout}
on each
diff --git
a/tika-parsers/tika-http-jdk/src/test/java/org/apache/tika/http/TikaHttpClientTest.java
b/tika-parsers/tika-http-jdk/src/test/java/org/apache/tika/http/TikaHttpClientTest.java
index df0d34af08..a2a296fb6c 100644
---
a/tika-parsers/tika-http-jdk/src/test/java/org/apache/tika/http/TikaHttpClientTest.java
+++
b/tika-parsers/tika-http-jdk/src/test/java/org/apache/tika/http/TikaHttpClientTest.java
@@ -18,6 +18,7 @@ package org.apache.tika.http;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map;
@@ -26,6 +27,7 @@ import org.junit.jupiter.api.Test;
import org.apache.tika.config.ParseTimeout;
import org.apache.tika.config.TimeoutLimits;
+import org.apache.tika.exception.TikaException;
import org.apache.tika.exception.TikaTimeoutException;
import org.apache.tika.http.TikaTestHttpServer.MockResponse;
import org.apache.tika.parser.ParseContext;
@@ -182,4 +184,104 @@ public class TikaHttpClientTest {
"no HTTP request should have been attempted at all with a
0ms granted budget");
}
}
+
+ /** A concurrency-capped engine answers 429 at once with no Retry-After:
the retry succeeds. */
+ @Test
+ public void testRetriesA429ThenSucceeds() throws Exception {
+ try (TikaTestHttpServer server = new TikaTestHttpServer();
+ TikaHttpClient client = TikaHttpClient.build(30, 4)) {
+ server.enqueue(new MockResponse(429, "{\"detail\":\"Concurrency
limit exceeded\"}"));
+ server.enqueue(new MockResponse(429, "{\"detail\":\"Concurrency
limit exceeded\"}"));
+ server.enqueue(new MockResponse(200, "{\"ok\":true}"));
+ long start = System.currentTimeMillis();
+ String body = client.postJson(server.url(), "{}", Map.of(),
10_000, new ParseContext());
+ long elapsed = System.currentTimeMillis() - start;
+ assertEquals("{\"ok\":true}", body);
+ assertEquals(3, server.getRequestCount());
+ assertTrue(elapsed >= 187 + 375, "two backoffs, 250 and 500 ms
less jitter: " + elapsed);
+ }
+ }
+
+ @Test
+ public void testNonRetryableStatusFailsAtOnce() throws Exception {
+ try (TikaTestHttpServer server = new TikaTestHttpServer();
+ TikaHttpClient client = TikaHttpClient.build(30, 4)) {
+ server.enqueue(new MockResponse(401, "{\"detail\":\"Invalid API
key\"}"));
+ server.enqueue(new MockResponse(200, "{\"ok\":true}"));
+ TikaException e = assertThrows(TikaException.class,
+ () -> client.postJson(server.url(), "{}", Map.of(),
10_000, new ParseContext()));
+ assertTrue(e.getMessage().contains("HTTP 401"), e.getMessage());
+ assertEquals(1, server.getRequestCount());
+ }
+ }
+
+ @Test
+ public void testGivesUpAfterMaxRetriesWithTheLastAnswer() throws Exception
{
+ try (TikaTestHttpServer server = new TikaTestHttpServer();
+ TikaHttpClient client = TikaHttpClient.build(30, 2)) {
+ for (int i = 0; i < 4; i++) {
+ server.enqueue(new MockResponse(503, "{\"detail\":\"busy " + i
+ "\"}"));
+ }
+ TikaException e = assertThrows(TikaException.class,
+ () -> client.postJson(server.url(), "{}", Map.of(),
10_000, new ParseContext()));
+ assertTrue(e.getMessage().contains("busy 2"), "the third answer is
the one reported: " + e.getMessage());
+ assertEquals(3, server.getRequestCount(), "one attempt plus two
retries");
+ }
+ }
+
+ @Test
+ public void testZeroRetriesFailsOnTheFirst429() throws Exception {
+ try (TikaTestHttpServer server = new TikaTestHttpServer();
+ TikaHttpClient client = TikaHttpClient.build(30, 0)) {
+ server.enqueue(new MockResponse(429, "{}"));
+ server.enqueue(new MockResponse(200, "{}"));
+ assertThrows(TikaException.class,
+ () -> client.postJson(server.url(), "{}", Map.of(),
10_000, new ParseContext()));
+ assertEquals(1, server.getRequestCount());
+ }
+ }
+
+ @Test
+ public void testRetryAfterHeaderSetsTheDelay() throws Exception {
+ try (TikaTestHttpServer server = new TikaTestHttpServer();
+ TikaHttpClient client = TikaHttpClient.build(30, 4)) {
+ server.enqueue(new MockResponse(429, "{}", Map.of("Retry-After",
"1")));
+ server.enqueue(new MockResponse(200, "{\"ok\":true}"));
+ long start = System.currentTimeMillis();
+ client.postJson(server.url(), "{}", Map.of(), 10_000, new
ParseContext());
+ long elapsed = System.currentTimeMillis() - start;
+ assertTrue(elapsed >= 1000, "waited the second the server asked
for: " + elapsed);
+ assertEquals(2, server.getRequestCount());
+ }
+ }
+
+ /** A backoff that would run past the parse budget is not taken: the last
answer fails the call. */
+ @Test
+ public void testBackoffNeverSleepsPastTheBudget() throws Exception {
+ try (TikaTestHttpServer server = new TikaTestHttpServer();
+ TikaHttpClient client = TikaHttpClient.build(30, 4)) {
+ server.enqueue(new MockResponse(429, "{}", Map.of("Retry-After",
"5")));
+ server.enqueue(new MockResponse(200, "{}"));
+ long start = System.currentTimeMillis();
+ TikaException e = assertThrows(TikaException.class,
+ () -> client.postJson(server.url(), "{}", Map.of(), 800,
new ParseContext()));
+ long elapsed = System.currentTimeMillis() - start;
+ assertTrue(e.getMessage().contains("HTTP 429"), e.getMessage());
+ assertTrue(elapsed < 800, "failed without waiting out the budget:
" + elapsed);
+ assertEquals(1, server.getRequestCount());
+ }
+ }
+
+ @Test
+ public void testRetryAfterParsing() {
+ assertEquals(7000, TikaHttpClient.parseRetryAfterMillis("7"));
+ assertEquals(-1, TikaHttpClient.parseRetryAfterMillis("soon"));
+ assertEquals(0, TikaHttpClient.parseRetryAfterMillis("Wed, 21 Oct 2015
07:28:00 GMT"),
+ "a date in the past is now");
+ for (int attempt = 0; attempt < 6; attempt++) {
+ long b = TikaHttpClient.backoffMillis(attempt);
+ long base = Math.min(4000, 250L << attempt);
+ assertTrue(b >= base - base / 4 && b <= base + base / 4, attempt +
": " + b);
+ }
+ }
}
diff --git
a/tika-parsers/tika-http-jdk/src/test/java/org/apache/tika/http/TikaTestHttpServer.java
b/tika-parsers/tika-http-jdk/src/test/java/org/apache/tika/http/TikaTestHttpServer.java
index 9749c74b2c..f042bf21cd 100644
---
a/tika-parsers/tika-http-jdk/src/test/java/org/apache/tika/http/TikaTestHttpServer.java
+++
b/tika-parsers/tika-http-jdk/src/test/java/org/apache/tika/http/TikaTestHttpServer.java
@@ -62,13 +62,20 @@ public class TikaTestHttpServer implements Closeable {
* after headers have already arrived,
distinct
* from an overall-slow response
*/
- public record MockResponse(int status, String body, long delayMillis, long
bodyDelayAfterHeadersMillis) {
+ public record MockResponse(int status, String body, long delayMillis,
+ long bodyDelayAfterHeadersMillis, Map<String,
String> headers) {
public MockResponse(int status, String body) {
- this(status, body, 0, 0);
+ this(status, body, 0, 0, Map.of());
}
-
public MockResponse(int status, String body, long delayMillis) {
- this(status, body, delayMillis, 0);
+ this(status, body, delayMillis, 0, Map.of());
+ }
+ public MockResponse(int status, String body, long delayMillis, long
bodyDelayAfterHeadersMillis) {
+ this(status, body, delayMillis, bodyDelayAfterHeadersMillis,
Map.of());
+ }
+ /** Extra response headers, e.g. {@code Retry-After}. */
+ public MockResponse(int status, String body, Map<String, String>
headers) {
+ this(status, body, 0, 0, headers);
}
}
@@ -198,8 +205,11 @@ public class TikaTestHttpServer implements Closeable {
"HTTP/1.1 " + resp.status() + " " + statusText + "\r\n"
+ "Content-Type: application/json\r\n"
+ "Content-Length: " + responseBytes.length + "\r\n"
- + "Connection: close\r\n"
- + "\r\n";
+ + "Connection: close\r\n";
+ for (Map.Entry<String, String> h : resp.headers().entrySet()) {
+ responseHeaders += h.getKey() + ": " + h.getValue() + "\r\n";
+ }
+ responseHeaders += "\r\n";
out.write(responseHeaders.getBytes(StandardCharsets.US_ASCII));
out.flush();
if (resp.bodyDelayAfterHeadersMillis() > 0) {
diff --git
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingEngine.java
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingEngine.java
index 31451b9118..e4433ed98b 100644
---
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingEngine.java
+++
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingEngine.java
@@ -67,6 +67,7 @@ public class OpenAIEmbeddingEngine implements
EmbeddingEngine, Initializable {
private Map<String, Object> requestParameters = new LinkedHashMap<>();
private String imageInput = IMAGE_INPUT_OBJECT;
private String mediaInput = MEDIA_INPUT_NONE;
+ private int maxRetries = TikaHttpClient.DEFAULT_MAX_RETRIES;
public static final String MEDIA_INPUT_NONE = "none";
public static final String MEDIA_INPUT_OBJECT = "object";
@@ -85,6 +86,9 @@ public class OpenAIEmbeddingEngine implements
EmbeddingEngine, Initializable {
if (StringUtils.isBlank(baseUrl)) {
throw new TikaConfigException("openai-embedding-engine needs a
baseUrl");
}
+ if (maxRetries < 0) {
+ throw new TikaConfigException("maxRetries must be at least 0");
+ }
if (maxBatchSize < 1) {
throw new TikaConfigException("maxBatchSize must be at least 1");
}
@@ -107,7 +111,7 @@ public class OpenAIEmbeddingEngine implements
EmbeddingEngine, Initializable {
+ "must stay the default");
}
}
- httpClient = TikaHttpClient.build(30);
+ httpClient = TikaHttpClient.build(30, maxRetries);
}
@Override
@@ -175,7 +179,7 @@ public class OpenAIEmbeddingEngine implements
EmbeddingEngine, Initializable {
private List<float[]> post(ObjectNode root, int expected, ParseContext
context)
throws IOException, TikaException {
if (httpClient == null) {
- httpClient = TikaHttpClient.build(30);
+ httpClient = TikaHttpClient.build(30, maxRetries);
}
Map<String, String> headers = new HashMap<>();
if (!StringUtils.isBlank(apiKey)) {
@@ -267,6 +271,15 @@ public class OpenAIEmbeddingEngine implements
EmbeddingEngine, Initializable {
this.timeoutMillis = timeoutMillis;
}
+ public int getMaxRetries() {
+ return maxRetries;
+ }
+
+ /** Retries of a 429/502/503/504 answer, with backoff inside the parse
budget; 0 fails at once. */
+ public void setMaxRetries(int maxRetries) {
+ this.maxRetries = maxRetries;
+ }
+
@Override
public int getMaxBatchSize() {
return maxBatchSize;
diff --git
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
index e901ece43d..95690f4e02 100644
---
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
+++
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
@@ -380,7 +380,7 @@ public abstract class AbstractVLMParser implements Parser,
Initializable, Closea
}
private TikaHttpClient buildHttpClient() {
- return TikaHttpClient.build(30);
+ return TikaHttpClient.build(30, defaultConfig.getMaxRetries());
}
// ---- delegating config getters/setters --------------------------------
@@ -429,6 +429,11 @@ public abstract class AbstractVLMParser implements Parser,
Initializable, Closea
defaultConfig.setTimeoutMillis(timeoutMillis);
}
+ /** Retries of a 429/502/503/504 answer from the service; 0 fails at once.
*/
+ public void setMaxRetries(int maxRetries) {
+ defaultConfig.setMaxRetries(maxRetries);
+ }
+
public String getApiKey() {
return defaultConfig.getApiKey();
}
diff --git
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/VLMOCRConfig.java
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/VLMOCRConfig.java
index 2951411043..22b716be88 100644
---
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/VLMOCRConfig.java
+++
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/VLMOCRConfig.java
@@ -67,6 +67,9 @@ public class VLMOCRConfig implements Serializable {
*/
private long timeoutMillis = 120_000;
+ /** Retries of a 429/502/503/504 answer from the service; 0 fails at once.
*/
+ private int maxRetries = 4;
+
/** Optional API key for authenticated endpoints. Empty means no auth. */
private String apiKey = "";
@@ -163,6 +166,14 @@ public class VLMOCRConfig implements Serializable {
this.timeoutMillis = timeoutMillis;
}
+ public int getMaxRetries() {
+ return maxRetries;
+ }
+
+ public void setMaxRetries(int maxRetries) {
+ this.maxRetries = maxRetries;
+ }
+
public String getApiKey() {
return apiKey;
}
@@ -241,7 +252,7 @@ public class VLMOCRConfig implements Serializable {
* and cost-sensitive fields at parse time.
* <p>
* <b>Always blocked:</b> {@code baseUrl}, {@code apiKey}, {@code model},
- * {@code maxTokens}, {@code allowRuntimePrompt}.
+ * {@code maxTokens}, {@code maxRetries}, {@code allowRuntimePrompt}.
* <p>
* <b>Blocked by default (opt-in):</b> {@code prompt} — set
* {@code allowRuntimePrompt=true} at initialization time to permit
@@ -299,6 +310,12 @@ public class VLMOCRConfig implements Serializable {
+ "separate parser instance.");
}
+ @Override
+ public void setMaxRetries(int maxRetries) {
+ throw new IllegalStateException("Cannot modify maxRetries at
runtime. "
+ + "Retries are configured at initialization time.");
+ }
+
@Override
public void setPrompt(String prompt) {
if (!isAllowRuntimePrompt()) {