-
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 1 commit
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 and "server" not in msg | ||
| return False | ||
|
|
||
| async def execute_command( | ||
| self, | ||
| prompt: str, | ||
|
|
@@ -288,11 +298,43 @@ 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) | ||
| last_exc: Optional[BaseException] = None | ||
|
|
||
| for attempt in range(max_attempts): | ||
| 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) | ||
| 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: | ||
| last_exc = exc | ||
| 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
|
||
| else: | ||
| if last_exc is not None: | ||
| raise last_exc | ||
|
||
|
|
||
| # 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,21 @@ class Settings(BaseSettings): | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description="List of explicitly disallowed Claude tools/commands", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Retry settings | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| claude_retry_max_attempts: int = Field( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DEFAULT_RETRY_MAX_ATTEMPTS, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
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.