Files
ragflow/internal/harness/core/retry.go
Yingfeng 706e0d2d06 Refactor harness framework (#16271)
### What problem does this PR solve?

- Tools management
- Pregel engine wrapper for better usage
- UT race
- Coding style

### Type of change

- [x] Refactoring
2026-06-23 20:18:04 +08:00

513 lines
14 KiB
Go

package core
import (
"context"
"errors"
"fmt"
"io"
"time"
"ragflow/internal/harness/core/schema"
"ragflow/internal/harness/graph/types"
)
var (
ErrExceedMaxRetries = errors.New("exceeds max retries")
)
// RetryExhaustedError is returned when all retry attempts are exhausted.
type RetryExhaustedError struct {
LastErr error
TotalRetries int
}
func (e *RetryExhaustedError) Error() string {
if e.LastErr != nil {
return fmt.Sprintf("exceeds max retries: last error: %v", e.LastErr)
}
return "exceeds max retries"
}
func (e *RetryExhaustedError) Unwrap() error { return ErrExceedMaxRetries }
// WillRetryError is emitted when a retryable error occurs and a retry will be attempted.
type WillRetryError struct {
ErrStr string
RetryAttempt int
rejectReason any
err error
}
func (e *WillRetryError) Error() string { return e.ErrStr }
func (e *WillRetryError) Unwrap() error { return e.err }
func (e *WillRetryError) RejectReason() any { return e.rejectReason }
func init() {
schema.RegisterType("agentcore_will_retry_error", func() any { return &WillRetryError{} })
}
// RetryContext contains context passed to ShouldRetry during a retry decision.
type TypedRetryContext[M MessageType] struct {
RetryAttempt int
InputMessages []M
OutputMessage M
Err error
}
type RetryContext = TypedRetryContext[*schema.Message]
// RetryDecision represents the decision made by ShouldRetry.
type TypedRetryDecision[M MessageType] struct {
Retry bool
RewriteError error
ModifiedInputMessages []M
PersistModifiedInputMessages bool
AdditionalOptions []ModelOption
Backoff time.Duration
RejectReason any
}
type RetryDecision = TypedRetryDecision[*schema.Message]
// ModelRetryConfig configures retry behavior for the Model.
type TypedModelRetryConfig[M MessageType] struct {
MaxRetries int
ShouldRetry func(ctx context.Context, retryCtx *TypedRetryContext[M]) *TypedRetryDecision[M]
IsRetryAble func(ctx context.Context, err error) bool
BackoffFunc func(ctx context.Context, attempt int) time.Duration
}
type ModelRetryConfig = TypedModelRetryConfig[*schema.Message]
func defaultIsRetryAble(_ context.Context, err error) bool { return err != nil }
func defaultBackoff(_ context.Context, attempt int) time.Duration {
p := types.RetryPolicy{
InitialInterval: 100 * time.Millisecond,
BackoffFactor: 2.0,
MaxInterval: 10 * time.Second,
Jitter: true,
}
return p.CalculateBackoff(attempt)
}
// typedRetryModelWrapper wraps a Model with retry logic.
type typedRetryModelWrapper[M MessageType] struct {
inner Model[M]
config *TypedModelRetryConfig[M]
}
func newTypedRetryModelWrapper[M MessageType](inner Model[M], config *TypedModelRetryConfig[M]) *typedRetryModelWrapper[M] {
return &typedRetryModelWrapper[M]{inner: inner, config: config}
}
func (r *typedRetryModelWrapper[M]) Generate(ctx context.Context, input []M, opts ...ModelOption) (M, error) {
if r.config.ShouldRetry != nil {
return r.generateWithShouldRetry(ctx, input, opts...)
}
return r.generateLegacy(ctx, input, opts...)
}
func (r *typedRetryModelWrapper[M]) generateLegacy(ctx context.Context, input []M, opts ...ModelOption) (zero M, _ error) {
isRetryAble := r.config.IsRetryAble
if isRetryAble == nil {
isRetryAble = defaultIsRetryAble
}
backoff := r.config.BackoffFunc
if backoff == nil {
backoff = defaultBackoff
}
var lastErr error
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
out, err := r.inner.Generate(ctx, input, opts...)
if err == nil {
return out, nil
}
if errors.Is(err, ErrStreamCanceled) {
return zero, err
}
if !isRetryAble(ctx, err) {
return zero, err
}
lastErr = err
if attempt < r.config.MaxRetries {
if err := contextAwareSleep(ctx, backoff(ctx, attempt+1)); err != nil {
return zero, err
}
}
}
return zero, &RetryExhaustedError{LastErr: lastErr, TotalRetries: r.config.MaxRetries}
}
func (r *typedRetryModelWrapper[M]) generateWithShouldRetry(ctx context.Context, input []M, opts ...ModelOption) (M, error) {
backoff := r.config.BackoffFunc
if backoff == nil {
backoff = defaultBackoff
}
execCtx := getReActExecCtx[M](ctx)
currentInput := input
currentOpts := opts
var lastErr error
var zero M
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
if execCtx != nil {
execCtx.suppressEventSend = true
}
out, err := r.inner.Generate(ctx, currentInput, currentOpts...)
if execCtx != nil {
execCtx.suppressEventSend = false
}
if errors.Is(err, ErrStreamCanceled) {
return zero, err
}
retryCtx := &TypedRetryContext[M]{
RetryAttempt: attempt + 1, InputMessages: currentInput,
OutputMessage: out, Err: err,
}
decision := r.config.ShouldRetry(ctx, retryCtx)
if decision == nil {
decision = &TypedRetryDecision[M]{}
}
if !decision.Retry {
if decision.RewriteError != nil {
return zero, decision.RewriteError
}
if err != nil {
return zero, err
}
if execCtx != nil && execCtx.generator != nil && !isNilMessage(out) {
execCtx.send(typedModelOutputEvent(out, nil))
}
return out, nil
}
lastErr = err
if lastErr == nil {
lastErr = fmt.Errorf("model output rejected by ShouldRetry at attempt %d", attempt+1)
}
if attempt >= r.config.MaxRetries {
break
}
// Emit WillRetryError event before sleeping
if execCtx != nil && execCtx.generator != nil {
willRetry := &WillRetryError{ErrStr: lastErr.Error(), RetryAttempt: attempt + 1, rejectReason: decision.RejectReason, err: lastErr}
execCtx.send(&TypedAgentEvent[M]{Err: any(willRetry).(error)})
}
applyRetryDecision(&currentInput, &currentOpts, decision)
delay := decision.Backoff
if delay == 0 {
delay = backoff(ctx, attempt+1)
}
if err := contextAwareSleep(ctx, delay); err != nil {
return zero, err
}
}
return zero, &RetryExhaustedError{LastErr: lastErr, TotalRetries: r.config.MaxRetries}
}
func (r *typedRetryModelWrapper[M]) Stream(ctx context.Context, input []M, opts ...ModelOption) (*schema.StreamReader[M], error) {
if r.config.ShouldRetry != nil {
return r.streamWithShouldRetry(ctx, input, opts...)
}
return r.streamLegacy(ctx, input, opts...)
}
func (r *typedRetryModelWrapper[M]) streamLegacy(ctx context.Context, input []M, opts ...ModelOption) (*schema.StreamReader[M], error) {
isRetryAble := r.config.IsRetryAble
if isRetryAble == nil {
isRetryAble = defaultIsRetryAble
}
backoff := r.config.BackoffFunc
if backoff == nil {
backoff = defaultBackoff
}
var lastErr error
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
stream, err := r.inner.Stream(ctx, input, opts...)
if err != nil {
if errors.Is(err, ErrStreamCanceled) {
return nil, err
}
if !isRetryAble(ctx, err) {
return nil, err
}
lastErr = err
if attempt < r.config.MaxRetries {
if err := contextAwareSleep(ctx, backoff(ctx, attempt+1)); err != nil {
return nil, err
}
}
continue
}
// Verify the stream is healthy by reading one chunk
chunk, streamErr := stream.Recv()
if streamErr == nil {
outStream := schema.NewStreamReader[M]()
go func() {
outStream.Send(chunk, nil)
for {
c, e := stream.Recv()
if e == io.EOF {
break
}
if e != nil {
outStream.Send(c, e)
return
}
select {
case <-ctx.Done():
outStream.Send(c, ctx.Err())
return
default:
}
outStream.Send(c, nil)
}
outStream.Close()
}()
return outStream, nil
}
stream.Close()
if errors.Is(streamErr, ErrStreamCanceled) {
return nil, streamErr
}
if !isRetryAble(ctx, streamErr) {
return nil, streamErr
}
lastErr = streamErr
if attempt < r.config.MaxRetries {
if err := contextAwareSleep(ctx, backoff(ctx, attempt+1)); err != nil {
return nil, err
}
}
}
return nil, &RetryExhaustedError{LastErr: lastErr, TotalRetries: r.config.MaxRetries}
}
func (r *typedRetryModelWrapper[M]) streamWithShouldRetry(ctx context.Context, input []M, opts ...ModelOption) (*schema.StreamReader[M], error) {
backoff := r.config.BackoffFunc
if backoff == nil {
backoff = defaultBackoff
}
execCtx := getReActExecCtx[M](ctx)
currentInput := input
currentOpts := opts
var lastErr error
sig := &retrySignal{ch: make(chan streamRetryVerdict, 1)}
if execCtx != nil {
execCtx.retrySignal = sig
}
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
stream, err := r.inner.Stream(ctx, currentInput, currentOpts...)
if err != nil {
if errors.Is(err, ErrStreamCanceled) {
return nil, err
}
retryCtx := &TypedRetryContext[M]{
RetryAttempt: attempt + 1, InputMessages: currentInput, Err: err,
}
decision := r.config.ShouldRetry(ctx, retryCtx)
if decision == nil {
decision = &TypedRetryDecision[M]{}
}
if !decision.Retry {
if decision.RewriteError != nil {
return nil, decision.RewriteError
}
return nil, err
}
lastErr = err
if attempt < r.config.MaxRetries {
if execCtx != nil && execCtx.generator != nil {
execCtx.send(&TypedAgentEvent[M]{Err: &WillRetryError{ErrStr: lastErr.Error(), RetryAttempt: attempt + 1, rejectReason: decision.RejectReason, err: lastErr}})
}
applyRetryDecision(&currentInput, &currentOpts, decision)
delay := decision.Backoff
if delay == 0 {
delay = backoff(ctx, attempt+1)
}
if err := contextAwareSleep(ctx, delay); err != nil {
return nil, err
}
}
continue
}
// Read first chunk for verification
chunk, streamErr := stream.Recv()
if streamErr != nil && streamErr != io.EOF {
stream.Close()
retryCtx := &TypedRetryContext[M]{
RetryAttempt: attempt + 1, InputMessages: currentInput, Err: streamErr,
}
decision := r.config.ShouldRetry(ctx, retryCtx)
if decision == nil {
decision = &TypedRetryDecision[M]{}
}
if !decision.Retry {
if decision.RewriteError != nil {
return nil, decision.RewriteError
}
return nil, streamErr
}
lastErr = streamErr
select {
case sig.ch <- streamRetryVerdict{WillRetry: true, Err: streamErr, RejectReason: decision.RejectReason}:
default:
}
if attempt < r.config.MaxRetries {
if execCtx != nil && execCtx.generator != nil {
execCtx.send(&TypedAgentEvent[M]{Err: &WillRetryError{ErrStr: lastErr.Error(), RetryAttempt: attempt + 1, rejectReason: decision.RejectReason, err: lastErr}})
}
applyRetryDecision(&currentInput, &currentOpts, decision)
delay := decision.Backoff
if delay == 0 {
delay = backoff(ctx, attempt+1)
}
if err := contextAwareSleep(ctx, delay); err != nil {
return nil, err
}
}
continue
}
// Collect all chunks for output event, forward to caller
var allChunks []M
if streamErr != io.EOF {
allChunks = append(allChunks, chunk)
}
callerCh := schema.NewStreamReader[M]()
go func() {
if len(allChunks) > 0 {
callerCh.Send(allChunks[0], nil)
}
for {
c, e := stream.Recv()
if e == io.EOF {
break
}
if e != nil {
callerCh.Send(c, e)
return
}
allChunks = append(allChunks, c)
callerCh.Send(c, nil)
}
// Send output event with merged message
if execCtx != nil && execCtx.generator != nil && len(allChunks) > 0 {
if merged, err := mergeChunks(allChunks); err == nil {
execCtx.send(typedModelOutputEvent(merged, nil))
}
}
callerCh.Close()
}()
select {
case sig.ch <- streamRetryVerdict{WillRetry: false}:
default:
}
return callerCh, nil
}
return nil, &RetryExhaustedError{LastErr: lastErr, TotalRetries: r.config.MaxRetries}
}
func (r *typedRetryModelWrapper[M]) BindTools(tools []*schema.ToolInfo) error {
return r.inner.BindTools(tools)
}
// WithModelRetry wraps a Model with retry logic.
// When cfg.ShouldRetry is set but MaxRetries is 0, the loop runs exactly once
// (attempt 0), so ShouldRetry returning Retry:true will immediately exhaust
// with RetryExhaustedError{TotalRetries: 0}. Set MaxRetries >= 1 to allow
// ShouldRetry-driven retries to actually retry.
func WithModelRetry[M MessageType](inner Model[M], cfg *TypedModelRetryConfig[M]) Model[M] {
if cfg == nil || (cfg.MaxRetries <= 0 && cfg.ShouldRetry == nil) {
return inner
}
return newTypedRetryModelWrapper(inner, cfg)
}
func applyRetryDecision[M MessageType](input *[]M, opts *[]ModelOption, decision *TypedRetryDecision[M]) {
if decision.ModifiedInputMessages != nil && decision.PersistModifiedInputMessages {
*input = decision.ModifiedInputMessages
} else if decision.ModifiedInputMessages != nil {
// Apply for the next attempt but don't persist to the original input.
// Caller must handle revert externally.
tmp := make([]M, len(decision.ModifiedInputMessages))
copy(tmp, decision.ModifiedInputMessages)
*input = tmp
}
if decision.AdditionalOptions != nil {
*opts = append(*opts, decision.AdditionalOptions...)
}
}
func contextAwareSleep(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
return nil
}
}
func mergeChunks[M MessageType](chunks []M) (M, error) {
var zero M
if len(chunks) == 0 {
return zero, nil
}
switch c := any(chunks).(type) {
case []*schema.Message:
merged, err := schema.ConcatMessages(c)
if err != nil {
return zero, err
}
return any(merged).(M), nil
case []*schema.AgenticMessage:
merged, err := schema.ConcatAgenticMessages(c)
if err != nil {
return zero, err
}
return any(merged).(M), nil
}
return chunks[0], nil
}
// streamRetryVerdict is the internal retry signal for streaming retry.
type streamRetryVerdict struct {
WillRetry bool
Err error
RejectReason any
}
type retrySignal struct {
ch chan streamRetryVerdict
}
func (rs *retrySignal) consume() streamRetryVerdict {
if rs == nil {
return streamRetryVerdict{}
}
select {
case v := <-rs.ch:
return v
default:
return streamRetryVerdict{}
}
}
// WithRetry attaches retry configuration to an option.
func WithRetry[M MessageType](cfg *TypedModelRetryConfig[M]) ModelOption {
return &typedModelOption[M]{f: func(o *modelOptions[M]) { o.RetryConfig = cfg }}
}