TikaHttpClientTest.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
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.assertTrue;

import java.util.Map;

import org.junit.jupiter.api.Test;

import org.apache.tika.config.ParseTimeout;
import org.apache.tika.config.TimeoutLimits;
import org.apache.tika.exception.TikaTimeoutException;
import org.apache.tika.http.TikaTestHttpServer.MockResponse;
import org.apache.tika.parser.ParseContext;

public class TikaHttpClientTest {

    @Test
    public void testHeartbeatFiresWhileWaitingForResponse() throws Exception {
        try (TikaTestHttpServer server = new TikaTestHttpServer();
             TikaHttpClient client = TikaHttpClient.build(30)) {
            server.enqueue(new MockResponse(200, "{\"ok\":true}", 6000));

            ParseContext context = new ParseContext();
            context.set(TimeoutLimits.class, new TimeoutLimits(60_000, 60_000));
            ParseTimeout parseTimeout = ParseTimeout.getOrCreate(context);

            Thread requester = new Thread(() -> {
                try {
                    client.get(server.url(), Map.of(), 20_000, context);
                } catch (Exception e) {
                    // surfaced via the assertion below if it prevented progress
                }
            });
            requester.start();

            try {
                // Wait a generous multiple of HEARTBEAT_INTERVAL_MILLIS (~1000ms), well short of
                // the server's 6s delay, for slack against jitter under a loaded test run.
                Thread.sleep(3000);

                // without a mid-wait checkpoint, millisSinceLastProgress() would be >= 3000
                assertTrue(parseTimeout.millisSinceLastProgress() < 3000,
                        "expected a checkpoint to have fired while the request was still in flight");
            } finally {
                requester.join(10_000);
            }
        }
    }

    @Test
    public void testNullContextDoesNotThrow() throws Exception {
        try (TikaTestHttpServer server = new TikaTestHttpServer();
             TikaHttpClient client = TikaHttpClient.build(30)) {
            server.enqueue(new MockResponse(200, "{\"ok\":true}"));

            String body = client.get(server.url(), Map.of(), 5_000);

            assertEquals("{\"ok\":true}", body);
        }
    }

    /**
     * A null context must grant the requested timeout unclipped --
     * {@code ParseTimeout.getOrCreate(null)} used to clip it against a detached
     * default-TimeoutLimits (1 hour) budget. Exercises {@code grantedMillis} directly.
     */
    @Test
    public void testNullContextGrantsRequestUnclippedEvenAboveDefaultOneHour() throws Exception {
        try (TikaHttpClient client = TikaHttpClient.build(30)) {
            long requestedTimeoutMillis = TimeoutLimits.DEFAULT_TOTAL_TASK_TIMEOUT_MILLIS + 60_000;

            assertEquals(requestedTimeoutMillis, client.grantedMillis(requestedTimeoutMillis, null),
                    "a null context must not silently clip the request against a default-TimeoutLimits budget");
        }
    }

    @Test
    public void testRequestTimeoutStillBoundsTheWait() throws Exception {
        try (TikaTestHttpServer server = new TikaTestHttpServer();
             TikaHttpClient client = TikaHttpClient.build(30)) {
            server.enqueue(new MockResponse(200, "{\"ok\":true}", 5000));

            long start = System.currentTimeMillis();
            boolean threw = false;
            try {
                client.get(server.url(), Map.of(), 1_000);
            } catch (Exception e) {
                threw = true;
            }
            long elapsed = System.currentTimeMillis() - start;

            assertTrue(threw, "a 5s server delay with a 1s request timeout must fail");
            assertTrue(elapsed < 4_000,
                    "the async polling rewrite must still honor the request timeout; took " + elapsed + "ms");
        }
    }

    @Test
    public void testSlowBodyAfterHeadersIsBoundedByClientDeadline() throws Exception {
        // TIKA-4813 follow-up: headers arrive immediately, but the body then trickles in
        // 5s later -- well past the task's 1s budget. HttpRequest.timeout() is not a
        // guaranteed bound on the full exchange in every JDK/transport scenario once
        // headers have already arrived; without waitWithHeartbeat enforcing its own
        // deadline, a document with many such calls (or one truly stuck) could stall far
        // past its configured budget instead of failing fast.
        try (TikaTestHttpServer server = new TikaTestHttpServer();
             TikaHttpClient client = TikaHttpClient.build(30)) {
            server.enqueue(new MockResponse(200, "{\"ok\":true}", 0, 5000));

            ParseContext context = new ParseContext();
            context.set(TimeoutLimits.class, new TimeoutLimits(1000, 1000));

            long start = System.currentTimeMillis();
            Exception thrown = null;
            try {
                // Request 30s -- far more than the 1s task budget -- so only the task's
                // own remaining-budget deadline (not the request's own timeout) is what
                // can be bounding this call.
                client.get(server.url(), Map.of(), 30_000, context);
            } catch (Exception e) {
                thrown = e;
            }
            long elapsed = System.currentTimeMillis() - start;

            assertTrue(thrown != null, "a 5s-delayed body under a 1s task budget must fail");
            assertInstanceOf(TikaTimeoutException.class, thrown,
                    "must be reported as a TikaTimeoutException with requested/granted info, not a bare " +
                            "IOException/HttpTimeoutException: " + thrown);
            assertTrue(elapsed < 4_000,
                    "client deadline must fire near the ~1s task budget, not wait for the 5s body; took " +
                            elapsed + "ms");
        }
    }

    @Test
    public void testExhaustedBudgetFailsFastWithoutAttemptingRequest() throws Exception {
        // TIKA-4813 follow-up: granted==0 must fail immediately (like ProcessUtils does
        // for external processes), not be floored up to a 1-second HTTP call -- a
        // document with many post-deadline calls would otherwise pay a full extra second
        // per call instead of failing fast.
        try (TikaTestHttpServer server = new TikaTestHttpServer();
             TikaHttpClient client = TikaHttpClient.build(30)) {
            server.enqueue(new MockResponse(200, "{\"ok\":true}"));

            ParseContext context = new ParseContext();
            // total=0 -> remainingMillis() is already 0 by the time anything checks it
            context.set(TimeoutLimits.class, new TimeoutLimits(0, 10_000));

            long start = System.currentTimeMillis();
            TikaTimeoutException thrown = null;
            try {
                client.get(server.url(), Map.of(), 5_000, context);
            } catch (TikaTimeoutException e) {
                thrown = e;
            }
            long elapsed = System.currentTimeMillis() - start;

            assertTrue(thrown != null, "an already-exhausted budget must throw TikaTimeoutException");
            assertEquals(0, thrown.getGrantedMillis());
            assertTrue(elapsed < 500,
                    "an exhausted budget must fail immediately, not floor up to a 1s HTTP call; took " +
                            elapsed + "ms");
            assertEquals(0, server.getRequestCount(),
                    "no HTTP request should have been attempted at all with a 0ms granted budget");
        }
    }
}