handleModelError classifies err and decides what to do next: - retryDecisionReturn — context cancelled while sleeping; caller returns ctx.Err() - retryDecisionBreak — non-retryable error or 429 with fallbacks; skip to next model - retryDecisionContinue — retryable error or 429 without fallbacks
( ctx context.Context, err error, a *agent.Agent, modelEntry modelWithFallback, attempt int, hasFallbacks bool, primaryFailedWithNonRetryable *bool, )
| 397 | // Side-effect: sets *primaryFailedWithNonRetryable when the primary model fails with a |
| 398 | // non-retryable (or rate-limited-with-fallbacks) error. |
| 399 | func (e *fallbackExecutor) handleModelError( |
| 400 | ctx context.Context, |
| 401 | err error, |
| 402 | a *agent.Agent, |
| 403 | modelEntry modelWithFallback, |
| 404 | attempt int, |
| 405 | hasFallbacks bool, |
| 406 | primaryFailedWithNonRetryable *bool, |
| 407 | ) retryDecision { |
| 408 | retryable, rateLimited, retryAfter := modelerrors.ClassifyModelError(err) |
| 409 | |
| 410 | if rateLimited { |
| 411 | // Gate: only retry on 429 if opt-in is enabled AND no fallbacks exist. |
| 412 | // Default behavior (retryOnRateLimit=false) treats 429 as non-retryable, |
| 413 | // identical to today's behavior before this feature was added. |
| 414 | if !e.retryOnRateLimit || hasFallbacks { |
| 415 | slog.WarnContext(ctx, "Rate limited, treating as non-retryable", |
| 416 | "agent", a.Name(), |
| 417 | "model", modelEntry.provider.ID().String(), |
| 418 | "retry_on_rate_limit_enabled", e.retryOnRateLimit, |
| 419 | "has_fallbacks", hasFallbacks, |
| 420 | "error", err) |
| 421 | if !modelEntry.isFallback { |
| 422 | *primaryFailedWithNonRetryable = true |
| 423 | } |
| 424 | return retryDecisionBreak |
| 425 | } |
| 426 | |
| 427 | // Opt-in enabled, no fallbacks → retry same model after honouring Retry-After (or backoff). |
| 428 | waitDuration := retryAfter |
| 429 | if waitDuration <= 0 { |
| 430 | waitDuration = backoff.Calculate(attempt) |
| 431 | } else if waitDuration > backoff.MaxRetryAfterWait { |
| 432 | slog.WarnContext(ctx, "Retry-After exceeds maximum, capping", |
| 433 | "agent", a.Name(), |
| 434 | "model", modelEntry.provider.ID().String(), |
| 435 | "retry_after", retryAfter, |
| 436 | "max", backoff.MaxRetryAfterWait) |
| 437 | waitDuration = backoff.MaxRetryAfterWait |
| 438 | } |
| 439 | slog.WarnContext(ctx, "Rate limited, retrying (opt-in enabled)", |
| 440 | "agent", a.Name(), |
| 441 | "model", modelEntry.provider.ID().String(), |
| 442 | "attempt", attempt+1, |
| 443 | "wait", waitDuration, |
| 444 | "retry_after_from_header", retryAfter > 0, |
| 445 | "error", err) |
| 446 | if !backoff.SleepWithContext(ctx, waitDuration) { |
| 447 | return retryDecisionReturn |
| 448 | } |
| 449 | return retryDecisionContinue |
| 450 | } |
| 451 | |
| 452 | if !retryable { |
| 453 | slog.ErrorContext(ctx, "Non-retryable error from model", |
| 454 | "agent", a.Name(), |
| 455 | "model", modelEntry.provider.ID().String(), |
| 456 | "error", err) |
no test coverage detected