-
Notifications
You must be signed in to change notification settings - Fork 274
feat: add exponential backoff retry for transient SDK errors #127
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -146,6 +146,16 @@ def __init__( | |
| else: | ||
| logger.info("No API key provided, using existing Claude CLI authentication") | ||
|
|
||
| def _is_retryable_error(self, exc: BaseException) -> bool: | ||
| """Return True for transient errors that warrant a retry. | ||
| asyncio.TimeoutError is intentional (user-configured timeout) — not retried. | ||
| Only non-MCP CLIConnectionError is considered transient. | ||
| """ | ||
| if isinstance(exc, CLIConnectionError): | ||
| msg = str(exc).lower() | ||
| return "mcp" not in msg # "server" alone is too broad | ||
| return False | ||
|
|
||
| async def execute_command( | ||
| self, | ||
| prompt: str, | ||
|
|
@@ -288,11 +298,49 @@ async def _run_client() -> None: | |
| finally: | ||
| await client.disconnect() | ||
|
|
||
| # Execute with timeout | ||
| await asyncio.wait_for( | ||
| _run_client(), | ||
| timeout=self.config.claude_timeout_seconds, | ||
| ) | ||
| # Execute with timeout, retrying on transient CLIConnectionError | ||
| max_attempts = max(1, self.config.claude_retry_max_attempts) | ||
|
|
||
| for attempt in range(max_attempts): | ||
| # Reset message accumulator each attempt so that a failed attempt | ||
| # does not pollute the next one with partial/duplicate messages. | ||
| # _run_client() closes over `messages` by reference (late-binding | ||
| # closure), so clearing it here is seen by every new call. | ||
| messages.clear() | ||
|
|
||
| if attempt > 0: | ||
| delay = min( | ||
| self.config.claude_retry_base_delay | ||
| * (self.config.claude_retry_backoff_factor ** (attempt - 1)), | ||
| self.config.claude_retry_max_delay, | ||
| ) | ||
| logger.warning( | ||
| "Retrying Claude SDK command", | ||
| attempt=attempt + 1, | ||
| max_attempts=max_attempts, | ||
| delay_seconds=delay, | ||
| ) | ||
| await asyncio.sleep(delay) | ||
| # Note: asyncio.TimeoutError raised by wait_for is intentionally | ||
| # NOT caught below — it propagates immediately to the outer | ||
| # `except asyncio.TimeoutError` handler, bypassing the retry | ||
| # loop entirely. Timeouts reflect a user-configured hard limit | ||
| # and should not be retried. | ||
| try: | ||
| await asyncio.wait_for( | ||
| _run_client(), | ||
| timeout=self.config.claude_timeout_seconds, | ||
| ) | ||
| break # success — exit retry loop | ||
| except CLIConnectionError as exc: | ||
| if self._is_retryable_error(exc) and attempt < max_attempts - 1: | ||
| logger.warning( | ||
| "Transient connection error, will retry", | ||
| attempt=attempt + 1, | ||
| error=str(exc), | ||
| ) | ||
| continue | ||
| raise # non-retryable or attempts exhausted | ||
|
Comment on lines
+301
to
+343
|
||
|
|
||
| # Extract cost, tools, and session_id from result message | ||
| cost = 0.0 | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -26,6 +26,10 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DEFAULT_RATE_LIMIT_BURST, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DEFAULT_RATE_LIMIT_REQUESTS, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DEFAULT_RATE_LIMIT_WINDOW, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DEFAULT_RETRY_BACKOFF_FACTOR, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DEFAULT_RETRY_BASE_DELAY, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DEFAULT_RETRY_MAX_ATTEMPTS, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DEFAULT_RETRY_MAX_DELAY, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DEFAULT_SESSION_TIMEOUT_HOURS, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -121,6 +125,34 @@ class Settings(BaseSettings): | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description="List of explicitly disallowed Claude tools/commands", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Retry settings | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| claude_retry_max_attempts: int = Field( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DEFAULT_RETRY_MAX_ATTEMPTS, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ge=0, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description="Max retry attempts for transient SDK errors (0 = disabled)", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| claude_retry_base_delay: float = Field( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DEFAULT_RETRY_BASE_DELAY, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ge=0, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description=( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Base delay in seconds between retries. " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "0 means retries are attempted immediately with no pause." | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| claude_retry_backoff_factor: float = Field( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DEFAULT_RETRY_BACKOFF_FACTOR, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| gt=0, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description="Exponential backoff multiplier", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| claude_retry_max_delay: float = Field( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DEFAULT_RETRY_MAX_DELAY, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ge=0, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description=( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Maximum delay cap in seconds. " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "0 disables the cap entirely (delays grow unbounded with backoff)." | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+132
to
+155
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description="Max retry attempts for transient SDK errors (0 = disabled)", | |
| ) | |
| claude_retry_base_delay: float = Field( | |
| DEFAULT_RETRY_BASE_DELAY, description="Base delay in seconds between retries" | |
| ) | |
| claude_retry_backoff_factor: float = Field( | |
| DEFAULT_RETRY_BACKOFF_FACTOR, description="Exponential backoff multiplier" | |
| ) | |
| claude_retry_max_delay: float = Field( | |
| DEFAULT_RETRY_MAX_DELAY, description="Maximum delay cap in seconds" | |
| ) | |
| ge=0, | |
| description="Max retry attempts for transient SDK errors (0 = disabled)", | |
| ) | |
| claude_retry_base_delay: float = Field( | |
| DEFAULT_RETRY_BASE_DELAY, | |
| ge=0, | |
| description="Base delay in seconds between retries", | |
| ) | |
| claude_retry_backoff_factor: float = Field( | |
| DEFAULT_RETRY_BACKOFF_FACTOR, | |
| gt=0, | |
| description="Exponential backoff multiplier", | |
| ) | |
| claude_retry_max_delay: float = Field( | |
| DEFAULT_RETRY_MAX_DELAY, | |
| ge=0, | |
| description="Maximum delay cap in seconds", | |
| ) | |
| @model_validator(mode="after") | |
| def validate_retry_delays(self) -> "Settings": | |
| """Ensure retry delay configuration is internally consistent.""" | |
| if self.claude_retry_max_delay < self.claude_retry_base_delay: | |
| raise ValueError( | |
| "claude_retry_max_delay must be greater than or equal to " | |
| "claude_retry_base_delay" | |
| ) | |
| return self |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_is_retryable_error()determines MCP vs non-MCP by substring matching onstr(exc), which is brittle and duplicates the MCP-detection logic used later when translatingCLIConnectionErrorintoClaudeMCPError. To reduce the chance of misclassification and keep behavior consistent, consider centralizing this classification (single helper used for both retry decision and final exception mapping), or using structured attributes fromCLIConnectionErrorif available.