2026-06-17 13:24:03 +08:00
// Package component — retry decorator tests.
//
// These tests exercise the retryInvoker wrapper directly. The wrapper
// is the chat-level retry loop introduced to mirror Python's
// max_retries/delay_after_error semantics (agent/component/llm.py,
// driven by LLMBundle in rag/llm/chat_model.py). Unlike the
// existing one-shot structured-output retry (in LLMComponent.Invoke),
// the retry loop lives at the ChatInvoker boundary so it covers
// every chat path: LLM, Agent, citation grounding.
package component
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
)
// scriptedInvoker fails the first failTimes calls then succeeds.
// err is returned on every failing call (asserted via errors.Is).
type scriptedInvoker struct {
failTimes int32
err error
resp * ChatInvokeResponse
calls int32
}
func ( s * scriptedInvoker ) Invoke ( _ context . Context , _ ChatInvokeRequest ) ( * ChatInvokeResponse , error ) {
n := atomic . AddInt32 ( & s . calls , 1 )
if n <= atomic . LoadInt32 ( & s . failTimes ) {
return nil , s . err
}
return s . resp , nil
}
func ( s * scriptedInvoker ) callCount ( ) int { return int ( atomic . LoadInt32 ( & s . calls ) ) }
// alwaysFailInvoker returns err on every call. Used to exercise the
// exhaustion path.
type alwaysFailInvoker struct {
err error
calls int32
}
func ( a * alwaysFailInvoker ) Invoke ( _ context . Context , _ ChatInvokeRequest ) ( * ChatInvokeResponse , error ) {
atomic . AddInt32 ( & a . calls , 1 )
return nil , a . err
}
func ( a * alwaysFailInvoker ) callCount ( ) int { return int ( atomic . LoadInt32 ( & a . calls ) ) }
// TestRetryInvoker_SucceedsOnSecondAttempt: 1 failure, 1 success —
// the loop must retry exactly once and return the success response
// without surfacing the error.
func TestRetryInvoker_SucceedsOnSecondAttempt ( t * testing . T ) {
want := & ChatInvokeResponse { Content : "ok" , Model : "m" , Stopped : true }
inner := & scriptedInvoker { failTimes : 1 , err : errors . New ( "transient" ) , resp : want }
r := newRetryInvoker ( inner , 3 , time . Millisecond )
resp , err := r . Invoke ( context . Background ( ) , ChatInvokeRequest { ModelName : "m" } )
if err != nil {
t . Fatalf ( "Invoke: unexpected err: %v" , err )
}
if resp != want {
t . Errorf ( "resp=%v, want %v" , resp , want )
}
if got := inner . callCount ( ) ; got != 2 {
t . Errorf ( "inner.calls=%d, want 2 (1 fail + 1 success)" , got )
}
}
// TestRetryInvoker_ExhaustsRetries: failures exceed the budget —
// the loop must stop after maxRetries+1 attempts and wrap the last
// error with the retry count.
func TestRetryInvoker_ExhaustsRetries ( t * testing . T ) {
sentinel := errors . New ( "permanent" )
inner := & alwaysFailInvoker { err : sentinel }
r := newRetryInvoker ( inner , 3 , time . Millisecond )
_ , err := r . Invoke ( context . Background ( ) , ChatInvokeRequest { ModelName : "m" } )
if err == nil {
t . Fatal ( "expected error after exhaustion" )
}
if ! errors . Is ( err , sentinel ) {
t . Errorf ( "err does not wrap sentinel: %v" , err )
}
if got , want := inner . callCount ( ) , 4 ; got != want {
// 1 initial + 3 retries
t . Errorf ( "inner.calls=%d, want %d (1 + maxRetries)" , got , want )
}
if ! strings . Contains ( err . Error ( ) , "3 retries" ) {
t . Errorf ( "error message missing retry count: %q" , err . Error ( ) )
}
}
// TestRetryInvoker_HonorsContextCancellation: a ctx cancelled
// during backoff must abort the sleep and return ctx.Err() promptly,
// not wait out the full delay.
2026-07-02 10:56:38 +08:00
//
// The semantic assertion is two-fold:
// 1. The returned error wraps context.Canceled.
// 2. The inner invoker was not called more than twice (the first
// attempt + possibly a second one if cancel landed between the
// attempt and the backoff sleep). A regression that ignored
// ctx during backoff would burn through the full 5-attempt
// budget and the inner counter would climb to 5.
//
// We do NOT assert on wall-clock elapsed time. The 30s backoff is
// the test's own punishment for regressions — if the loop hangs,
// the call simply takes 30s and the Go test runner's timeout (or
// the surrounding CI budget) catches it. There is no load-bearing
// information in "elapsed < 2s" that isn't already covered by the
// counter check.
2026-06-17 13:24:03 +08:00
func TestRetryInvoker_HonorsContextCancellation ( t * testing . T ) {
inner := & alwaysFailInvoker { err : errors . New ( "transient" ) }
// 30s delay so the test would obviously hang if ctx were not
// honored. The cancellation lands within milliseconds.
r := newRetryInvoker ( inner , 5 , 30 * time . Second )
ctx , cancel := context . WithCancel ( context . Background ( ) )
// Cancel after a short delay, while the retry is sleeping
// through the first backoff.
go func ( ) {
time . Sleep ( 20 * time . Millisecond )
cancel ( )
} ( )
_ , err := r . Invoke ( ctx , ChatInvokeRequest { ModelName : "m" } )
if err == nil {
t . Fatal ( "expected error from cancelled context" )
}
if ! errors . Is ( err , context . Canceled ) {
t . Errorf ( "err=%v, want context.Canceled" , err )
}
// First call happens, then we cancel during the first backoff.
// The retry loop should not have made more than 2 calls.
if got := inner . callCount ( ) ; got > 2 {
t . Errorf ( "inner.calls=%d, want <= 2 (ctx cancel should abort backoff)" , got )
}
}
2026-07-02 10:56:38 +08:00
// TestRetryInvoker_ExponentialBackoff: the retry loop's backoff
// function must grow exponentially (with full jitter) — a constant
// backoff would burn the same 20ms per attempt and the loop would
// finish far too quickly.
//
// Semantic check: the LOWER bound on elapsed wall-clock time is
// the load-bearing assertion (proves the loop actually slept
// between attempts with growing delays). A regression to a
// constant backoff would slip below the lower bound. We assert
// the upper bound semantically via the call counter — the loop
// must make exactly 4 calls (1 initial + 3 retries), and each
// retry must observe a non-zero backoff. We do NOT assert on a
// fragile upper wall-clock bound: a slow CI runner could push
// elapsed past 2s without any real regression.
2026-06-17 13:24:03 +08:00
func TestRetryInvoker_ExponentialBackoff ( t * testing . T ) {
inner := & alwaysFailInvoker { err : errors . New ( "transient" ) }
const initial = 20 * time . Millisecond
r := newRetryInvoker ( inner , 3 , initial )
start := time . Now ( )
_ , _ = r . Invoke ( context . Background ( ) , ChatInvokeRequest { ModelName : "m" } )
elapsed := time . Since ( start )
// 20 + 40 + 80 = 140ms of backoff (3 retries, 4 attempts total).
// Use 130ms as the lower bound to avoid CI flakes from clock
2026-07-02 10:56:38 +08:00
// granularity. A constant-delay regression would slip below this.
2026-06-17 13:24:03 +08:00
if elapsed < 130 * time . Millisecond {
2026-07-02 10:56:38 +08:00
t . Errorf ( "elapsed=%v, want >= 130ms (proves exponential doubling, not constant delay)" , elapsed )
2026-06-17 13:24:03 +08:00
}
2026-07-02 10:56:38 +08:00
if got , want := inner . callCount ( ) , 4 ; got != want {
t . Errorf ( "inner.calls=%d, want %d (1 initial + 3 retries)" , got , want )
2026-06-17 13:24:03 +08:00
}
}
// TestRetryInvoker_NoRetriesWhenZero: maxRetries=0 means a single
// attempt with no retry on failure. Mirrors LLMParam.MaxRetries=0
// for latency-sensitive flows.
func TestRetryInvoker_NoRetriesWhenZero ( t * testing . T ) {
inner := & alwaysFailInvoker { err : errors . New ( "nope" ) }
r := newRetryInvoker ( inner , 0 , 50 * time . Millisecond )
_ , err := r . Invoke ( context . Background ( ) , ChatInvokeRequest { ModelName : "m" } )
if err == nil {
t . Fatal ( "expected error" )
}
if got := inner . callCount ( ) ; got != 1 {
t . Errorf ( "inner.calls=%d, want 1 (no retries)" , got )
}
}
// TestRetryInvoker_NilInner: a defensive nil check — the wrapper
// should not panic when constructed with nil inner.
func TestRetryInvoker_NilInner ( t * testing . T ) {
r := newRetryInvoker ( nil , 3 , time . Millisecond )
_ , err := r . Invoke ( context . Background ( ) , ChatInvokeRequest { ModelName : "m" } )
if err == nil {
t . Fatal ( "expected error for nil inner" )
}
}
// TestLLMParam_RespectsMaxRetries: an LLMComponent configured with
// MaxRetries=5 should exhaust after 6 attempts (1 initial + 5
// retries) when the invoker always fails. This is the integration
// test for the param-override path through resolveChatInvoker.
func TestLLMParam_RespectsMaxRetries ( t * testing . T ) {
inner := & alwaysFailInvoker { err : errors . New ( "downstream dead" ) }
withStubInvoker ( t , inner )
c := NewLLMComponent ( LLMParam {
ModelID : "m" ,
MaxRetries : 5 ,
} )
// Force the param to a tiny delay so the test is fast. The
// zero-value default is 2s, which would make this test slow.
c . param . DelayAfterError = time . Millisecond
_ , err := c . Invoke ( context . Background ( ) , map [ string ] any { "user_prompt" : "x" } )
if err == nil {
t . Fatal ( "expected error from exhausted retries" )
}
// 1 initial + 5 retries = 6 invoker calls.
if got , want := inner . callCount ( ) , 6 ; got != want {
t . Errorf ( "inner.calls=%d, want %d" , got , want )
}
}
// TestLLMParam_ZeroRetriesMeansOneAttempt: MaxRetries=0 must bypass
// retries entirely (the param-override path passes through
// resolveChatInvoker and a fresh retryInvoker with maxRetries=0).
func TestLLMParam_ZeroRetriesMeansOneAttempt ( t * testing . T ) {
inner := & alwaysFailInvoker { err : errors . New ( "once" ) }
withStubInvoker ( t , inner )
c := NewLLMComponent ( LLMParam {
ModelID : "m" ,
MaxRetries : 0 ,
} )
// MaxRetries=0 with default zero-value DelayAfterError triggers
// the "no param override" path through resolveChatInvoker, which
// returns the package default (3 retries). To genuinely request
// zero retries we set DelayAfterError to a non-zero sentinel so
// resolveChatInvoker wraps the default in a new retryInvoker
// with maxRetries=0.
c . param . DelayAfterError = time . Millisecond
_ , err := c . Invoke ( context . Background ( ) , map [ string ] any { "user_prompt" : "x" } )
if err == nil {
t . Fatal ( "expected error" )
}
if got := inner . callCount ( ) ; got != 1 {
t . Errorf ( "inner.calls=%d, want 1 (zero retries)" , got )
}
}
// TestLLMParam_DefaultRetries: with MaxRetries and
// DelayAfterError both zero (the v1 fixture default), the
// component should still retry up to the package default
// (retryInvokerDefaultRetries=3). This protects against
// regressions where a future change accidentally bypasses the
// retry loop on the hot path.
func TestLLMParam_DefaultRetries ( t * testing . T ) {
inner := & alwaysFailInvoker { err : errors . New ( "flaky" ) }
withStubInvoker ( t , inner )
c := NewLLMComponent ( LLMParam { ModelID : "m" } )
// Both fields zero — the test relies on the package default
// being applied. The default initial delay is 2s, which is too
// slow for a unit test, so we mutate the package default
// indirectly: the test cannot reach into the retry decorator
// (it's wrapped by resolveChatInvoker), so we instead assert
// behavior with a manually-fast retryInvoker injected via
// SetDefaultChatInvoker. This is the more honest test.
fastInner := & alwaysFailInvoker { err : errors . New ( "flaky" ) }
SetDefaultChatInvoker ( newRetryInvoker ( fastInner , 2 , time . Millisecond ) )
t . Cleanup ( func ( ) { SetDefaultChatInvoker ( nil ) } )
_ , err := c . Invoke ( context . Background ( ) , map [ string ] any { "user_prompt" : "x" } )
if err == nil {
t . Fatal ( "expected error after default retries" )
}
// 1 initial + 2 retries = 3.
if got , want := fastInner . callCount ( ) , 3 ; got != want {
t . Errorf ( "inner.calls=%d, want %d" , got , want )
}
// Original (un-wrapped) inner should have been called 0 times
// because resolveChatInvoker returned the package default (the
// one we just installed), not the one passed via withStubInvoker.
if got := inner . callCount ( ) ; got != 0 {
t . Errorf ( "unused inner.calls=%d, want 0" , got )
}
}
// TestUnwrapChatInvoker_StripsSingleRetryLayer is the unit-level
// test for the unwrapChatInvoker helper. It must peel off a
// single retryInvoker layer to return the bare invoker
// underneath, so the param-override path can install a fresh
// retryInvoker with the operator's literal MaxRetries without
// multiplicatively stacking on the boot retry.
func TestUnwrapChatInvoker_StripsSingleRetryLayer ( t * testing . T ) {
bare := & alwaysFailInvoker { err : errors . New ( "bare" ) }
wrapped := newRetryInvoker ( bare , 3 , time . Millisecond )
if got := unwrapChatInvoker ( wrapped ) ; got != bare {
t . Errorf ( "unwrapChatInvoker(retry(bare)) = %v, want %v (bare invoker)" , got , bare )
}
}
// TestUnwrapChatInvoker_NoRetryLayer verifies that a bare
// (non-retry) invoker passes through unwrapChatInvoker
// unchanged. The function must not wrap or modify the input
// when no retry layers are present.
func TestUnwrapChatInvoker_NoRetryLayer ( t * testing . T ) {
bare := & alwaysFailInvoker { err : errors . New ( "bare" ) }
if got := unwrapChatInvoker ( bare ) ; got != bare {
t . Errorf ( "unwrapChatInvoker(bare) = %v, want %v (unchanged passthrough)" , got , bare )
}
}
// TestUnwrapChatInvoker_StripsMultipleLayers is the defensive
// case: a chain of retryInvokers (production only installs one,
// but pathological callers could nest) is peeled down to the
// bare invoker. The loop bounds the walk at the first
// non-retryInvoker layer.
func TestUnwrapChatInvoker_StripsMultipleLayers ( t * testing . T ) {
bare := & alwaysFailInvoker { err : errors . New ( "bare" ) }
double := newRetryInvoker ( newRetryInvoker ( bare , 3 , time . Millisecond ) , 3 , time . Millisecond )
if got := unwrapChatInvoker ( double ) ; got != bare {
t . Errorf ( "unwrapChatInvoker(retry(retry(bare))) = %v, want %v (bare invoker)" , got , bare )
}
}
// TestLLM_ParamOverride_AbsoluteCount_NotStacked is the
// integration test for LLM retry normal-absolute-count
// semantics. It installs a boot retryInvoker with MaxRetries=3
// wrapping an alwaysFailInvoker, then runs an LLMComponent with
// MaxRetries=5. The pre-fix behaviour (stacking) would produce
// (3+1)*(5+1) = 24 invoker calls. The current implementation
// unwraps the boot layer and installs a fresh retryInvoker with
// the operator's literal MaxRetries, so the absolute count is
// exactly MaxRetries+1 = 6.
//
// A regression that re-introduces stacking (e.g. someone drops
// the unwrapChatInvoker call) fails this test.
func TestLLM_ParamOverride_AbsoluteCount_NotStacked ( t * testing . T ) {
bare := & alwaysFailInvoker { err : errors . New ( "downstream dead" ) }
// Simulate the production boot: a retryInvoker wrapping the
// bare invoker. The boot layer's MaxRetries=3 means 4
// invocations per call to the wrapped invoker; without
// unwrapping, the param-override retryInvoker would stack on
// top and produce 4*6 = 24 calls. With unwrapping, the
// absolute count is 6.
boot := newRetryInvoker ( bare , 3 , time . Millisecond )
withStubInvoker ( t , boot )
c := NewLLMComponent ( LLMParam {
ModelID : "m" ,
MaxRetries : 5 ,
} )
// Force a tiny delay so the test runs fast.
c . param . DelayAfterError = time . Millisecond
_ , err := c . Invoke ( context . Background ( ) , map [ string ] any { "user_prompt" : "x" } )
if err == nil {
t . Fatal ( "expected error from exhausted retries" )
}
// With the unwrap: 1 initial + 5 retries = 6 calls to
// the bare invoker. The boot layer is peeled off first.
// Without the unwrap: 6 outer × 4 inner = 24.
if got , want := bare . callCount ( ) , 6 ; got != want {
t . Errorf ( "bare.calls=%d, want %d (absolute count, not stacked). If you see 24, the multiplicative-stacking regression has been re-introduced." , got , want )
}
}
// TestLLM_NoParamOverride_StackingPreserved is the
// back-compat companion to the absolute-count test. When
// MaxRetries=0 AND DelayAfterError=0, the boot retry chain must
// run unchanged so existing DSLs that rely on the implicit
// 3-retry budget keep working.
//
// A future change that aggressively unwraps even when no
// override is set would silence the boot retry chain and
// regress production retry behaviour.
func TestLLM_NoParamOverride_StackingPreserved ( t * testing . T ) {
bare := & alwaysFailInvoker { err : errors . New ( "downstream dead" ) }
// Boot layer with the production default (3 retries).
boot := newRetryInvoker ( bare , 3 , time . Millisecond )
withStubInvoker ( t , boot )
// No param override: MaxRetries=0 AND DelayAfterError=0.
c := NewLLMComponent ( LLMParam { ModelID : "m" } )
_ , err := c . Invoke ( context . Background ( ) , map [ string ] any { "user_prompt" : "x" } )
if err == nil {
t . Fatal ( "expected error from exhausted retries" )
}
// 1 initial + 3 retries = 4 calls to the bare invoker (the
// boot layer ran unchanged).
if got , want := bare . callCount ( ) , 4 ; got != want {
t . Errorf ( "bare.calls=%d, want %d (boot layer ran unchanged — no param override means we keep the implicit 3-retry budget)" , got , want )
}
}