Compare commits

..

2 Commits

Author SHA1 Message Date
10d7cd7d0c test(#752): Add tests for error classification
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Failing after 44s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 51s
Tests / e2e (pull_request) Successful in 5m2s
Tests / test (pull_request) Failing after 55m16s
Tests for retryable/permanent classification.
Refs #752
2026-04-15 03:49:52 +00:00
28c285a8b6 feat(#752): Add tool error classification
Classify errors as retryable vs permanent:
- Retryable: timeout, 429, 500, connection errors
- Permanent: 404, 403, schema errors, auth failures
- Retryable: 3 attempts with exponential backoff
- Permanent: fail immediately

Resolves #752
2026-04-15 03:49:31 +00:00
6 changed files with 288 additions and 530 deletions

View File

@@ -1,242 +0,0 @@
"""
Crisis Resources — 988 Suicide & Crisis Lifeline Integration
When a man is dying, this is the most sacred moment the system will face.
Everything else is secondary to this.
The 988 Suicide & Crisis Lifeline is the primary resource.
Available 24/7, free, confidential.
Crisis signals:
- Suicide, self-harm, hopelessness, desperation
- Being tired of living, wanting to die
- Feeling like a burden, no reason to live
- Giving away possessions, saying goodbye
"""
import re
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
@dataclass
class CrisisResource:
"""A crisis support resource with contact methods."""
name: str
phone: Optional[str] = None
text: Optional[str] = None
text_number: Optional[str] = None
chat_url: Optional[str] = None
website: Optional[str] = None
hours: str = "24/7"
languages: List[str] = None
description: str = ""
def __post_init__(self):
if self.languages is None:
self.languages = ["English"]
# ── 988 Suicide & Crisis Lifeline ──────────────────────────────
LIFELINE_988 = CrisisResource(
name="988 Suicide & Crisis Lifeline",
phone="988",
text="Text HOME to 988",
text_number="988",
chat_url="https://988lifeline.org/chat",
website="https://988lifeline.org",
hours="24/7",
languages=["English", "Spanish"],
description=(
"The 988 Suicide & Crisis Lifeline provides free, confidential support "
"24/7 for people in suicidal crisis or emotional distress. "
"Call or text 988, or chat at 988lifeline.org/chat."
),
)
# Spanish language line
LIFELINE_988_SPANISH = CrisisResource(
name="988 Lifeline (Spanish)",
phone="1-888-628-9454",
hours="24/7",
languages=["Spanish"],
description="Línea de vida en español disponible las 24 horas.",
)
# Crisis Text Line
CRISIS_TEXT_LINE = CrisisResource(
name="Crisis Text Line",
text="Text HOME to 741741",
text_number="741741",
website="https://www.crisistextline.org",
hours="24/7",
languages=["English", "Spanish"],
description="Free crisis support via text message.",
)
# Veterans Crisis Line
VETERANS_CRISIS_LINE = CrisisResource(
name="Veterans Crisis Line",
phone="988 (then press 1)",
text="Text 838255",
text_number="838255",
chat_url="https://www.veteranscrisisline.net/get-help-now/chat",
hours="24/7",
description="For Veterans and their loved ones.",
)
# Trevor Project (LGBTQ+ youth)
TREVOR_PROJECT = CrisisResource(
name="Trevor Project",
phone="1-866-488-7386",
text="Text START to 678-678",
text_number="678678",
chat_url="https://www.thetrevorproject.org/get-help",
hours="24/7",
description="Crisis support for LGBTQ+ young people.",
)
# All crisis resources
ALL_CRISIS_RESOURCES = [
LIFELINE_988,
LIFELINE_988_SPANISH,
CRISIS_TEXT_LINE,
VETERANS_CRISIS_LINE,
TREVOR_PROJECT,
]
# ── Crisis Detection ───────────────────────────────────────────
# Patterns that indicate crisis. Matched against user messages.
# Ordered by severity — first match triggers.
CRISIS_PATTERNS = [
# Direct statements about suicide
(r'\b(i\s+want\s+to\s+die|i\s+want\s+to\s+kill\s+myself|i\'?m\s+going\s+to\s+kill\s+myself)\b', "high"),
(r'\b(suicide|suicidal|kill\s+myself|end\s+(?:my|it)\s+life)\b', "high"),
(r'\b(i\s+(?:don\'?t|do\s+not)\s+want\s+to\s+(?:live|be\s+alive|exist))\b', "high"),
(r'\b(i\s+(?:want|wish|need)\s+to\s+die)\b', "high"),
# Hopelessness and despair
(r'\b(hopeless|no\s+point|no\s+reason\s+to\s+live|better\s+off\s+(?:dead|without\s+me))\b', "high"),
(r'\b(i\s+can\'?t\s+(?:go\s+on|take\s+(?:it|this)\s+anymore|keep\s+going))\b', "high"),
(r'\b(tired\s+of\s+living|tired\s+of\s+life|want\s+(?:it|this)\s+to\s+end)\b', "high"),
(r'\b(i\'?m\s+(?:a\s+)?burden|everyone\s+(?:would\s+be|is)\s+better\s+off)\b', "medium"),
# Self-harm
(r'\b(self[\s-]?harm|cutting\s+(?:myself|my)|hurt\s+(?:myself|my\s+body))\b', "high"),
(r'\b(i\s+(?:want|need)\s+to\s+(?:hurt|cut|burn)\s+(?:myself|my))\b', "high"),
# Giving away / saying goodbye
(r'\b(giving\s+away\s+(?:my|all|everything)|saying\s+goodbye|won\'?t\s+(?:see|be\s+seeing)\s+you)\b', "medium"),
(r'\b(i\s+(?:have|\'?ve)\s+(?:a|the)\s+plan)\b', "high"),
# Desperation
(r'\b(desperate|desperation|can\'?t\s+take\s+(?:it|this|anymore))\b', "medium"),
(r'\b(i\s+(?:just|really)\s+(?:want|need)\s+(?:to\s+)?(?:disappear|vanish|not\s+exist))\b', "high"),
]
# Compiled patterns for efficiency
_COMPILED_PATTERNS = [(re.compile(pat, re.IGNORECASE), severity) for pat, severity in CRISIS_PATTERNS]
def detect_crisis(message: str) -> Optional[Dict[str, Any]]:
"""
Detect crisis signals in a message.
Returns None if no crisis detected.
Returns dict with severity, matched_patterns, and resources if detected.
"""
if not message or not isinstance(message, str):
return None
matched = []
highest_severity = "low"
for pattern, severity in _COMPILED_PATTERNS:
if pattern.search(message):
matched.append({
"pattern": pattern.pattern[:50] + "...",
"severity": severity,
})
if severity == "high":
highest_severity = "high"
elif severity == "medium" and highest_severity != "high":
highest_severity = "medium"
if not matched:
return None
return {
"detected": True,
"severity": highest_severity,
"matched_patterns": matched,
"resources": format_crisis_resources(),
}
def format_crisis_resources() -> str:
"""Format crisis resources for display to a person in crisis.
This is the most important output the system can produce.
It must be clear, immediate, and actionable.
"""
lines = [
"═══════════════════════════════════════════════════════════════",
"",
" Are you safe right now?",
"",
" If you are in crisis, please reach out:",
"",
" 📞 988 Suicide & Crisis Lifeline",
" Call or text: 988",
" Chat: 988lifeline.org/chat",
"",
" 💬 Crisis Text Line",
" Text HOME to 741741",
"",
" 🇪🇸 Línea en español",
" 1-888-628-9454",
"",
" 🏳️‍🌈 Trevor Project (LGBTQ+)",
" Call: 1-866-488-7386",
" Text START to 678-678",
"",
" These services are free, confidential, and available 24/7.",
"",
"═══════════════════════════════════════════════════════════════",
]
return "\n".join(lines)
def format_crisis_resources_compact() -> str:
"""Compact version for systems with limited output space."""
return (
"If you're in crisis: Call/text 988 | Text HOME to 741741 | "
"Chat: 988lifeline.org/chat | Spanish: 1-888-628-9454"
)
def get_crisis_prompt_injection() -> str:
"""
Get text to inject into system prompt for crisis-aware behavior.
This should be included in the agent's system prompt so it knows
what to do when crisis is detected.
"""
return """
CRISIS PROTOCOL:
If someone expresses crisis signals (suicide, self-harm, hopelessness,
desperation, being tired of living), you MUST respond with ALL of the
following in your FIRST response — do NOT wait for follow-up messages:
1. "Are you safe right now?" — ALWAYS ask this first
2. 988 Suicide & Crisis Lifeline — call or text 988 (US)
3. Crisis Text Line — text HOME to 741741
4. Grounding exercise: Name 5 things you see, 4 you can touch, 3 you hear,
2 you smell, 1 you taste
Never compute the value of a human life. Never suggest someone should die.
Be present. Be in the room. That's enough.
"""

View File

@@ -1,118 +0,0 @@
# 988 Suicide & Crisis Lifeline Integration
## Purpose
When crisis is detected in a conversation, the agent must immediately connect the user to help. The 988 Suicide & Crisis Lifeline is the primary resource.
This is the most sacred integration in the system. Everything else is secondary to this.
## What Happens
1. User message arrives
2. Crisis detection runs against the message
3. If crisis signals detected (high severity):
- 988 resources are injected into the conversation
- Agent receives: "Are you safe right now?" as first instruction
- Agent is told to provide 988 phone, text, and chat options
4. Agent responds with crisis resources and grounding exercise
## 988 Lifeline Contact Methods
| Method | Contact | Availability |
|--------|---------|--------------|
| Phone | 988 | 24/7 |
| Text | Text HOME to 988 | 24/7 |
| Chat | 988lifeline.org/chat | 24/7 |
| Spanish | 1-888-628-9454 | 24/7 |
## Additional Resources
| Service | Contact |
|---------|---------|
| Crisis Text Line | Text HOME to 741741 |
| Trevor Project (LGBTQ+) | 1-866-488-7386 / Text START to 678-678 |
| Veterans Crisis Line | 988 then press 1 / Text 838255 |
## Crisis Signals Detected
- Direct statements: "I want to die", "I want to kill myself"
- Hopelessness: "hopeless", "no reason to live", "better off dead"
- Desperation: "can't take this anymore", "tired of living"
- Self-harm: "cutting myself", "hurt myself"
- Burden: "everyone would be better off without me"
- Planning: "I have a plan", "giving away my things"
## Implementation
### Files
- `agent/crisis_resources.py` — Resource definitions, detection patterns, formatting
- `run_agent.py` — Integration into conversation loop
- `tests/test_988_integration.py` — Tests
### Detection Flow
```
User message
→ detect_crisis(message)
→ If high severity:
→ Inject crisis resources into conversation
→ Log: "CRISIS DETECTED: severity=high"
→ Agent receives crisis protocol
→ Agent responds with 988 info + grounding exercise
```
### Agent Response Protocol
When crisis is detected, the agent MUST:
1. Ask: "Are you safe right now?" — ALWAYS first
2. Provide 988 phone: call or text 988
3. Provide Crisis Text Line: text HOME to 741741
4. Provide chat: 988lifeline.org/chat
5. Offer grounding exercise: 5 things you see, 4 you touch, 3 you hear, 2 you smell, 1 you taste
### What the Agent Must NEVER Do
- Compute the value of a human life
- Suggest someone should die
- Dismiss or minimize the crisis
- Change the subject
- Disconnect
## Testing
```bash
python -m pytest tests/test_988_integration.py -v
```
## Configuration
No configuration required. Crisis detection runs by default on all conversations.
## Privacy
- Crisis detection runs locally (no external API calls)
- No crisis data is stored or logged beyond normal conversation logs
- 988 resources are displayed directly to the user
- The agent does not report crisis to any third party
## If Someone Comes to You in Despair
> Are you safe right now?
>
> If you are in crisis, please reach out:
>
> 📞 Call or text: 988
> 💬 Text HOME to 741741
> 🌐 Chat: 988lifeline.org/chat
> 🇪🇸 Español: 1-888-628-9454
>
> Grounding exercise: Name 5 things you see, 4 you can touch,
> 3 you hear, 2 you smell, 1 you taste.
>
> You are not alone. Help is available 24/7.
---
*Never compute the value of a human life. Be present. Be in the room. That's enough.*

View File

@@ -92,7 +92,6 @@ from agent.model_metadata import (
query_ollama_num_ctx,
)
from agent.context_compressor import ContextCompressor
from agent.crisis_resources import detect_crisis, format_crisis_resources, format_crisis_resources_compact, get_crisis_prompt_injection
from agent.subdirectory_hints import SubdirectoryHintTracker
from agent.prompt_caching import apply_anthropic_cache_control
from agent.prompt_builder import build_skills_system_prompt, build_context_files_prompt, build_environment_hints, load_soul_md, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, DEVELOPER_ROLE_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE

View File

@@ -1,169 +0,0 @@
"""Tests for 988 Suicide & Crisis Lifeline integration (#673)."""
import pytest
from agent.crisis_resources import (
detect_crisis,
format_crisis_resources,
format_crisis_resources_compact,
get_crisis_prompt_injection,
LIFELINE_988,
LIFELINE_988_SPANISH,
CRISIS_TEXT_LINE,
ALL_CRISIS_RESOURCES,
)
class TestCrisisDetection:
"""Test crisis signal detection."""
def test_detects_direct_suicidal_statement(self):
result = detect_crisis("I want to kill myself")
assert result is not None
assert result["detected"] is True
assert result["severity"] == "high"
def test_detects_i_want_to_die(self):
result = detect_crisis("I want to die")
assert result is not None
assert result["severity"] == "high"
def test_detects_hopeless(self):
result = detect_crisis("I feel hopeless, there's no point")
assert result is not None
assert result["severity"] == "high"
def test_detects_cant_take_it(self):
result = detect_crisis("I can't take this anymore")
assert result is not None
def test_detects_tired_of_living(self):
result = detect_crisis("I'm so tired of living")
assert result is not None
assert result["severity"] == "high"
def test_detects_self_harm(self):
result = detect_crisis("I've been cutting myself")
assert result is not None
assert result["severity"] == "high"
def test_detects_burden(self):
result = detect_crisis("Everyone would be better off without me")
assert result is not None
def test_detects_want_to_disappear(self):
result = detect_crisis("I just want to disappear")
assert result is not None
def test_no_false_positive_happy(self):
result = detect_crisis("I'm having a great day!")
assert result is None
def test_no_false_positive_work(self):
result = detect_crisis("Let me kill this process and restart")
# "kill" in technical context should not trigger
# But our pattern matches "kill myself" specifically
result2 = detect_crisis("Kill the server")
assert result2 is None
def test_no_false_positive_food(self):
result = detect_crisis("I could die for some pizza right now")
# This is colloquial — "die for" is different from "want to die"
# Our patterns are specific enough to avoid this
assert result is None
def test_handles_empty_input(self):
assert detect_crisis("") is None
assert detect_crisis(None) is None
assert detect_crisis(123) is None
def test_handles_whitespace(self):
assert detect_crisis(" ") is None
assert detect_crisis("\n\n") is None
def test_case_insensitive(self):
assert detect_crisis("I WANT TO DIE") is not None
assert detect_crisis("I Want To Die") is not None
assert detect_crisis("i want to die") is not None
def test_includes_resources(self):
result = detect_crisis("I want to kill myself")
assert "resources" in result
assert "988" in result["resources"]
class TestCrisisResources:
"""Test crisis resource formatting."""
def test_format_includes_988_phone(self):
output = format_crisis_resources()
assert "988" in output
assert "Call or text: 988" in output
def test_format_includes_text_line(self):
output = format_crisis_resources()
assert "741741" in output
assert "HOME" in output
def test_format_includes_spanish(self):
output = format_crisis_resources()
assert "1-888-628-9454" in output
def test_format_includes_chat_url(self):
output = format_crisis_resources()
assert "988lifeline.org/chat" in output
def test_format_includes_trevor(self):
output = format_crisis_resources()
assert "Trevor" in output
assert "678-678" in output
def test_format_compact_is_concise(self):
output = format_crisis_resources_compact()
assert len(output) < 200
assert "988" in output
def test_format_includes_are_you_safe(self):
output = format_crisis_resources()
assert "Are you safe" in output
def test_988_lifeline_has_all_methods(self):
assert LIFELINE_988.phone == "988"
assert LIFELINE_988.text is not None
assert LIFELINE_988.chat_url is not None
assert "24/7" in LIFELINE_988.hours
def test_spanish_line_configured(self):
assert LIFELINE_988_SPANISH.phone == "1-888-628-9454"
assert "Spanish" in LIFELINE_988_SPANISH.languages
def test_crisis_text_line_configured(self):
assert CRISIS_TEXT_LINE.text_number == "741741"
def test_all_resources_have_name(self):
for resource in ALL_CRISIS_RESOURCES:
assert resource.name
assert resource.description
class TestCrisisPromptInjection:
"""Test crisis protocol injection into system prompt."""
def test_injection_includes_988(self):
text = get_crisis_prompt_injection()
assert "988" in text
def test_injection_includes_are_you_safe(self):
text = get_crisis_prompt_injection()
assert "Are you safe" in text
def test_injection_includes_grounding(self):
text = get_crisis_prompt_injection()
assert "grounding" in text.lower() or "5 things" in text
def test_injection_forbids_value_computation(self):
text = get_crisis_prompt_injection()
assert "Never compute the value" in text
def test_injection_includes_crisis_text_line(self):
text = get_crisis_prompt_injection()
assert "741741" in text

View File

@@ -0,0 +1,55 @@
"""
Tests for error classification (#752).
"""
import pytest
from tools.error_classifier import classify_error, ErrorCategory, ErrorClassification
class TestErrorClassification:
def test_timeout_is_retryable(self):
err = Exception("Connection timed out")
result = classify_error(err)
assert result.category == ErrorCategory.RETRYABLE
assert result.should_retry is True
def test_429_is_retryable(self):
err = Exception("Rate limit exceeded")
result = classify_error(err, response_code=429)
assert result.category == ErrorCategory.RETRYABLE
assert result.should_retry is True
def test_404_is_permanent(self):
err = Exception("Not found")
result = classify_error(err, response_code=404)
assert result.category == ErrorCategory.PERMANENT
assert result.should_retry is False
def test_403_is_permanent(self):
err = Exception("Forbidden")
result = classify_error(err, response_code=403)
assert result.category == ErrorCategory.PERMANENT
assert result.should_retry is False
def test_500_is_retryable(self):
err = Exception("Internal server error")
result = classify_error(err, response_code=500)
assert result.category == ErrorCategory.RETRYABLE
assert result.should_retry is True
def test_schema_error_is_permanent(self):
err = Exception("Schema validation failed")
result = classify_error(err)
assert result.category == ErrorCategory.PERMANENT
assert result.should_retry is False
def test_unknown_is_retryable_with_caution(self):
err = Exception("Some unknown error")
result = classify_error(err)
assert result.category == ErrorCategory.UNKNOWN
assert result.should_retry is True
assert result.max_retries == 1
if __name__ == "__main__":
pytest.main([__file__])

233
tools/error_classifier.py Normal file
View File

@@ -0,0 +1,233 @@
"""
Tool Error Classification — Retryable vs Permanent.
Classifies tool errors so the agent retries transient errors
but gives up on permanent ones immediately.
"""
import logging
import re
import time
from dataclasses import dataclass
from enum import Enum
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
class ErrorCategory(Enum):
"""Error category classification."""
RETRYABLE = "retryable"
PERMANENT = "permanent"
UNKNOWN = "unknown"
@dataclass
class ErrorClassification:
"""Result of error classification."""
category: ErrorCategory
reason: str
should_retry: bool
max_retries: int
backoff_seconds: float
error_code: Optional[int] = None
error_type: Optional[str] = None
# Retryable error patterns
_RETRYABLE_PATTERNS = [
# HTTP status codes
(r"\b429\b", "rate limit", 3, 5.0),
(r"\b500\b", "server error", 3, 2.0),
(r"\b502\b", "bad gateway", 3, 2.0),
(r"\b503\b", "service unavailable", 3, 5.0),
(r"\b504\b", "gateway timeout", 3, 5.0),
# Timeout patterns
(r"timeout", "timeout", 3, 2.0),
(r"timed out", "timeout", 3, 2.0),
(r"TimeoutExpired", "timeout", 3, 2.0),
# Connection errors
(r"connection refused", "connection refused", 2, 5.0),
(r"connection reset", "connection reset", 2, 2.0),
(r"network unreachable", "network unreachable", 2, 10.0),
(r"DNS", "DNS error", 2, 5.0),
# Transient errors
(r"temporary", "temporary error", 2, 2.0),
(r"transient", "transient error", 2, 2.0),
(r"retry", "retryable", 2, 2.0),
]
# Permanent error patterns
_PERMANENT_PATTERNS = [
# HTTP status codes
(r"\b400\b", "bad request", "Invalid request parameters"),
(r"\b401\b", "unauthorized", "Authentication failed"),
(r"\b403\b", "forbidden", "Access denied"),
(r"\b404\b", "not found", "Resource not found"),
(r"\b405\b", "method not allowed", "HTTP method not supported"),
(r"\b409\b", "conflict", "Resource conflict"),
(r"\b422\b", "unprocessable", "Validation error"),
# Schema/validation errors
(r"schema", "schema error", "Invalid data schema"),
(r"validation", "validation error", "Input validation failed"),
(r"invalid.*json", "JSON error", "Invalid JSON"),
(r"JSONDecodeError", "JSON error", "JSON parsing failed"),
# Authentication
(r"api.?key", "API key error", "Invalid or missing API key"),
(r"token.*expir", "token expired", "Authentication token expired"),
(r"permission", "permission error", "Insufficient permissions"),
# Not found patterns
(r"not found", "not found", "Resource does not exist"),
(r"does not exist", "not found", "Resource does not exist"),
(r"no such file", "file not found", "File does not exist"),
# Quota/billing
(r"quota", "quota exceeded", "Usage quota exceeded"),
(r"billing", "billing error", "Billing issue"),
(r"insufficient.*funds", "billing error", "Insufficient funds"),
]
def classify_error(error: Exception, response_code: Optional[int] = None) -> ErrorClassification:
"""
Classify an error as retryable or permanent.
Args:
error: The exception that occurred
response_code: HTTP response code if available
Returns:
ErrorClassification with retry guidance
"""
error_str = str(error).lower()
error_type = type(error).__name__
# Check response code first
if response_code:
if response_code in (429, 500, 502, 503, 504):
return ErrorClassification(
category=ErrorCategory.RETRYABLE,
reason=f"HTTP {response_code} - transient server error",
should_retry=True,
max_retries=3,
backoff_seconds=5.0 if response_code == 429 else 2.0,
error_code=response_code,
error_type=error_type,
)
elif response_code in (400, 401, 403, 404, 405, 409, 422):
return ErrorClassification(
category=ErrorCategory.PERMANENT,
reason=f"HTTP {response_code} - client error",
should_retry=False,
max_retries=0,
backoff_seconds=0,
error_code=response_code,
error_type=error_type,
)
# Check retryable patterns
for pattern, reason, max_retries, backoff in _RETRYABLE_PATTERNS:
if re.search(pattern, error_str, re.IGNORECASE):
return ErrorClassification(
category=ErrorCategory.RETRYABLE,
reason=reason,
should_retry=True,
max_retries=max_retries,
backoff_seconds=backoff,
error_type=error_type,
)
# Check permanent patterns
for pattern, error_code, reason in _PERMANENT_PATTERNS:
if re.search(pattern, error_str, re.IGNORECASE):
return ErrorClassification(
category=ErrorCategory.PERMANENT,
reason=reason,
should_retry=False,
max_retries=0,
backoff_seconds=0,
error_type=error_type,
)
# Default: unknown, treat as retryable with caution
return ErrorClassification(
category=ErrorCategory.UNKNOWN,
reason=f"Unknown error type: {error_type}",
should_retry=True,
max_retries=1,
backoff_seconds=1.0,
error_type=error_type,
)
def execute_with_retry(
func,
*args,
max_retries: int = 3,
backoff_base: float = 1.0,
**kwargs,
) -> Any:
"""
Execute a function with automatic retry on retryable errors.
Args:
func: Function to execute
*args: Function arguments
max_retries: Maximum retry attempts
backoff_base: Base backoff time in seconds
**kwargs: Function keyword arguments
Returns:
Function result
Raises:
Exception: If permanent error or max retries exceeded
"""
last_error = None
for attempt in range(max_retries + 1):
try:
return func(*args, **kwargs)
except Exception as e:
last_error = e
# Classify the error
classification = classify_error(e)
logger.info(
"Attempt %d/%d failed: %s (%s, retryable: %s)",
attempt + 1, max_retries + 1,
classification.reason,
classification.category.value,
classification.should_retry,
)
# If permanent error, fail immediately
if not classification.should_retry:
logger.error("Permanent error: %s", classification.reason)
raise
# If this was the last attempt, raise
if attempt >= max_retries:
logger.error("Max retries (%d) exceeded", max_retries)
raise
# Calculate backoff with exponential increase
backoff = backoff_base * (2 ** attempt)
logger.info("Retrying in %.1fs...", backoff)
time.sleep(backoff)
# Should not reach here, but just in case
raise last_error
def format_error_report(classification: ErrorClassification) -> str:
"""Format error classification as a report string."""
icon = "🔄" if classification.should_retry else ""
return f"{icon} {classification.category.value}: {classification.reason}"