Skip to content

fix(model): honour backoffMultiplier in retry strategy - #3089

Open
wangzhen8866 wants to merge 2 commits into
agentscope-ai:mainfrom
wangzhen8866:fix/backoff-multiplier-effective
Open

fix(model): honour backoffMultiplier in retry strategy#3089
wangzhen8866 wants to merge 2 commits into
agentscope-ai:mainfrom
wangzhen8866:fix/backoff-multiplier-effective

Conversation

@wangzhen8866

Copy link
Copy Markdown

AgentScope-Java Version

2.0.3-SNAPSHOT (based on main, post v2.0.1 GA)

Description

Background

ExecutionConfig.backoffMultiplier was a dead field: it was exposed as a
first-class, user-configurable knob (documented in the builder Javadoc and
MODEL_DEFAULTS), but none of the three inline retry chains actually consumed it.
All three used Retry.backoff(maxAttempts - 1, initialBackoff)..., whose built-in
exponential factor is hard-coded to 2 by Reactor. As a result, any user-supplied
backoffMultiplier(1.5) / 3.0 was silently ignored — the retry cadence was
always 2× regardless of configuration. Even the E2E test passing 2.0 vs 1.5
could not detect the difference.

Root cause

Retry.backoff(...) returns a RetryBackoffSpec whose multiplier defaults to
2.0; the inline chains never called .multiplier(...), so
ExecutionConfig.getBackoffMultiplier() had no consumer.

Changes

  1. New RetrySpecs.build(ExecutionConfig) — single place that turns an
    ExecutionConfig into a RetryBackoffSpec, delegating to Reactor's native
    RetryBackoffSpec.multiplier(double) so the configured multiplier finally
    takes effect. Defaults to 2.0 when unset (backward compatible). Also
    centralises the initialBackoff/maxBackoff/retryOn null-fallbacks that
    had previously drifted out of sync across three call sites.
  2. Refactor the three drifted inline copies to use RetrySpecs.build(...):
    • ModelUtils.applyTimeoutAndRetry (model API calls)
    • ToolExecutor.applyRetry (tool calls)
    • EmbeddingUtils.applyTimeoutAndRetry (embedding API calls)
      Each keeps its own doBeforeRetry logging; debug log now also prints the
      effective multiplier for observability.
  3. Javadoc sync: ExecutionConfig.getBackoffMultiplier() and both
    applyTimeoutAndRetry method docs now state that multiplier is honoured via
    RetrySpecs (null → default 2.0).
  4. Tests:
    • New RetrySpecsTest (5 cases) — deterministic, field-level assertions on
      RetryBackoffSpec.multiplier / minBackoff / maxBackoff / maxAttempts / errorFilter / jitterFactor, locking the multiplier propagation and default
      fallbacks. No real-time sleeps, no jitter randomness.
    • ModelTimeoutRetryTest private helper migrated to RetrySpecs.build so the
      test mirror stays in lock-step with production.

Why low-risk / easy to review

  • No public API change (pure internal refactor + one new package-private utility).
  • Behaviour-neutral for existing call sites: when backoffMultiplier is unset,
    the default 2.0 matches Reactor's previous implicit behaviour exactly.
  • The fix is delegated to Reactor's own RetryBackoffSpec.multiplier(...) rather
    than a hand-rolled Retry.from companion_publisher — zero custom retry logic.

How to test

  • mvn -pl agentscope-core test -Dtest=RetrySpecsTest → 5/5
  • mvn -pl agentscope-core test -Dtest=ModelTimeoutRetryTest → 7/7
  • mvn -pl agentscope-extensions/agentscope-extensions-rag/agentscope-extensions-rag-simple test -Dtest=EmbeddingUtilsTest → 9/9
  • mvn -pl agentscope-core spotless:check → clean (499 files)

Checklist

  • Code has been formatted with mvn spotless:apply
  • All tests are passing (mvn test for affected modules)
  • Javadoc comments are complete and follow project conventions
  • Related documentation has been updated (e.g. links, examples, etc.)
  • Code is ready for review

@CLAassistant

CLAassistant commented Sep 10, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

ExecutionConfig.backoffMultiplier was a dead field: the inline retry chains in ModelUtils, ToolExecutor and EmbeddingUtils used Retry.backoff(...) whose built-in multiplier defaults to 2, silently ignoring any user-supplied value.

Introduce RetrySpecs.build(ExecutionConfig) as the single place that turns an ExecutionConfig into a RetryBackoffSpec, delegating to Reactor RetryBackoffSpec.multiplier(double) so the configured multiplier finally takes effect (defaulting to 2.0 for backward compatibility). Refactor the three drifted inline copies to use it and align the ModelTimeoutRetryTest helper with production.

Add RetrySpecsTest with deterministic field-level assertions that lock the multiplier propagation and default fallbacks.
@wangzhen8866
wangzhen8866 force-pushed the fix/backoff-multiplier-effective branch from aab0a91 to 4d97435 Compare September 10, 2026 10:13
@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.77778% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...main/java/io/agentscope/core/model/ModelUtils.java 0.00% 3 Missing ⚠️
...ain/java/io/agentscope/core/tool/ToolExecutor.java 0.00% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Confirmed the root cause: backoffMultiplier was a dead knob — all three inline chains used Retry.backoff(...) without .multiplier(...), so Reactor's hard-coded 2.0 always won. Centralising into RetrySpecs.build(ExecutionConfig) is the right shape (it also collapses three copies of the null-default logic that had already drifted), and RetrySpecsTest asserts the spec fields directly instead of sleeping on wall-clock backoff, which is exactly how this should be pinned down. Both findings below are non-blocking.

Findings

  • [Info] RetrySpecs.java:104getMaxAttempts() - 1L NPEs on a null maxAttempts; the precondition is documented but unenforced on a now-public helper
  • [Info] ModelUtils.java:116 — reading Reactor's multiplier/minBackoff fields for logging ties three call sites to an undocumented surface

Compatibility note (please call out in the release note)

Behaviour for configurations that set backoffMultiplier != 2.0 changes on upgrade — previously ignored, now honoured (this is of course the point, but it is a user-visible timing change for anyone who had e.g. 1.0 to get a fixed delay). ExecutionConfig.Builder#backoffMultiplier already rejects < 1.0, so Reactor's own multiplier() validation can't be tripped by a config built through the normal path. MODEL_DEFAULTS uses 2.0, so default behaviour is bit-for-bit unchanged.

Checklist notes

  • Scope: StudioClient (agentscope-extensions-studio) still builds its own Retry.backoff(...) — out of scope here since it is not driven by ExecutionConfig, but worth remembering if that knob is ever added there.
  • Tests: ModelTimeoutRetryTest now mirrors production through RetrySpecs instead of duplicating the inline code — good, that removes the drift that let the bug survive in the first place.
  • CLA signed, CI green (ubuntu + windows build, license, module sync, codecov patch).

Automated review by github-manager-bot

Double multiplier = config.getBackoffMultiplier();
double effectiveMultiplier = multiplier != null ? multiplier : DEFAULT_MULTIPLIER;

return Retry.backoff(config.getMaxAttempts() - 1L, initialBackoff)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] config.getMaxAttempts() - 1L unboxes Integer, so calling build() with a maxAttempts == null config throws a bare NPE instead of the documented precondition failing loudly. The Javadoc puts the guard on the caller, but this class is now public API in io.agentscope.core.model, so someone will call it directly. Consider Objects.requireNonNull(config.getMaxAttempts(), "maxAttempts must be set before building a retry spec") (or return Retry.max(0) for the disabled case) so the contract is enforced rather than assumed.

Comment on lines 112 to +116
LOG.debug(
"Applied retry config: maxAttempts={}, initialBackoff={} for model: {}",
"Applied retry config: maxAttempts={}, multiplier={}, initialBackoff={}"
+ " for model: {}",
maxAttempts,
initialBackoff,
retrySpec.multiplier,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] retrySpec.multiplier / retrySpec.minBackoff are Reactor's public fields on RetryBackoffSpec, not a documented accessor API — they are convenient today, but they are the kind of internal surface that gets renamed on a reactor-core minor bump (and this same pattern is repeated in ToolExecutor and EmbeddingUtils, so one upgrade breaks three call sites). Since the effective values are already computed in RetrySpecs.build, a cheap alternative is to return them from a small holder (or just log execConfig.getBackoffMultiplier() plus the resolved default) and keep the Reactor dependency behind the helper.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Consolidates the three duplicated inline Retry.backoff(...) chains into a single RetrySpecs.build(ExecutionConfig) helper and, in doing so, fixes a real bug: backoffMultiplier was exposed on ExecutionConfig but never applied. The direction is right and the new RetrySpecsTest covers the knob directly. My main blocker is that the logging changes read package-private fields of Reactor's RetryBackoffSpec (multiplier, minBackoff) from io.agentscope.core.*, which should not compile; please confirm the build status.

CLA: signed ✅

Findings:

  • [Critical] ModelUtils.java:113retrySpec.multiplier / retrySpec.minBackoff are not public on RetryBackoffSpec; same pattern in ToolExecutor.java and EmbeddingUtils.java, and in RetrySpecsTest.
  • [Warning] RetrySpecs.java:88getMaxAttempts() is unboxed without a null/range guard, despite the javadoc pushing that responsibility onto callers.
  • [Info] RetrySpecs.java:46 — worth documenting as a user-visible behaviour change: anyone who had set backoffMultiplier now gets different retry timings than before.

Automated review by github-manager-bot

responseFlux = responseFlux.retryWhen(retrySpec);
LOG.debug(
"Applied retry config: maxAttempts={}, initialBackoff={} for model: {}",
"Applied retry config: maxAttempts={}, multiplier={}, initialBackoff={}"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compile risk: retrySpec.multiplier and retrySpec.minBackoff are package-private fields of Reactor's RetryBackoffSpec (they live in reactor.util.retry), so reading them from io.agentscope.core.model should fail with "multiplier has private access in RetryBackoffSpec". The same pattern is repeated in ToolExecutor.java and EmbeddingUtils.java, and RetrySpecsTest also asserts on those fields. Could you confirm the exact reactor-core version you built against, and if it does not compile, log the values computed locally in RetrySpecs.build() instead (e.g. expose a small describe(config) helper or return a record holding spec + effective multiplier/backoff)? That keeps the debug log informative without depending on Reactor internals.

public static RetryBackoffSpec build(ExecutionConfig config) {
Duration initialBackoff = config.getInitialBackoff();
if (initialBackoff == null) {
initialBackoff = DEFAULT_INITIAL_BACKOFF;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config.getMaxAttempts() - 1L dereferences maxAttempts without a null check, and a caller passing maxAttempts <= 1 would build Retry.backoff(0, ...) / a negative bound. The javadoc documents the guard as the caller's responsibility, which is fine, but three production call sites now rely on it. Consider failing fast here (Objects.requireNonNull(config.getMaxAttempts(), ...), plus if (maxAttempts < 2) return Retry.max(0)-style no-op, or simply reject with IllegalArgumentException) so a future call site cannot produce an opaque NPE inside a reactive chain.

* <li>{@code maxBackoff} unset &rarr; 10 seconds
* <li>{@code retryOn} unset &rarr; retry all errors
* <li>{@code backoffMultiplier} unset &rarr; {@code 2.0} (Reactor default)
* </ul>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good deduplication — this is the right shape for the fix. One behavioural note worth calling out explicitly for reviewers: Retry.backoff(long, Duration) sets firstFixDelay, so with a configured maxAttempts the effective backoff sequence for existing users stays initialBackoff * multiplier^n; since multiplier was previously always the implicit 2.0, users who had set backoffMultiplier will now see different (larger or smaller) real delays. That is the intent of the PR, but flagging it as a behaviour change in the PR description / changelog would help, since it affects model call retry timing for everyone who configured the knob.

@oss-maintainer

Copy link
Copy Markdown
Collaborator

Correction after checking CI: build (ubuntu-latest) / build (windows-latest) both pass on this head commit, so my [Critical] point about retrySpec.multiplier / retrySpec.minBackoff is retracted — Reactor 3.7 widened those RetryBackoffSpec fields to protected, so reading them from a subclass in another package compiles fine. Apologies for the noise; please ignore that comment (and the equivalent phrasing in ToolExecutor.java / EmbeddingUtils.java / RetrySpecsTest).

The two remaining points still stand and are the only ones I would like addressed:

  • RetrySpecs.build() unboxes config.getMaxAttempts() with no null/range guard even though the javadoc assigns that check to callers (three production call sites now rely on it).
  • The PR changes observable retry timing for anyone who had already configured backoffMultiplier; worth calling out as a behaviour change in the changelog / PR description.

Overall: LGTM once the null-guard question is settled.


Automated review follow-up by github-manager-bot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants