Coverage for src / agent / cli / error_handler.py: 90%
186 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 14:30 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 14:30 +0000
1# Copyright 2025-2026 Microsoft Corporation
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
15"""Error handler for provider API errors.
17This module provides error classification, formatting, and display for LLM provider
18API errors. It converts cryptic SDK exceptions into user-friendly error messages
19with actionable troubleshooting steps.
20"""
22import logging
24from agent.config.schema import AgentSettings
25from agent.exceptions import (
26 AgentError,
27 ProviderAPIError,
28 ProviderAuthError,
29 ProviderModelNotFoundError,
30 ProviderRateLimitError,
31 ProviderTimeoutError,
32)
34logger = logging.getLogger(__name__)
37def classify_anthropic_error(
38 error: Exception, config: AgentSettings | None = None
39) -> AgentError | None:
40 """Classify Anthropic SDK exceptions into our exception types.
42 Args:
43 error: Exception from Anthropic SDK
44 config: Agent configuration (for model name)
46 Returns:
47 Classified AgentError or None if can't classify
48 """
49 try:
50 # Import anthropic module to check exception types
51 import anthropic
52 except ImportError:
53 return None
55 model = config.anthropic_model if config else None
57 # Check for APIStatusError (HTTP errors)
58 if isinstance(error, anthropic.APIStatusError):
59 status_code = error.status_code
60 message = str(error)
61 request_id = getattr(error, "request_id", None)
63 # Extract error details from response body if available
64 if hasattr(error, "body") and isinstance(error.body, dict):
65 body = error.body
66 if "error" in body and isinstance(body["error"], dict):
67 error_dict = body["error"]
68 message = error_dict.get("message", message)
70 # Classify by status code
71 if status_code in (500, 503, 529): # 529 is Anthropic's overloaded status
72 return ProviderAPIError(
73 provider="anthropic",
74 status_code=status_code,
75 message=message,
76 request_id=request_id,
77 model=model,
78 original_error=error,
79 )
80 elif status_code in (401, 403):
81 return ProviderAuthError(
82 provider="anthropic",
83 status_code=status_code,
84 message=message,
85 model=model,
86 original_error=error,
87 )
88 elif status_code == 429:
89 retry_after = getattr(error.response, "headers", {}).get("retry-after")
90 retry_after_int = int(retry_after) if retry_after and retry_after.isdigit() else None
91 return ProviderRateLimitError(
92 provider="anthropic",
93 status_code=status_code,
94 message=message,
95 retry_after=retry_after_int,
96 model=model,
97 original_error=error,
98 )
99 elif status_code == 404:
100 return ProviderModelNotFoundError(
101 provider="anthropic",
102 status_code=status_code,
103 message=message,
104 model=model,
105 original_error=error,
106 )
108 # Check for connection errors (network issues, timeouts)
109 elif isinstance(error, anthropic.APIConnectionError):
110 return ProviderTimeoutError(
111 provider="anthropic",
112 message=str(error),
113 model=model,
114 original_error=error,
115 )
117 return None
120def classify_openai_error(
121 error: Exception, config: AgentSettings | None = None
122) -> AgentError | None:
123 """Classify OpenAI SDK exceptions into our exception types.
125 Args:
126 error: Exception from OpenAI SDK
127 config: Agent configuration (for model name)
129 Returns:
130 Classified AgentError or None if can't classify
131 """
132 try:
133 # Import openai module to check exception types
134 import openai
135 except ImportError:
136 return None
138 # Determine model based on provider
139 model = None
140 if config:
141 if config.llm_provider == "openai":
142 model = config.openai_model
143 elif config.llm_provider == "azure":
144 model = config.azure_openai_deployment
145 elif config.llm_provider == "github":
146 model = config.github_model
148 provider = config.llm_provider if config else "openai"
150 # Check for specific OpenAI error types
151 if isinstance(error, openai.AuthenticationError):
152 return ProviderAuthError(
153 provider=provider,
154 status_code=401,
155 message=str(error),
156 model=model,
157 original_error=error,
158 )
159 elif isinstance(error, openai.RateLimitError):
160 return ProviderRateLimitError(
161 provider=provider,
162 status_code=429,
163 message=str(error),
164 model=model,
165 original_error=error,
166 )
167 elif isinstance(error, openai.APIConnectionError):
168 return ProviderTimeoutError(
169 provider=provider,
170 message=str(error),
171 model=model,
172 original_error=error,
173 )
174 elif isinstance(error, openai.APIStatusError):
175 status_code = error.status_code
176 message = str(error)
178 if status_code in (500, 503):
179 return ProviderAPIError(
180 provider=provider,
181 status_code=status_code,
182 message=message,
183 model=model,
184 original_error=error,
185 )
186 elif status_code == 404:
187 return ProviderModelNotFoundError(
188 provider=provider,
189 status_code=status_code,
190 message=message,
191 model=model,
192 original_error=error,
193 )
195 return None
198def classify_gemini_error(
199 error: Exception, config: AgentSettings | None = None
200) -> AgentError | None:
201 """Classify Google Gemini exceptions into our exception types.
203 Args:
204 error: Exception from Google SDK
205 config: Agent configuration (for model name)
207 Returns:
208 Classified AgentError or None if can't classify
209 """
210 try:
211 # Import google.api_core for exception types
212 from google.api_core import exceptions as google_exceptions # type: ignore[import-untyped]
213 except ImportError:
214 return None
216 model = config.gemini_model if config else None
218 # Check for specific Google API exceptions
219 if isinstance(error, google_exceptions.Unauthenticated):
220 return ProviderAuthError(
221 provider="gemini",
222 status_code=401,
223 message=str(error),
224 model=model,
225 original_error=error,
226 )
227 elif isinstance(error, google_exceptions.ResourceExhausted):
228 return ProviderRateLimitError(
229 provider="gemini",
230 status_code=429,
231 message=str(error),
232 model=model,
233 original_error=error,
234 )
235 elif isinstance(error, google_exceptions.NotFound):
236 return ProviderModelNotFoundError(
237 provider="gemini",
238 status_code=404,
239 message=str(error),
240 model=model,
241 original_error=error,
242 )
243 elif isinstance(
244 error, (google_exceptions.InternalServerError, google_exceptions.ServiceUnavailable)
245 ):
246 status_code = 500 if isinstance(error, google_exceptions.InternalServerError) else 503
247 return ProviderAPIError(
248 provider="gemini",
249 status_code=status_code,
250 message=str(error),
251 model=model,
252 original_error=error,
253 )
254 elif isinstance(error, (google_exceptions.DeadlineExceeded, google_exceptions.Aborted)):
255 return ProviderTimeoutError(
256 provider="gemini",
257 message=str(error),
258 model=model,
259 original_error=error,
260 )
262 return None
265def classify_provider_error(
266 error: Exception, config: AgentSettings | None = None
267) -> AgentError | None:
268 """Classify any provider exception into our exception types.
270 This is the main dispatch function that tries all provider-specific classifiers.
272 Args:
273 error: Exception from any provider SDK
274 config: Agent configuration (for provider and model info)
276 Returns:
277 Classified AgentError or None if can't classify
278 """
279 # Try Anthropic classifier
280 classified = classify_anthropic_error(error, config)
281 if classified:
282 return classified
284 # Try OpenAI classifier (handles openai, azure, github)
285 classified = classify_openai_error(error, config)
286 if classified:
287 return classified
289 # Try Gemini classifier
290 classified = classify_gemini_error(error, config)
291 if classified:
292 return classified
294 # Unknown error - can't classify
295 return None
298# ============================================================================
299# Error Formatting
300# ============================================================================
303# Provider status pages
304PROVIDER_STATUS_PAGES = {
305 "anthropic": "https://status.anthropic.com",
306 "openai": "https://status.openai.com",
307 "azure": "https://status.azure.com",
308 "gemini": "https://status.cloud.google.com",
309 "github": "https://www.githubstatus.com",
310 "local": None,
311 "foundry": "https://status.azure.com",
312}
314# Alternative providers for suggestions
315PROVIDER_ALTERNATIVES = {
316 "anthropic": ["openai", "github"],
317 "openai": ["anthropic", "github"],
318 "azure": ["anthropic", "openai"],
319 "gemini": ["anthropic", "openai"],
320 "github": ["anthropic", "openai"],
321 "local": ["github", "anthropic"],
322 "foundry": ["anthropic", "openai"],
323}
325# Provider console URLs for API keys
326PROVIDER_CONSOLE_URLS = {
327 "anthropic": "https://console.anthropic.com/settings/keys",
328 "openai": "https://platform.openai.com/api-keys",
329 "azure": "https://portal.azure.com",
330 "gemini": "https://makersuite.google.com/app/apikey",
331 "github": "https://github.com/settings/tokens",
332}
335def _get_provider_display_name(provider: str) -> str:
336 """Get user-friendly provider display name.
338 Args:
339 provider: Provider name (anthropic, openai, etc.)
341 Returns:
342 Display name (Anthropic, OpenAI, etc.)
343 """
344 display_names = {
345 "anthropic": "Anthropic",
346 "openai": "OpenAI",
347 "azure": "Azure OpenAI",
348 "gemini": "Google Gemini",
349 "github": "GitHub Models",
350 "local": "Local (Docker)",
351 "foundry": "Azure AI Foundry",
352 }
353 return display_names.get(provider, provider.title())
356def format_provider_api_error(error: ProviderAPIError) -> str:
357 """Format a provider API error (500, 503, 529) with troubleshooting steps.
359 Args:
360 error: ProviderAPIError instance
362 Returns:
363 Formatted error message with Rich markup
364 """
365 provider_display = _get_provider_display_name(error.provider)
366 status_name = "Overloaded" if error.status_code == 529 else "Internal Server Error"
368 lines = [
369 f"[bold red]Provider API Error ({provider_display})[/bold red]",
370 "",
371 f"The {provider_display} API returned a {error.status_code} {status_name}.",
372 "This is a temporary issue on the provider's side.",
373 "",
374 "[bold]Troubleshooting:[/bold]",
375 " • Try again in a few minutes",
376 ]
378 # Add provider alternatives
379 alternatives = PROVIDER_ALTERNATIVES.get(error.provider, [])
380 if alternatives:
381 lines.append(" • Switch to a different provider:")
382 lines.append(f" [cyan]agent --provider {alternatives[0]}[/cyan]")
384 # Add status page link
385 status_url = PROVIDER_STATUS_PAGES.get(error.provider)
386 if status_url:
387 lines.append(f" • Check status: [link]{status_url}[/link]")
389 lines.append("")
390 lines.append("[dim]Technical Details:[/dim]")
391 lines.append(f"[dim] Status: {error.status_code} {status_name}[/dim]")
392 if error.model:
393 lines.append(f"[dim] Model: {error.model}[/dim]")
394 if error.request_id:
395 lines.append(f"[dim] Request ID: {error.request_id}[/dim]")
397 return "\n".join(lines)
400def format_provider_auth_error(error: ProviderAuthError) -> str:
401 """Format a provider authentication error (401, 403) with fix instructions.
403 Args:
404 error: ProviderAuthError instance
406 Returns:
407 Formatted error message with Rich markup
408 """
409 provider_display = _get_provider_display_name(error.provider)
411 lines = [
412 f"[bold red]Authentication Error ({provider_display})[/bold red]",
413 "",
414 f"The {provider_display} API rejected your API key or credentials.",
415 "",
416 "[bold]Fix:[/bold]",
417 ]
419 # Provider-specific instructions
420 console_url = PROVIDER_CONSOLE_URLS.get(error.provider)
421 if console_url:
422 lines.append(" 1. Get your API key from:")
423 lines.append(f" [link]{console_url}[/link]")
425 lines.append(" 2. Configure it:")
426 lines.append(f" [cyan]agent config provider {error.provider}[/cyan]")
428 # Environment variable option
429 env_var_names = {
430 "anthropic": "ANTHROPIC_API_KEY",
431 "openai": "OPENAI_API_KEY",
432 "azure": "AZURE_OPENAI_API_KEY",
433 "gemini": "GEMINI_API_KEY",
434 "github": "GITHUB_TOKEN",
435 }
436 env_var = env_var_names.get(error.provider)
437 if env_var:
438 lines.append(" 3. Or set environment variable:")
439 lines.append(f" [cyan]export {env_var}=your-key-here[/cyan]")
441 lines.append("")
442 lines.append("[dim]Technical Details:[/dim]")
443 lines.append(f"[dim] Status: {error.status_code} Unauthorized[/dim]")
444 if error.model:
445 lines.append(f"[dim] Model: {error.model}[/dim]")
447 return "\n".join(lines)
450def format_provider_rate_limit_error(error: ProviderRateLimitError) -> str:
451 """Format a provider rate limit error (429) with retry guidance.
453 Args:
454 error: ProviderRateLimitError instance
456 Returns:
457 Formatted error message with Rich markup
458 """
459 provider_display = _get_provider_display_name(error.provider)
461 lines = [
462 f"[bold yellow]Rate Limit Exceeded ({provider_display})[/bold yellow]",
463 "",
464 f"You've exceeded {provider_display}'s rate limit.",
465 "",
466 "[bold]What to do:[/bold]",
467 ]
469 if error.retry_after:
470 lines.append(f" • Wait {error.retry_after} seconds before retrying")
471 else:
472 lines.append(" • Wait a few minutes before retrying")
474 # Add provider alternatives
475 alternatives = PROVIDER_ALTERNATIVES.get(error.provider, [])
476 if alternatives:
477 lines.append(" • Switch to a different provider:")
478 lines.append(f" [cyan]agent --provider {alternatives[0]}[/cyan]")
480 # Upgrade suggestion (if applicable)
481 if error.provider in ("anthropic", "openai"):
482 lines.append(f" • Consider upgrading your {provider_display} plan")
484 lines.append("")
485 lines.append("[dim]Technical Details:[/dim]")
486 lines.append("[dim] Status: 429 Too Many Requests[/dim]")
487 if error.model:
488 lines.append(f"[dim] Model: {error.model}[/dim]")
490 return "\n".join(lines)
493def format_provider_model_not_found_error(error: ProviderModelNotFoundError) -> str:
494 """Format a provider model not found error (404) with model suggestions.
496 Args:
497 error: ProviderModelNotFoundError instance
499 Returns:
500 Formatted error message with Rich markup
501 """
502 provider_display = _get_provider_display_name(error.provider)
504 lines = [
505 f"[bold red]Model Not Found ({provider_display})[/bold red]",
506 "",
507 f"The model '{error.model or 'unknown'}' is not available on {provider_display}.",
508 "",
509 "[bold]Fix:[/bold]",
510 " • Check your model name for typos",
511 " • Use the correct model for your provider:",
512 f" [cyan]agent config provider {error.provider}[/cyan]",
513 " • See documentation for valid model names",
514 ]
516 lines.append("")
517 lines.append("[dim]Technical Details:[/dim]")
518 lines.append("[dim] Status: 404 Not Found[/dim]")
519 if error.model:
520 lines.append(f"[dim] Invalid Model: {error.model}[/dim]")
522 return "\n".join(lines)
525def format_provider_timeout_error(error: ProviderTimeoutError) -> str:
526 """Format a provider timeout/network error with troubleshooting steps.
528 Args:
529 error: ProviderTimeoutError instance
531 Returns:
532 Formatted error message with Rich markup
533 """
534 provider_display = _get_provider_display_name(error.provider)
536 lines = [
537 f"[bold red]Network Error ({provider_display})[/bold red]",
538 "",
539 f"Failed to connect to {provider_display} API.",
540 "",
541 "[bold]Troubleshooting:[/bold]",
542 " • Check your internet connection",
543 " • Try again in a moment",
544 " • Verify your network allows API access",
545 ]
547 # Add provider alternatives
548 alternatives = PROVIDER_ALTERNATIVES.get(error.provider, [])
549 if alternatives:
550 lines.append(" • Try a different provider:")
551 lines.append(f" [cyan]agent --provider {alternatives[0]}[/cyan]")
553 lines.append("")
554 lines.append("[dim]Technical Details:[/dim]")
555 lines.append("[dim] Error: Connection timeout[/dim]")
556 if error.model:
557 lines.append(f"[dim] Model: {error.model}[/dim]")
559 return "\n".join(lines)
562def format_error(error: AgentError) -> str:
563 """Format any AgentError into a user-friendly message.
565 This is the main dispatch function for error formatting.
567 Args:
568 error: AgentError instance
570 Returns:
571 Formatted error message with Rich markup
572 """
573 if isinstance(error, ProviderAPIError):
574 return format_provider_api_error(error)
575 elif isinstance(error, ProviderAuthError):
576 return format_provider_auth_error(error)
577 elif isinstance(error, ProviderRateLimitError):
578 return format_provider_rate_limit_error(error)
579 elif isinstance(error, ProviderModelNotFoundError):
580 return format_provider_model_not_found_error(error)
581 elif isinstance(error, ProviderTimeoutError):
582 return format_provider_timeout_error(error)
583 else:
584 # Fallback for unknown AgentError types
585 return f"[bold red]Error:[/bold red] {str(error)}"