Skip to content

Commit ec6c45d

Browse files
Merge pull request #1268 from svetanis:fix/graceful-jvm-shutdown
PiperOrigin-RevId: 941709147
2 parents 1ce89b9 + 9b046b6 commit ec6c45d

5 files changed

Lines changed: 122 additions & 3 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.internal.http;
18+
19+
import java.util.concurrent.Executors;
20+
import java.util.concurrent.ThreadFactory;
21+
import okhttp3.Dispatcher;
22+
import okhttp3.OkHttpClient;
23+
24+
/** Utility class for common HTTP client configuration across the ADK. */
25+
public final class HttpClientFactory {
26+
27+
private HttpClientFactory() {}
28+
29+
/**
30+
* Configures a custom OkHttp dispatcher that uses daemon threads. By default, OkHttp uses
31+
* non-daemon threads for its async call thread pool, which prevents the JVM from shutting down
32+
* for 60 seconds (the default keep-alive) after the last streaming request completes.
33+
*
34+
* @param name The prefix name to use for the dispatcher threads.
35+
* @return A pre-configured Dispatcher using daemon threads.
36+
*/
37+
private static Dispatcher createDaemonDispatcher(String name) {
38+
ThreadFactory daemonThreadFactory =
39+
r -> {
40+
Thread t = new Thread(r, name + "-Dispatcher");
41+
t.setDaemon(true);
42+
return t;
43+
};
44+
return new Dispatcher(Executors.newCachedThreadPool(daemonThreadFactory));
45+
}
46+
47+
/**
48+
* Creates a shared OkHttpClient instance equipped with a daemon thread dispatcher.
49+
*
50+
* @param threadName The prefix name to use for the dispatcher threads.
51+
* @return A pre-configured OkHttpClient.
52+
*/
53+
public static OkHttpClient createSharedHttpClient(String threadName) {
54+
return new OkHttpClient.Builder().dispatcher(createDaemonDispatcher(threadName)).build();
55+
}
56+
}

‎core/src/main/java/com/google/adk/models/Gemini.java‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@
1919
import static com.google.common.base.StandardSystemProperty.JAVA_VERSION;
2020

2121
import com.google.adk.Version;
22+
import com.google.adk.internal.http.HttpClientFactory;
2223
import com.google.common.collect.ImmutableList;
2324
import com.google.common.collect.ImmutableMap;
2425
import com.google.errorprone.annotations.CanIgnoreReturnValue;
2526
import com.google.genai.Client;
2627
import com.google.genai.ResponseStream;
2728
import com.google.genai.types.Candidate;
29+
import com.google.genai.types.ClientOptions;
2830
import com.google.genai.types.Content;
2931
import com.google.genai.types.FinishReason;
3032
import com.google.genai.types.FunctionCall;
@@ -42,6 +44,7 @@
4244
import java.util.Objects;
4345
import java.util.UUID;
4446
import java.util.concurrent.CompletableFuture;
47+
import okhttp3.OkHttpClient;
4548
import org.jspecify.annotations.Nullable;
4649
import org.slf4j.Logger;
4750
import org.slf4j.LoggerFactory;
@@ -56,6 +59,8 @@ public class Gemini extends BaseLlm {
5659

5760
private static final Logger logger = LoggerFactory.getLogger(Gemini.class);
5861
private static final ImmutableMap<String, String> TRACKING_HEADERS;
62+
private static final OkHttpClient SHARED_HTTP_CLIENT =
63+
HttpClientFactory.createSharedHttpClient("GeminiApiClient");
5964

6065
static {
6166
String frameworkLabel = "google-adk/" + Version.JAVA_ADK_VERSION;
@@ -94,6 +99,7 @@ public Gemini(String modelName, String apiKey) {
9499
Client.builder()
95100
.apiKey(apiKey)
96101
.httpOptions(HttpOptions.builder().headers(TRACKING_HEADERS).build())
102+
.clientOptions(ClientOptions.builder().customHttpClient(SHARED_HTTP_CLIENT).build())
97103
.build();
98104
}
99105

@@ -107,7 +113,9 @@ public Gemini(String modelName, VertexCredentials vertexCredentials) {
107113
super(modelName);
108114
Objects.requireNonNull(vertexCredentials, "vertexCredentials cannot be null");
109115
Client.Builder apiClientBuilder =
110-
Client.builder().httpOptions(HttpOptions.builder().headers(TRACKING_HEADERS).build());
116+
Client.builder()
117+
.httpOptions(HttpOptions.builder().headers(TRACKING_HEADERS).build())
118+
.clientOptions(ClientOptions.builder().customHttpClient(SHARED_HTTP_CLIENT).build());
111119
vertexCredentials.project().ifPresent(apiClientBuilder::project);
112120
vertexCredentials.location().ifPresent(apiClientBuilder::location);
113121
vertexCredentials.credentials().ifPresent(apiClientBuilder::credentials);
@@ -206,6 +214,7 @@ public Gemini build() {
206214
modelName,
207215
Client.builder()
208216
.httpOptions(HttpOptions.builder().headers(TRACKING_HEADERS).build())
217+
.clientOptions(ClientOptions.builder().customHttpClient(SHARED_HTTP_CLIENT).build())
209218
.build());
210219
}
211220
}

‎core/src/main/java/com/google/adk/models/chat/ChatCompletionsHttpClient.java‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import com.fasterxml.jackson.core.JsonProcessingException;
2020
import com.fasterxml.jackson.databind.ObjectMapper;
2121
import com.google.adk.JsonBaseModel;
22+
import com.google.adk.internal.http.HttpClientFactory;
2223
import com.google.adk.models.LlmRequest;
2324
import com.google.adk.models.LlmResponse;
2425
import com.google.common.annotations.VisibleForTesting;
@@ -72,7 +73,8 @@ public final class ChatCompletionsHttpClient implements ChatCompletionsClient {
7273
* {@link ChatCompletionsHttpClient} instances. Each instance forks this client via {@link
7374
* OkHttpClient#newBuilder()} to apply per-instance timeouts without leaking pools.
7475
*/
75-
private static final OkHttpClient SHARED_POOL_CLIENT = new OkHttpClient();
76+
private static final OkHttpClient SHARED_POOL_CLIENT =
77+
HttpClientFactory.createSharedHttpClient("ChatCompletionsHttpClient");
7678

7779
private final OkHttpClient client;
7880
private final HttpUrl completionsUrl;

‎core/src/main/java/com/google/adk/sessions/ApiClient.java‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import static com.google.common.base.StandardSystemProperty.JAVA_VERSION;
2020

21+
import com.google.adk.internal.http.HttpClientFactory;
2122
import com.google.auth.oauth2.GoogleCredentials;
2223
import com.google.common.base.Ascii;
2324
import com.google.common.base.Strings;
@@ -102,8 +103,22 @@ abstract class ApiClient {
102103
this.httpClient = createHttpClient(httpOptions.timeout().orElse(null));
103104
}
104105

106+
/**
107+
* Shared OkHttpClient instance whose connection pool and thread dispatcher are reused across all
108+
* {@link ApiClient} subclass instances.
109+
*
110+
* <p>Even though {@link ApiClient} is abstract, every instantiation of a concrete subclass (e.g.,
111+
* GoogleAiClient) triggers this class's constructor. Making this static prevents a severe
112+
* resource leak where every new LLM Agent would otherwise spin up a brand new thread pool and TCP
113+
* connection pool. Instead, all clients share this pool and fork it via {@link
114+
* OkHttpClient#newBuilder()}.
115+
*/
116+
private static final OkHttpClient SHARED_POOL_CLIENT =
117+
HttpClientFactory.createSharedHttpClient("ApiClient");
118+
105119
private OkHttpClient createHttpClient(@Nullable Integer timeout) {
106-
OkHttpClient.Builder builder = new OkHttpClient().newBuilder();
120+
OkHttpClient.Builder builder = SHARED_POOL_CLIENT.newBuilder();
121+
107122
if (timeout != null) {
108123
builder.connectTimeout(Duration.ofMillis(timeout));
109124
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.internal.http;
18+
19+
import static org.junit.jupiter.api.Assertions.assertTrue;
20+
21+
import java.util.concurrent.ThreadFactory;
22+
import java.util.concurrent.ThreadPoolExecutor;
23+
import org.junit.jupiter.api.Test;
24+
25+
public class HttpClientFactoryTest {
26+
27+
@Test
28+
public void testSharedHttpClientDaemonThreads() {
29+
ThreadFactory tf =
30+
((ThreadPoolExecutor)
31+
HttpClientFactory.createSharedHttpClient("Test").dispatcher().executorService())
32+
.getThreadFactory();
33+
// Usually OkHttp uses a specific thread factory. We passed our own DaemonThreadFactory
34+
Thread t = tf.newThread(() -> {});
35+
assertTrue(t.isDaemon(), "HttpClientFactory thread factory should produce daemon threads");
36+
}
37+
}

0 commit comments

Comments
 (0)