Tool calls are the bridge between an agent and the outside world, but the outside world does not always cooperate. A database query hangs. An API endpoint gets sluggish. A network packet vanishes. Without explicit timeout handling, an agent can wait forever, blocking the entire invocation and starving downstream callers. ADK Java provides a runtime timeout mechanism that interrupts long-running tools and hands control back to the agent, but knowing that a timeout fired and acting on it correctly is the tool author’s responsibility. This article walks through how the runtime enforces timeouts, how tool code detects interruption and cleans up safely, patterns for graceful degradation and retry, strategies for monitoring and testing timeouts, and how to avoid common pitfalls that turn a handled timeout into a cascading failure.

Runtime-side timeout

Every tool invocation runs under a deadline set by the RuntimeConfig. The timeout is not a soft limit that the tool can ignore; it is enforced by the ADK runtime itself, independent of the tool author’s code.

RuntimeConfig.builder()
    .toolTimeout(Duration.ofSeconds(10))
    .build();

This single line tells the runtime: if any tool does not return within 10 seconds, interrupt its thread and end the invocation. The default is typically 30 seconds. In production, choose a timeout that balances the latency budget of a user turn against the real-world response time of the slowest downstream service your tools call. A database tool that contacts a geographically distant cluster may need 15–20 seconds; a local cache lookup might be safe at 5 seconds. Timeouts are configured once per runtime instance, which means all tools in that runtime share the same deadline. If you need per-tool timeouts, you must implement them within the tool itself using a separate timer or a timeout-aware library like ScheduledExecutorService.

Advertisement

Interrupt-based cancellation

When the timeout fires, the runtime does not forcibly terminate the thread; it interrupts it. Interruption is a cooperative mechanism: the thread gets a signal that it should stop, and it is responsible for noticing and reacting. The runtime calls Thread.interrupt() on the tool’s thread, setting the interrupted flag. Code inside the tool can check this flag and exit gracefully.

public ToolResult fetchUser(String userId) throws InterruptedException {
    for (String id : userIds) {
        if (Thread.interrupted()) {
            throw new InterruptedException("Tool interrupted");
        }
        // do work
    }
    return result;
}

Many Java I/O and concurrency APIs are interrupt-aware. Thread.join(), Object.wait(), Thread.sleep(), and NIO socket operations automatically throw InterruptedException when the thread is interrupted. If your tool uses these, catching and re-throwing the exception is the natural pattern. If your tool does its own computation without calling interrupt-aware APIs, it must check Thread.interrupted() or Thread.currentThread().isInterrupted() periodically. A long computation without any check point is a footgun: the thread will not notice the interrupt until it hits an I/O or sleep call, leaving the user waiting for that operation to complete even though the timeout has already elapsed.

Cleanup on cancellation

When a tool is interrupted and must exit, it often has open resources to close: database connections, file handles, network sockets, temporary files, in-memory buffers. Leaving them open is a leak. The cleanest way to ensure cleanup is the try-with-resources statement, which automatically calls close() on any AutoCloseable even if an exception is thrown:

public ToolResult queryDatabase(String query) throws InterruptedException {
    try (Connection conn = dataSource.getConnection();
         Statement stmt = conn.createStatement()) {
        ResultSet rs = stmt.executeQuery(query);
        // process results
    } catch (InterruptedException e) {
        // connection and statement are closed automatically
        throw e;
    }
}

For resources that do not implement AutoCloseable, use try/finally. The finally block runs whether the tool exits normally or via an exception, including InterruptedException:

MyResource resource = acquireResource();
try {
    // use resource
} finally {
    resource.release();
}

Do not swallow InterruptedException; always re-throw it (or throw a new exception) after cleanup. Swallowing it clears the interrupted flag and may hide the timeout from the runtime and the agent.

Detecting timeout in tool code

Sometimes a tool must know that it ran out of time so it can return a partial result or fail gracefully with a meaningful error message. The clearest way is to catch InterruptedException:

public ToolResult processLargeDataset(List items) {
    List results = new ArrayList<>();
    try {
        for (Item item : items) {
            results.add(processItem(item));
            Thread.sleep(0);  // yield and check interrupted flag
        }
    } catch (InterruptedException e) {
        // Timeout occurred; return partial results with a note
        return ToolResult.ofPartial(
            results,
            "Processed " + results.size() + " of " + items.size() + " items before timeout"
        );
    }
    return ToolResult.ofSuccess(results);
}

Alternatively, a tool can check the interrupted flag directly and decide whether to push forward or yield:

if (Thread.currentThread().isInterrupted()) {
    throw new InterruptedException("Tool interrupted by runtime timeout");
}

The advantage of explicit detection is that you can return rich context about what was completed and what was skipped, giving the agent a chance to retry or take an alternate path. If you simply re-throw and let the exception propagate, the agent will see a tool failure but may not know how much work was done.

Graceful degradation patterns

A timeout does not always mean failure. Many tools can return a useful answer even if they run out of time partway through:

  • Partial results: A tool that fetches a list of items can return the ones it has gathered so far. A search tool can return the top 5 results instead of all 100. A summary tool can return an incomplete summary based on what it has read.
  • Cached fallback: If a tool calls an external service and the call times out, return the most recent cached response if one exists, annotated with its age so the agent knows it may be stale.
  • Approximation: Some algorithms have anytime properties: they produce better answers the longer they run, but even a short run gives a reasonable answer. A clustering or ranking algorithm can return its best-so-far result when interrupted.

These patterns shift the burden away from the agent, which would otherwise have to retry or backoff. They also preserve the user experience: a fast partial answer is often better than a slow error.

Monitoring and logging timeouts

A timeout that fires silently is a problem waiting to happen. Instrument every tool that catches InterruptedException:

private static final Logger log = LoggerFactory.getLogger(MyTool.class);

public ToolResult expensiveQuery(String query) {
    try {
        // implementation
    } catch (InterruptedException e) {
        log.warn(
            "Tool interrupted by timeout; processed {} of {} items",
            itemsProcessed, totalItems,
            e
        );
        return ToolResult.ofTimeout(
            "Query timed out after " + itemsProcessed + " items"
        );
    }
}

Log the relevant details: how much work was completed, what the tool was waiting on, whether this is the first timeout for this input or a repeat. Over time, timeouts should be rare. If a particular tool times out frequently, it is a signal to increase the global timeout, optimize the tool, or break it into smaller pieces. Set up alerts on timeout logs in production so you spot the signal before users complain.

Testing timeout scenarios

A timeout bug that only surfaces under load is expensive to find. Test timeout handling explicitly:

@Test
public void testToolHandlesTimeout() throws InterruptedException {
    // Simulate a slow operation
    ToolImpl tool = new ToolImpl(() -> {
        Thread.sleep(5000);  // Simulate work
        return "result";
    });

    // Run tool in a separate thread with a timeout
    Thread toolThread = new Thread(() -> tool.execute());
    toolThread.start();

    // Wait for timeout to fire
    Thread.sleep(1000);
    toolThread.interrupt();

    // Verify tool cleaned up its resources
    assertTrue(tool.resourcesClosed());
}

Test both the happy path (tool returns before timeout) and the timeout path. Verify that resources are actually closed, that the tool returns appropriate status, and that logs are emitted. Use a framework like awaitility to wait for async cleanup to complete.

Advertisement

Common timeout pitfalls

Pitfall 1: Ignoring InterruptedException. The worst mistake is to catch InterruptedException and ignore it, or catch a broad Exception that swallows it. The interrupted flag is cleared when the exception is thrown; if you do not re-throw, the runtime never learns that the tool was interrupted.

// WRONG: do not do this
try {
    Thread.sleep(10000);
} catch (InterruptedException e) {
    log.debug("Interrupted");
    // Swallows the interrupt; runtime unaware
}

// CORRECT: re-throw or restore the flag
try {
    Thread.sleep(10000);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();  // restore flag
    throw new ToolException("Interrupted", e);
}

Pitfall 2: Not checking the interrupted flag in compute loops. A long loop that does CPU-bound work and never calls an interrupt-aware API will not notice a timeout until it exits. Add a checkpoint:

for (int i = 0; i < 1_000_000; i++) {
    if (i % 1000 == 0 && Thread.currentThread().isInterrupted()) {
        throw new InterruptedException("Computation interrupted");
    }
    // expensive computation
}

Pitfall 3: Timeout longer than the turn deadline. If a tool’s timeout is longer than the total time the agent is willing to spend on a turn, the agent may be cancelled before the tool. Make sure tool timeouts are well within the overall request deadline.

Timeout cascades and downstream effects

A timeout in one tool can trigger a cascade of failures if not handled carefully. Scenario: a tool calls a remote API and the call times out. If the tool exits without closing the network connection, the OS will eventually close it, but in the meantime the thread pool is exhausted. Other tools trying to make requests block, and suddenly the whole agent is stalled.

Prevention is defense in depth:

  • Always use try-with-resources or try/finally for every resource acquisition.
  • Set connection timeouts independently on HTTP clients and database drivers; do not rely solely on the tool timeout.
  • Monitor thread pool exhaustion. If thread creation is blocked, log an error and alert.
  • Implement per-resource limits. A tool that opens 100 connections during a timeout will leave 100 connections open; cap the pool size so you fail fast rather than accumulate zombie resources.

Retry strategies after timeout

Sometimes a timeout is transient. The network was congested, or the API was briefly overloaded. A retry with backoff can succeed where the first attempt failed:

public ToolResult queryWithRetry(String query) {
    int maxRetries = 3;
    Duration backoff = Duration.ofMillis(100);

    for (int attempt = 1; attempt <= maxRetries; attempt++) {
        try {
            return performQuery(query);
        } catch (InterruptedException e) {
            if (attempt < maxRetries) {
                log.info("Attempt {} timed out; retrying in {}", attempt, backoff);
                try {
                    Thread.sleep(backoff.toMillis());
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw new ToolException("Retry interrupted", ie);
                }
                backoff = backoff.multipliedBy(2);
            } else {
                throw new ToolException("Query failed after " + maxRetries + " attempts", e);
            }
        }
    }
    return null;  // unreachable
}

Be cautious with retry: each attempt consumes time, so retries must be cheap and rare, or you will exceed the tool timeout on the final attempt. Use exponential backoff to avoid hammering a slow service. If a tool times out on its first attempt and you have retries left, subtract the spent time from the tool timeout before the next attempt to avoid overrunning the deadline.

Performance tuning for timeout-prone tools

If a tool frequently times out even with a generous timeout, the problem is usually not the timeout setting but the tool itself. Profile and optimize:

  • Add connection pooling. Creating a new connection on every invocation adds hundreds of milliseconds. Reuse a pool.
  • Cache results. If the same query is asked multiple times, return the cached result instead of re-querying. Use a brief TTL to balance freshness and speed.
  • Reduce query scope. A tool that fetches all fields and all rows is slower than one that fetches only what is needed. Use database projections and limits.
  • Parallelize inner work. If a tool must fetch data from multiple sources, fetch them concurrently rather than sequentially.
  • Add a fast path. For common cases, have a quick answer ready without querying. A tool that answers “is the user active?” can check an in-memory cache before hitting the database.

Performance tuning is often more cost-effective than raising timeouts. A tool that completes in 2 seconds is more robust than one that needs 10 seconds and still times out sometimes.

Integration with circuit breakers

A circuit breaker wraps a call to an external service and fails fast if the service is unhealthy. Combining a circuit breaker with timeout handling makes tools more resilient:

CircuitBreaker breaker = CircuitBreaker.ofDefaults("payment-api");

public ToolResult processPayment(String orderId) throws InterruptedException {
    try {
        return breaker.executeSupplier(() -> paymentTool.charge(orderId));
    } catch (CircuitBreakerOpenException e) {
        log.warn("Payment API is down; returning cached result");
        return ToolResult.ofFallback(getCachedPaymentStatus(orderId));
    } catch (InterruptedException e) {
        log.warn("Payment tool interrupted by timeout");
        throw e;
    }
}

When a service times out repeatedly, the circuit breaker opens and subsequent calls fail immediately without waiting. This prevents threads from being starved by stalled requests. Use a library like Resilience4j to manage circuit breaker state and expose metrics.

Best practices summary

Configure a reasonable timeout: Set tool timeouts via RuntimeConfig based on the latency budget of a turn and the real-world response time of downstream services. Default to 30 seconds; tune down for fast operations and up for slow ones, but never so high that the agent blocks the user.

Always handle InterruptedException: Catch it, clean up resources, log the event, and re-throw or throw a meaningful exception. Never swallow it silently.

Check the interrupted flag in loops: Add periodic checks in CPU-bound loops so the tool notices interruption quickly rather than waiting for the next I/O call.

Use try-with-resources: Let Java close your resources automatically, even when an exception is thrown. Manual try/finally is a fallback for non-closeable resources.

Plan for partial results: Design tools to return the best answer they can before time runs out, not just succeed or fail. This makes the agent more resilient.

Monitor timeout events: Log and alert on timeouts. Frequent timeouts are a signal that something is slow or broken. Use metrics to track timeout rate and latency.

Test timeout behavior: Write explicit tests that interrupt tools and verify cleanup. Do not rely on production to find timeout bugs.

Combine with other safety layers: Use circuit breakers, connection pooling, and per-service timeouts as defense in depth. A tool timeout is one layer; it works best with others.

Tool timeouts are not automatic failure — they are signals that a tool must handle gracefully. The ADK runtime enforces a timeout by interrupting the tool’s thread. Respond by catching InterruptedException, cleaning up resources with try-with-resources or try/finally, and returning a meaningful result (partial, cached, or approximate) rather than an error. Check the interrupted flag in compute loops to notice timeouts quickly. Log timeout events, test them explicitly, and monitor their frequency. Combine tool timeouts with circuit breakers and connection pooling as layers of resilience. The goal is not to avoid timeouts — they will happen — but to handle them without cascading into downstream failures or user-visible hangs.