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

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. 

14 

15"""Error handler for provider API errors. 

16 

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""" 

21 

22import logging 

23 

24from agent.config.schema import AgentSettings 

25from agent.exceptions import ( 

26 AgentError, 

27 ProviderAPIError, 

28 ProviderAuthError, 

29 ProviderModelNotFoundError, 

30 ProviderRateLimitError, 

31 ProviderTimeoutError, 

32) 

33 

34logger = logging.getLogger(__name__) 

35 

36 

37def classify_anthropic_error( 

38 error: Exception, config: AgentSettings | None = None 

39) -> AgentError | None: 

40 """Classify Anthropic SDK exceptions into our exception types. 

41 

42 Args: 

43 error: Exception from Anthropic SDK 

44 config: Agent configuration (for model name) 

45 

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 

54 

55 model = config.anthropic_model if config else None 

56 

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) 

62 

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) 

69 

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 ) 

107 

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 ) 

116 

117 return None 

118 

119 

120def classify_openai_error( 

121 error: Exception, config: AgentSettings | None = None 

122) -> AgentError | None: 

123 """Classify OpenAI SDK exceptions into our exception types. 

124 

125 Args: 

126 error: Exception from OpenAI SDK 

127 config: Agent configuration (for model name) 

128 

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 

137 

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 

147 

148 provider = config.llm_provider if config else "openai" 

149 

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) 

177 

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 ) 

194 

195 return None 

196 

197 

198def classify_gemini_error( 

199 error: Exception, config: AgentSettings | None = None 

200) -> AgentError | None: 

201 """Classify Google Gemini exceptions into our exception types. 

202 

203 Args: 

204 error: Exception from Google SDK 

205 config: Agent configuration (for model name) 

206 

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 

215 

216 model = config.gemini_model if config else None 

217 

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 ) 

261 

262 return None 

263 

264 

265def classify_provider_error( 

266 error: Exception, config: AgentSettings | None = None 

267) -> AgentError | None: 

268 """Classify any provider exception into our exception types. 

269 

270 This is the main dispatch function that tries all provider-specific classifiers. 

271 

272 Args: 

273 error: Exception from any provider SDK 

274 config: Agent configuration (for provider and model info) 

275 

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 

283 

284 # Try OpenAI classifier (handles openai, azure, github) 

285 classified = classify_openai_error(error, config) 

286 if classified: 

287 return classified 

288 

289 # Try Gemini classifier 

290 classified = classify_gemini_error(error, config) 

291 if classified: 

292 return classified 

293 

294 # Unknown error - can't classify 

295 return None 

296 

297 

298# ============================================================================ 

299# Error Formatting 

300# ============================================================================ 

301 

302 

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} 

313 

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} 

324 

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} 

333 

334 

335def _get_provider_display_name(provider: str) -> str: 

336 """Get user-friendly provider display name. 

337 

338 Args: 

339 provider: Provider name (anthropic, openai, etc.) 

340 

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()) 

354 

355 

356def format_provider_api_error(error: ProviderAPIError) -> str: 

357 """Format a provider API error (500, 503, 529) with troubleshooting steps. 

358 

359 Args: 

360 error: ProviderAPIError instance 

361 

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" 

367 

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 ] 

377 

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]") 

383 

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]") 

388 

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]") 

396 

397 return "\n".join(lines) 

398 

399 

400def format_provider_auth_error(error: ProviderAuthError) -> str: 

401 """Format a provider authentication error (401, 403) with fix instructions. 

402 

403 Args: 

404 error: ProviderAuthError instance 

405 

406 Returns: 

407 Formatted error message with Rich markup 

408 """ 

409 provider_display = _get_provider_display_name(error.provider) 

410 

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 ] 

418 

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]") 

424 

425 lines.append(" 2. Configure it:") 

426 lines.append(f" [cyan]agent config provider {error.provider}[/cyan]") 

427 

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]") 

440 

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]") 

446 

447 return "\n".join(lines) 

448 

449 

450def format_provider_rate_limit_error(error: ProviderRateLimitError) -> str: 

451 """Format a provider rate limit error (429) with retry guidance. 

452 

453 Args: 

454 error: ProviderRateLimitError instance 

455 

456 Returns: 

457 Formatted error message with Rich markup 

458 """ 

459 provider_display = _get_provider_display_name(error.provider) 

460 

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 ] 

468 

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") 

473 

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]") 

479 

480 # Upgrade suggestion (if applicable) 

481 if error.provider in ("anthropic", "openai"): 

482 lines.append(f" • Consider upgrading your {provider_display} plan") 

483 

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]") 

489 

490 return "\n".join(lines) 

491 

492 

493def format_provider_model_not_found_error(error: ProviderModelNotFoundError) -> str: 

494 """Format a provider model not found error (404) with model suggestions. 

495 

496 Args: 

497 error: ProviderModelNotFoundError instance 

498 

499 Returns: 

500 Formatted error message with Rich markup 

501 """ 

502 provider_display = _get_provider_display_name(error.provider) 

503 

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 ] 

515 

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]") 

521 

522 return "\n".join(lines) 

523 

524 

525def format_provider_timeout_error(error: ProviderTimeoutError) -> str: 

526 """Format a provider timeout/network error with troubleshooting steps. 

527 

528 Args: 

529 error: ProviderTimeoutError instance 

530 

531 Returns: 

532 Formatted error message with Rich markup 

533 """ 

534 provider_display = _get_provider_display_name(error.provider) 

535 

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 ] 

546 

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]") 

552 

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]") 

558 

559 return "\n".join(lines) 

560 

561 

562def format_error(error: AgentError) -> str: 

563 """Format any AgentError into a user-friendly message. 

564 

565 This is the main dispatch function for error formatting. 

566 

567 Args: 

568 error: AgentError instance 

569 

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)}"