Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions core/src/main/java/com/google/adk/agents/RetryConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/*
* Copyright 2026 Google LLC
*
* Licensed 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 com.google.adk.agents;

import com.google.common.collect.ImmutableSet;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.time.Duration;
import java.util.Objects;
import java.util.Set;

/** Configuration for retrying transient failures at the LLM provider-call boundary. */
public final class RetryConfig {

private static final ImmutableSet<Integer> DEFAULT_RETRYABLE_STATUS_CODES =
ImmutableSet.of(408, 429, 500, 502, 503, 504);

private final int maxAttempts;
private final Duration initialBackoff;
private final Duration maxBackoff;
private final double multiplier;
private final double jitterRatio;
private final ImmutableSet<Integer> retryableStatusCodes;

private RetryConfig(Builder builder) {
this.maxAttempts = builder.maxAttempts;
this.initialBackoff = builder.initialBackoff;
this.maxBackoff = builder.maxBackoff;
this.multiplier = builder.multiplier;
this.jitterRatio = builder.jitterRatio;
this.retryableStatusCodes = ImmutableSet.copyOf(builder.retryableStatusCodes);
}

/** Returns a retry configuration that performs exactly one provider attempt. */
public static RetryConfig disabled() {
return builder().build();
}

/** Returns a builder whose defaults keep retries disabled. */
public static Builder builder() {
return new Builder();
}

public int maxAttempts() {
return maxAttempts;
}

public Duration initialBackoff() {
return initialBackoff;
}

public Duration maxBackoff() {
return maxBackoff;
}

public double multiplier() {
return multiplier;
}

public double jitterRatio() {
return jitterRatio;
}

public ImmutableSet<Integer> retryableStatusCodes() {
return retryableStatusCodes;
}

@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof RetryConfig that)) {
return false;
}
return maxAttempts == that.maxAttempts
&& Double.compare(multiplier, that.multiplier) == 0
&& Double.compare(jitterRatio, that.jitterRatio) == 0
&& initialBackoff.equals(that.initialBackoff)
&& maxBackoff.equals(that.maxBackoff)
&& retryableStatusCodes.equals(that.retryableStatusCodes);
}

@Override
public int hashCode() {
return Objects.hash(
maxAttempts, initialBackoff, maxBackoff, multiplier, jitterRatio, retryableStatusCodes);
}

/** Builder for {@link RetryConfig}. */
public static final class Builder {
private int maxAttempts = 1;
private Duration initialBackoff = Duration.ofMillis(500);
private Duration maxBackoff = Duration.ofSeconds(8);
private double multiplier = 2.0;
private double jitterRatio = 0.0;
private Set<Integer> retryableStatusCodes = DEFAULT_RETRYABLE_STATUS_CODES;

private Builder() {}

@CanIgnoreReturnValue
public Builder maxAttempts(int maxAttempts) {
this.maxAttempts = maxAttempts;
return this;
}

@CanIgnoreReturnValue
public Builder initialBackoff(Duration initialBackoff) {
this.initialBackoff = Objects.requireNonNull(initialBackoff, "initialBackoff cannot be null");
return this;
}

@CanIgnoreReturnValue
public Builder maxBackoff(Duration maxBackoff) {
this.maxBackoff = Objects.requireNonNull(maxBackoff, "maxBackoff cannot be null");
return this;
}

@CanIgnoreReturnValue
public Builder multiplier(double multiplier) {
this.multiplier = multiplier;
return this;
}

@CanIgnoreReturnValue
public Builder jitterRatio(double jitterRatio) {
this.jitterRatio = jitterRatio;
return this;
}

@CanIgnoreReturnValue
public Builder retryableStatusCodes(Set<Integer> retryableStatusCodes) {
this.retryableStatusCodes =
Objects.requireNonNull(retryableStatusCodes, "retryableStatusCodes cannot be null");
return this;
}

public RetryConfig build() {
if (maxAttempts < 1) {
throw new IllegalArgumentException("maxAttempts must be at least 1.");
}
if (initialBackoff.isNegative()) {
throw new IllegalArgumentException("initialBackoff cannot be negative.");
}
if (maxBackoff.isNegative()) {
throw new IllegalArgumentException("maxBackoff cannot be negative.");
}
if (maxBackoff.compareTo(initialBackoff) < 0) {
throw new IllegalArgumentException("maxBackoff cannot be less than initialBackoff.");
}
if (!Double.isFinite(multiplier) || multiplier < 1.0) {
throw new IllegalArgumentException("multiplier must be finite and at least 1.0.");
}
if (!Double.isFinite(jitterRatio) || jitterRatio < 0.0 || jitterRatio > 1.0) {
throw new IllegalArgumentException("jitterRatio must be between 0.0 and 1.0.");
}
for (int statusCode : retryableStatusCodes) {
if (statusCode < 100 || statusCode > 599) {
throw new IllegalArgumentException(
"retryableStatusCodes must contain valid HTTP status codes.");
}
}
return new RetryConfig(this);
}
}
}
7 changes: 7 additions & 0 deletions core/src/main/java/com/google/adk/agents/RunConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ public enum ToolExecutionMode {

public abstract int maxLlmCalls();

public abstract RetryConfig retryConfig();

public abstract boolean autoCreateSession();

/**
Expand Down Expand Up @@ -127,6 +129,7 @@ public static Builder builder() {
.streamingMode(StreamingMode.NONE)
.toolExecutionMode(ToolExecutionMode.NONE)
.maxLlmCalls(500)
.retryConfig(RetryConfig.disabled())
.autoCreateSession(false)
.customMetadata(ImmutableMap.of());
}
Expand All @@ -138,6 +141,7 @@ public static Builder builder(RunConfig runConfig) {
.streamingMode(runConfig.streamingMode())
.toolExecutionMode(runConfig.toolExecutionMode())
.maxLlmCalls(runConfig.maxLlmCalls())
.retryConfig(runConfig.retryConfig())
.responseModalities(runConfig.responseModalities())
.speechConfig(runConfig.speechConfig())
.avatarConfig(runConfig.avatarConfig())
Expand Down Expand Up @@ -232,6 +236,9 @@ public final Builder setMaxLlmCalls(int maxLlmCalls) {
@CanIgnoreReturnValue
public abstract Builder maxLlmCalls(int maxLlmCalls);

@CanIgnoreReturnValue
public abstract Builder retryConfig(RetryConfig retryConfig);

@Deprecated
@CanIgnoreReturnValue
public final Builder setAutoCreateSession(boolean autoCreateSession) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,9 @@ private Flowable<Event> callLlm(
agent.resolvedModel().modelName().get());
LlmRequest finalLlmRequest = llmRequestBuilder.build();

return llm.generateContent(
return LlmRetryPolicy.execute(
context,
llm,
finalLlmRequest,
context.runConfig().streamingMode()
== StreamingMode.SSE)
Expand Down
126 changes: 126 additions & 0 deletions core/src/main/java/com/google/adk/flows/llmflows/LlmRetryPolicy.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* Copyright 2026 Google LLC
*
* Licensed 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 com.google.adk.flows.llmflows;

import com.google.adk.agents.InvocationContext;
import com.google.adk.agents.RetryConfig;
import com.google.adk.models.BaseLlm;
import com.google.adk.models.LlmCallsLimitExceededException;
import com.google.adk.models.LlmRequest;
import com.google.adk.models.LlmResponse;
import io.opentelemetry.api.trace.Span;
import io.reactivex.rxjava3.core.Flowable;
import java.time.Duration;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** Executes retries around a single LLM provider call without replaying agent-side effects. */
final class LlmRetryPolicy {
private static final Logger logger = LoggerFactory.getLogger(LlmRetryPolicy.class);

private LlmRetryPolicy() {}

static Flowable<LlmResponse> execute(
InvocationContext context, BaseLlm llm, LlmRequest request, boolean stream) {
return executeAttempt(context, llm, request, stream, /* attempt= */ 1);
}

private static Flowable<LlmResponse> executeAttempt(
InvocationContext context, BaseLlm llm, LlmRequest request, boolean stream, int attempt) {
return Flowable.defer(
() -> {
AtomicBoolean emittedResponse = new AtomicBoolean(false);
return llm.generateContent(request, stream)
.doOnNext(unused -> emittedResponse.set(true))
.onErrorResumeNext(
error -> {
RetryConfig config = context.runConfig().retryConfig();
if (emittedResponse.get()
|| attempt >= config.maxAttempts()
|| !isRetryable(llm, error, config)) {
return Flowable.error(error);
}

long delayMillis = retryDelay(config, attempt);
int nextAttempt = attempt + 1;
recordRetry(llm, context, error, nextAttempt, delayMillis);
return Flowable.timer(delayMillis, TimeUnit.MILLISECONDS)
.flatMapPublisher(
unused -> {
try {
context.incrementLlmCallsCount();
} catch (LlmCallsLimitExceededException e) {
return Flowable.error(e);
}
return executeAttempt(context, llm, request, stream, nextAttempt);
});
});
});
}

private static boolean isRetryable(BaseLlm llm, Throwable error, RetryConfig config) {
Set<Throwable> visited = Collections.newSetFromMap(new IdentityHashMap<>());
for (Throwable current = error;
current != null && visited.add(current);
current = current.getCause()) {
if (current instanceof LlmCallsLimitExceededException) {
return false;
}
if (llm.isExceptionRetryable(current, config.retryableStatusCodes())) {
return true;
}
}
return false;
}

private static long retryDelay(RetryConfig config, int attempt) {
Duration initial = config.initialBackoff();
Duration maximum = config.maxBackoff();
double exponential = initial.toMillis() * Math.pow(config.multiplier(), attempt - 1);
double bounded = Math.min(exponential, maximum.toMillis());
if (config.jitterRatio() > 0.0 && bounded > 0.0) {
double jitter =
ThreadLocalRandom.current().nextDouble(-config.jitterRatio(), config.jitterRatio());
bounded *= 1.0 + jitter;
}
return Math.max(0L, Math.round(Math.min(bounded, maximum.toMillis())));
}

private static void recordRetry(
BaseLlm llm, InvocationContext context, Throwable error, int nextAttempt, long delayMillis) {
logger.warn(
"Retrying LLM call for model {} and agent {} (attempt {}, delay {} ms) after {}",
llm.model(),
context.agent().name(),
nextAttempt,
delayMillis,
error.getClass().getName());

Span span = Span.current();
span.setAttribute("adk.llm.retry.model", llm.model());
span.setAttribute("adk.llm.retry.agent", context.agent().name());
span.setAttribute("adk.llm.retry.attempt", nextAttempt);
span.setAttribute("adk.llm.retry.delay_ms", delayMillis);
span.setAttribute("adk.llm.retry.error_type", error.getClass().getName());
}
}
Loading