Skip to content

bug(retry):Pydantic AI provider never retries on HTTP 429/5xx -- ModelHTTPError not recognized as retryable #454

Description

@nskun

Summary:

The Claude provider's retry logic silently skips retries for HTTP 429 (rate limit) and 5xx errors when using the Pydantic AI backend.
Agents fail immediately even with retry configured in workflow YAML.

Environment:

Conductor v0.1.31
pydantic-ai 2.x(latest)
Provider: claude (Pydantic AI backend)

Steps to Reproduce:

1.Configure an agent with retry

agents:
  -name: my_agent
    retry:
      max_attempts: 3
      backoff: exponential
      delay_seconds: 60
      retry_on: [provider_error]
  1. Hit a 429 rate limit from the API.
  2. Observe: agent fails immediately. Zero agent_retry events in the event log.

Root Cause:

Pydantic AI v2 wraps HTTP errors as pydantic_ai.exceptions.ModelHTTPError. In execute_with_retry(), the function _is_retryable_error() checks for:

  • conductor.exceptions.ProviderError -- ModelHTTPError is not this
  • Anthropic SDK class names (RateLimitError, APIStatusError) -- "ModelHTTPError" is not in the set
  • anthropic.APIStatusError isinstance check -- ModelHTTPError is not this

All checks fail -> returns False -> error is immediately re-raised at L289 as ProviderError(is_retryable=False)
, before the retry loop ever executes.

Suggested Fix:

Add ModelHTTPError handling:

try:
    from pydantic_ai.exceptions import ModelHTTPError
except ImportError:
    ModelHTTPError = None

def _is_retryable_error(exception):
    # New: handle Pydantic AI's HTTP error type
    if ModelHTTPError is not None and isinstance(exception, ModelHTTPError):
        code = exception.status_code
        return code == 429 or 500 <= code < 600

Also worth considering:

_get_retry_after() should extract the wait time from ModelHTTPError.body when present, so Conductor can respect the server's requested delay instead of falling back to _calculate_delay().

Related:

max_delay default silently caps delay_seconds
A secondary issue compounds the problem. RetryConfig.max_delay defaults to 30 seconds.
When a user sets delay_seconds: 60, the delay is silently capped:

delay = base_delay * 2 ** (attempt - 1) # 60 * 1 = 60
delay = min(delay, config.max_delay) # min(60, 30) = 30 <- capped

For rate limits with a 60second window, the 30second wait is insufficient, causing repeated 429s even when retries do fire. The user's explicit delay_seconds should not be overridden by a lower default.

Suggestion:

Ensure max_delay >= delay_seconds when resolving the config.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions