What @Retry Actually Retries When Your AI Service Has Tools
One sentence in the Quarkus LangChain4j fault tolerance guide describes a double charge: “Retrying the whole method call restarts the entire agent loop, so tool side effects that already completed (charges, emails, writes) may be executed again.”
The key word is “whole”. @Retry from MicroProfile Fault Tolerance wraps a CDI bean method. On an AI service, that method is the entire conversation with the model: the first request, the tool calls the model asks for, the tool results sent back, the next request, and so on until the model produces a final answer. Every tool invocation in that loop runs inside the method call. When the method throws after the second round-trip, @Retry does exactly what it was built to do and starts the method over from the first message. The charge that went through during the first attempt is invisible to the retry. The model gets the same prompt and asks for the same tool, and the charge goes through a second time.
The guide’s answer is a contract: “Tools are expected to be idempotent”, and you are the one who has to “make sure your tool implementations tolerate being invoked multiple times for a single logical operation”. The tool is yours, so the contract is yours. That note was not the first answer, though. The team implemented a build-time check for exactly this situation, then took it out and moved the guidance to the documentation.
The retry sits outside the loop, and you can see it in a stack trace
SmallRye Fault Tolerance, the MicroProfile Fault Tolerance implementation Quarkus ships, nests the strategies in a fixed chain around the method: fallback outermost, then retry, then circuit breaker, rate limit (a SmallRye addition, not part of the MicroProfile spec), timeout, bulkhead, and finally the call itself. Retry re-runs everything inside it. On a plain service method, “everything inside” is one operation. On an AI service with tools, it is a loop that talks to your database, your payment provider, your mail server.
Run the project below with a deliberately wrong API key and you can watch it happen. The log shows three POSTs to the chat completions endpoint with identical bodies: the same system message, the same user message, the same tool definition. One call, two retries. The stack trace of the final failure reads, from the outside in: io.smallrye.faulttolerance.core.retry.Retry.retryLoopIteration, then CheckoutAssistant$$QuarkusImpl_Subclass.checkout, then AiServiceMethodImplementationSupport.implement, the generated code that runs the model-and-tool loop. The retry is the outer frame. The loop, tools included, is the inner one.
The same trace shows a second, smaller retry: dev.langchain4j.internal.RetryUtils.withRetry inside OpenAiChatModel.doChat. Its unit is a single HTTP request to the model. That retry can repeat a network call; it cannot repeat a tool that already ran, because it wraps the request and never the loop. Two retries with different units, and the one on the interface has the wide blast radius.
With a bad key, nothing reaches the tool, so the three attempts are harmless. Move the failure one step later, after the model has called charge and the provider times out on the second round-trip, and the three attempts become three charges.
A build-time check, implemented and reverted
PR #2749 in quarkiverse/quarkus-langchain4j, merged on August 24, 2026, “reverts the build-time fault tolerance check (and its test)” and “keeps and expands the CAUTION note in guide-fault-tolerance.adoc, explaining that retrying an AI service method restarts the agent loop and that tools must be idempotent”.
The description states the reasoning: after discussion with maintainers @geoand and @cescoffier, “instead of a build-time check, the guidance belongs in the documentation”. In the review thread, @cescoffier wrote: “I would only write a warning in the doc”, and: “For me it’s normal that tools are retried, the developers must implement idempotency correctly.” @geoand replied: “Exactly”.
So the position is explicit: retrying tools is normal and idempotency is the developer’s job. One caution note in the guide is all the framework gives you for it.
What the contract looks like in code
The project is small: an AI service with @Retry, one tool that charges an order, a gateway that refuses to charge the same key twice, and a REST endpoint to trigger it from Swagger UI.
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-fault-tolerance</artifactId>
</dependency>
<dependency>
<groupId>io.quarkiverse.langchain4j</groupId>
<artifactId>quarkus-langchain4j-openai</artifactId>
</dependency>
</dependencies>
The gateway stands in for a payment provider. The only property that matters is the map: charging a key that already has a receipt returns that receipt and does nothing else.
// src/main/java/com/example/checkout/Receipt.java
package com.example.checkout;
public record Receipt(String id, String idempotencyKey, double amount) {
}
// src/main/java/com/example/checkout/PaymentGateway.java
package com.example.checkout;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import jakarta.enterprise.context.ApplicationScoped;
/**
* Stand-in for a real payment provider. The one property that matters here:
* charging the same idempotency key twice returns the first receipt instead
* of charging again.
*/
@ApplicationScoped
public class PaymentGateway {
private final Map<String, Receipt> receipts = new ConcurrentHashMap<>();
public Receipt charge(String idempotencyKey, double amount) {
return receipts.computeIfAbsent(idempotencyKey,
key -> new Receipt(UUID.randomUUID().toString(), key, amount));
}
}
The tool uses the order id as the key. One order, one charge, however many times the loop asks.
// src/main/java/com/example/checkout/PaymentTools.java
package com.example.checkout;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class PaymentTools {
@Inject
PaymentGateway gateway;
@Tool("Charges the customer for an order. Calling it again for the same order does not charge twice.")
public String charge(@P("the order id") String orderId,
@P("the amount to charge") double amount) {
Receipt receipt = gateway.charge(orderId, amount);
return "charged " + receipt.amount() + " for order " + orderId
+ ", receipt " + receipt.id();
}
}
The AI service is where @Retry goes. Here it means: if checkout throws, run checkout again, tools and all, up to two more times.
// src/main/java/com/example/checkout/CheckoutAssistant.java
package com.example.checkout;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
import io.quarkiverse.langchain4j.RegisterAiService;
import org.eclipse.microprofile.faulttolerance.Retry;
@RegisterAiService(tools = PaymentTools.class)
public interface CheckoutAssistant {
@SystemMessage("You are a checkout assistant. Use the charge tool to take the payment, then confirm it to the customer in one sentence.")
@UserMessage("Charge order {orderId} the amount of {amount}.")
@Retry(maxRetries = 2)
String checkout(@V("orderId") String orderId, @V("amount") double amount);
}
// src/main/java/com/example/checkout/CheckoutResource.java
package com.example.checkout;
import jakarta.inject.Inject;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
@Path("/checkout")
public class CheckoutResource {
@Inject
CheckoutAssistant assistant;
@POST
@Produces(MediaType.TEXT_PLAIN)
public String checkout(@QueryParam("orderId") String orderId,
@QueryParam("amount") double amount) {
return assistant.checkout(orderId, amount);
}
}
# src/main/resources/application.properties
quarkus.langchain4j.openai.api-key=${OPENAI_API_KEY}
quarkus.langchain4j.openai.log-requests=true
quarkus.langchain4j.openai.log-responses=true
Export OPENAI_API_KEY, run ./mvnw quarkus:dev, open http://localhost:8080/q/swagger-ui and POST to /checkout with an order id and an amount. Request and response logging is on so you can watch each round-trip of the loop, including the tool call the model emits and the tool result that goes back.
With a real provider, the in-memory map is replaced by whatever the provider offers for the same purpose. If it accepts an idempotency key with the charge request, the key goes there. If it does not, the receipts table is yours to keep, and it has to survive a JVM restart, because the retry that hits you in production may come from a different instance than the one that made the first charge.
The key has to be minted outside the method
Charging is the easy case. An order id is a natural idempotency key: the business already agrees that one order is charged once. The model has the id in the prompt and passes it to the tool as an argument.
Sending an email has no natural key. “Send the customer a confirmation for order A-100” executed twice is two confirmations, both correct on their own terms. To make that tool idempotent, something has to identify the logical operation, and that something cannot be created inside the retried method, because the retried method is precisely what restarts. A UUID generated inside checkout is a new UUID on every attempt, which makes every attempt a new operation and defeats the purpose.
The caller mints the key once, before invoking the AI service. The tool reads it from the request. Tool arguments are whatever the model decides to write into the call; a key that has to travel through the prompt and come back intact is a key the model can drop, shorten or reinvent. The caller sets it and the tool reads it; the model never sees it.
This also shows in the gateway signature. charge(String idempotencyKey, double amount) cannot be called without a key. A reviewer reading the tool sees the contract in the parameter list. The tool description, “calling it again for the same order does not charge twice”, is written for the model; the signature is written for the next developer. A model can ignore the description; the compiler will not let anyone skip the parameter.
@Timeout plus @Retry is the combination that produces the incident
The same guide has a second caution: “Use @Timeout carefully when using tools or multi-step function calls, as those may require more time to complete due to multiple background interactions with your application’s tools or services.”
Stack the two and the chain order does the rest. Timeout sits inside retry. A timeout that fires on the third round-trip, after the tool ran on the second, throws exactly the exception retry is waiting for. Retry restarts the method from the first message and the tool runs again. A slow provider and a tight timeout are enough; no bug in your tool is required.
The documentation note assumes you handled this scenario in advance by making the tool idempotent. Whether a note is the right place to ask for that is the next question.
Is a documentation note enough when the effect cannot be undone?
The maintainers’ position has real arguments behind it.
A build-time check cannot tell a tool that reads from a tool that writes. A @Tool that searches a catalog and a @Tool that charges a card look identical to the build. The check either fires on every @Retry on every AI service with tools, which turns it into noise that gets silenced, or it needs a marker annotation on side-effecting tools, which people forget to add and which then produces a false sense of safety. Idempotency is a property of the tool’s implementation and its backend, and the compiler cannot see either. And for read-only tools, retrying is correct: a catalog search run three times costs latency and nothing else. “It’s normal that tools are retried” is a defensible description of the general case.
The counter-argument is about who reads the note and when. The person who writes the first tool reads the fault tolerance guide in week one, if they read it at all. @Retry gets added in month six, by someone else, during an incident, because the model provider is flaky and a retry is the obvious fix. At that moment, the note is not on the screen. A warning in the build log would be. The reverted check existed, and it had a test; the project decided the cost of that warning was higher than its value. For tools that search and summarize, I agree. For tools that move money or send mail, a line in the build output at the exact moment someone adds the annotation is cheaper than the refund.
What the framework’s choice leaves you with is a floor, and you build the rest:
- Keep
@Retryoff AI service methods whose tools write. The per-request retry inside the provider client already covers the transient network failure, at the unit where retrying is safe. - If the method-level retry is genuinely needed, every side-effecting tool takes a key minted outside the method.
- Treat
@Timeouttogether with@Retryon an AI service with tools as a decision to be written down, with the double-execution case covered. Defaults copied from a REST client don’t cover it. - Put the contract in the signature. A gateway that requires an idempotency key is a check that runs on every call, in every codebase, without a plugin.
The sentence in the guide is a contract, and you sign it the moment you add the annotation. The framework tells you once and trusts you from there. Whether that trust holds is decided in the parameter list of your gateway, and nothing else in the build is going to check it for you.