Repository navigation
Retry and time out individual model and tool calls (CallPolicy) - #54
Conversation
CallPolicy.retries(n, backoff).timeout(t).retryOn(p) retries one model or tool call inside the attempt that makes it, instead of re-running the whole task: set runtime-wide with Builder.callPolicy, or per call with ctx.call(tool, input, policy) and ctx.model(policy). Only the outcome is recorded (one request, one result), plus an informational CallRetried per retry, so a replay substitutes it without retrying or waiting. A timeout runs the attempt on its own virtual thread and gives up with CallTimeoutException. Streams are retried only before the first chunk. A cancel cuts retries short as CANCELLED; a shutdown surfaces the last failure to the usual drain decision. Exit demo: Demo calls (CI gate) + CallsAcceptanceTest. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| O output; | ||
| try { | ||
| output = applyUnchecked(tool, input); | ||
| output = applyWithPolicy(tool, input, policy); |
There was a problem hiding this comment.
The in-doubt RETRY path records the cancel signal's text, not the tool's real failure.
This line now goes through applyWithPolicy. That means invokeWithPolicy → stoppedRetrying can throw a CancellationSignal here, with the real failure attached as a suppressed exception. runToolLive unwraps that with lastRealFailure(ex), but the catch block below still records String.valueOf(ex) (line 671: append(new ToolCompleted(now(), null, String.valueOf(ex), latencyMillis))).
Scenario:
InDoubtPolicy.RETRYis set, and the runtime has acallPolicy(CallPolicy.retries(5, 5s)).- A resume finds a dangling
ToolRequested. - The retried call fails once, then
cancel(id)lands during the backoff. - The log gets
ToolCompleted(error = "…CancellationSignal: …")instead of the tool's real error.
That is the exact case the PR's mutation check ("recording the signal's text instead of the real failure") guards, but only on the runToolLive path. The recorded error is what inspect, the timeline and OTel show, and it is what a replay substitutes as RecordedToolFailureException.
Fix: use String.valueOf(lastRealFailure(ex)) at line 671, as runToolLive does.
(Minor: the execution still ends CANCELLED. Only the recorded failure text is wrong.)
Review of c24b666 (CallPolicy: per-call retries and timeouts)Files read:
Checked and found sound:
Confirmed findings: 1
Verdict: looks correct apart from the one minor fix above. I did not build or run anything; this review is from reading the code only. |
…cuts its retries Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| signal.addSuppressed(failure); | ||
| return signal; | ||
| } | ||
| return failure; |
There was a problem hiding this comment.
A graceful shutdown during a per-call backoff fails the execution for good.
Scenario: callPolicy(CallPolicy.retries(3, 1s)) with the default RetryPolicy.none(). A tool fails once with a transient error, and close() lands during the backoff (or right after the failure, since stopRequested() is checked first).
waitBackoffreturns false, and this path returns the rawfailure.runToolLiverecordsToolCompleted(error)and rethrows.- In
runAttempt,raisedAbandon()is false and the throwable isn't anExecutionAbandonedSignal, sostoppedByAbandonis false and control reaches the retry decision (CatalystRuntime ~L1714). WithRetryPolicy.none(),nextBackoffis empty, so it callsrecordFailedand writes a terminalExecutionFailed.
The model path behaves the same way, writing ExecutionFailed after the open CompletionRequested.
This goes against Deploy-4 ("close() abandons running attempts instead of failing them"). The call's own policy would have retried, so this failure was still in progress, not final. A routine deploy now permanently fails every execution that happens to be in a call-level backoff, and recovery can't undo that.
The tool path already records the request's outcome. It could record the real failure, set raisedAbandon, and throw ExecutionAbandonedSignal. On restart the recorded failure would be substituted, though, so it also needs a durable "this failure gets retried" verdict. One option is a RetryRequested naming the ToolCompleted seq (which seed() already drops). That way the next start re-runs the call live. The model path can use the same verdict to close its request.
| } catch (InterruptedException e) { | ||
| worker.interrupt(); | ||
| Thread.currentThread().interrupt(); | ||
| throw new RuntimeException(call + " was interrupted", e); |
There was a problem hiding this comment.
With a timeout and no retries, a cancel ends the execution FAILED instead of CANCELLED.
Scenario: ctx.call(tool, in, CallPolicy.timeoutAfter(30s)), or ctx.model(timeoutAfter(...)), followed by runtime.cancel(id) while the call is running.
tripCancelinterrupts the task thread, which is blocked inresult.get.- That throws
RuntimeException("… was interrupted")from here. - In
invokeWithPolicy,policy.retries(1, failure)is false becausemaxAttempts == 1, so it rethrows the plain failure andstoppedRetryingnever runs. runToolLiverecords the error, andrunAttemptsees noCancellationSignalorraisedCancellation. It then reachesbackoff.isEmpty() || token.isCancelled()and callsrecordFailed, which writesExecutionFailed.
Without a timeout, a tool that ignores the interrupt finishes, and the next boundary's requireAppendable turns the cancel into CANCELLED. So adding a timeout alone turns cancels into failures. The PR's "a cancel … ends CANCELLED" test covers only the retrying case.
Suggested fix: when cancellation != null && cancellation.isCancelled() here, set raisedCancellation and throw a CancellationSignal. Alternatively, have invokeWithPolicy send stop-time failures through stoppedRetrying even when the policy wouldn't retry.
Review of d59000e (CallPolicy: per-call retries and timeouts)Files read:
Confirmed findings: 2 (posted inline)
Checked, no problem found:
Verdict: changes requested. Replay and log correctness look intact, but both findings change an execution's terminal state on the stop paths. I did not build or run anything; this review comes from reading the code only. |
…ing a timeout ends CANCELLED Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| */ | ||
| private void recordRetryOnRestart(RuntimeException stop, long failedSeq) { | ||
| if (stop instanceof ExecutionAbandonedSignal && stop.getSuppressed().length > 0) { | ||
| append(new RetryRequested(now(), String.valueOf(stop.getSuppressed()[0]), 0, failedSeq)); |
There was a problem hiding this comment.
A shutdown during a per-call backoff uses up the task's whole-task RetryPolicy budget.
This RetryRequested folds through Reducer (case RetryRequested rr -> retries++). The runtime then compares that counter with the task's policy: policy.nextBackoff(state.retries(), t) in CatalystRuntime.runAttempt.
Scenario:
- Settings:
RetryPolicy.maxRetries(1, …)plusCallPolicy.retries(3, 5s). - A tool fails once, and
close()lands during the 5s backoff. - This line writes
RetryRequested(failedSeq), soretries == 1. The task was never retried as a whole. - After restart, the resumed attempt succeeds at this call and later hits a genuine transient failure.
maxRetries(1)seesretriesSoFar == 1and returns empty, so the execution ends FAILED where it should have got its one whole-task retry.
CLAUDE.md does say the restart "counts one retries". But the effect on the RetryPolicy budget isn't stated, and it contradicts the PR's framing that per-call retries are separate from whole-task retries. The TimelineReport / OTel retries figure is inflated the same way.
Possible fixes:
- Mark these (e.g. a negative
backoffMillis, or a distinct cause prefix) and have the reducer skip the budget bump while keepingseed's drop and request-closing behaviour. - Or document the budget cost explicitly.
|
Review of 682cf56: per-call retries and timeouts ( I read the code only. I did not build it or run any tests or demos. Files read
Confirmed findings: 1
Checked, nothing wrong found
Verdict: no correctness blocker in the replay or recovery paths. One low-to-medium semantic issue: a shutdown during per-call retries uses up the whole-task retry budget. Worth fixing or documenting explicitly before merge. |
…try budget (RetryRequested.callRetry) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Review of d59f848: no confirmed findings. I did not build or run anything. This review comes from reading the code only. Files read
What I checked
Not defects, noted for awareness
Verdict: looks correct. No inline comments. |
Roadmap item from the Boudin review: per-call retries and timeouts. Until now Catalyst retried only the whole task, and a hung provider call had no limit.
What
maxBackoff(30s by default).retryOn(predicate)chooses which failures are retried.Builder.callPolicy) applies to any call that names no policy. Per call, usectx.call(tool, input, policy)andctx.model(policy).CallRetried(a new, additive event), which the timeline and OTel show. A replay substitutes the outcome, so a call that needed three tries replays like one that needed one.CallTimeoutExceptionis thrown, which the policy can retry. A provider may still have accepted the call, and that's documented.CANCELLEDrather thanFAILED. The tool's real last failure is still recorded.CallPolicy.none(): one attempt, no timeout.Tests
CallPolicyTest(9 tests):CallPolicyUnitTest(5 tests): attempts, backoff doubling and cap, the predicate, validation.CallsAcceptanceTest+Demo calls, added as a CI gate. A provider answers 503 twice and a tool hangs once (cut at 1s). The task runs in one attempt with 0 whole-task retries, and replay makes 0 calls.CallRetried;🤖 Generated with Claude Code