From be0e0f39d7332ce747e322cf90f4649ae88b62c3 Mon Sep 17 00:00:00 2001 From: jottakka Date: Wed, 24 Sep 2025 09:04:42 -0300 Subject: [PATCH] [READY][PROD-215][TDK] Adding MS error adapter (#575) --- .../arcade_tdk/error_adapters/__init__.py | 3 +- .../arcade_tdk/error_adapters/utils.py | 6 +- .../providers/http/error_adapter.py | 23 + .../providers/microsoft/__init__.py | 5 + .../providers/microsoft/error_adapter.py | 211 ++++++ libs/arcade-tdk/pyproject.toml | 2 +- libs/tests/sdk/test_httpx_adapter.py | 127 ++++ libs/tests/sdk/test_microsoft_adapter.py | 708 ++++++++++++++++++ 8 files changed, 1081 insertions(+), 4 deletions(-) create mode 100644 libs/arcade-tdk/arcade_tdk/providers/microsoft/__init__.py create mode 100644 libs/arcade-tdk/arcade_tdk/providers/microsoft/error_adapter.py create mode 100644 libs/tests/sdk/test_microsoft_adapter.py diff --git a/libs/arcade-tdk/arcade_tdk/error_adapters/__init__.py b/libs/arcade-tdk/arcade_tdk/error_adapters/__init__.py index 471dc0b9..709430ac 100644 --- a/libs/arcade-tdk/arcade_tdk/error_adapters/__init__.py +++ b/libs/arcade-tdk/arcade_tdk/error_adapters/__init__.py @@ -1,5 +1,6 @@ from arcade_tdk.error_adapters.base import ErrorAdapter from arcade_tdk.providers.google import GoogleErrorAdapter from arcade_tdk.providers.http import HTTPErrorAdapter +from arcade_tdk.providers.microsoft import MicrosoftGraphErrorAdapter -__all__ = ["ErrorAdapter", "HTTPErrorAdapter", "GoogleErrorAdapter"] +__all__ = ["ErrorAdapter", "HTTPErrorAdapter", "GoogleErrorAdapter", "MicrosoftGraphErrorAdapter"] diff --git a/libs/arcade-tdk/arcade_tdk/error_adapters/utils.py b/libs/arcade-tdk/arcade_tdk/error_adapters/utils.py index 4a7de8fe..a271b726 100644 --- a/libs/arcade-tdk/arcade_tdk/error_adapters/utils.py +++ b/libs/arcade-tdk/arcade_tdk/error_adapters/utils.py @@ -1,5 +1,5 @@ -from arcade_tdk.auth import Google, ToolAuthorization -from arcade_tdk.error_adapters import ErrorAdapter, GoogleErrorAdapter +from arcade_tdk.auth import Google, Microsoft, ToolAuthorization +from arcade_tdk.error_adapters import ErrorAdapter, GoogleErrorAdapter, MicrosoftGraphErrorAdapter def get_adapter_for_auth_provider(auth_provider: ToolAuthorization | None) -> ErrorAdapter | None: @@ -11,5 +11,7 @@ def get_adapter_for_auth_provider(auth_provider: ToolAuthorization | None) -> Er if isinstance(auth_provider, Google): return GoogleErrorAdapter() + if isinstance(auth_provider, Microsoft): + return MicrosoftGraphErrorAdapter() return None diff --git a/libs/arcade-tdk/arcade_tdk/providers/http/error_adapter.py b/libs/arcade-tdk/arcade_tdk/providers/http/error_adapter.py index 1fa56beb..3e84fc13 100644 --- a/libs/arcade-tdk/arcade_tdk/providers/http/error_adapter.py +++ b/libs/arcade-tdk/arcade_tdk/providers/http/error_adapter.py @@ -87,8 +87,31 @@ class BaseHTTPErrorMapper: extra=extra, ) + if status == 403 and self._is_rate_limit_403(headers, msg): + return UpstreamRateLimitError( + retry_after_ms=self._parse_retry_ms(headers), + message=msg, + extra=extra, + ) + return UpstreamError(message=msg, status_code=status, extra=extra) + def _is_rate_limit_403(self, headers: dict[str, str], msg: str) -> bool: + """ + Determine if a 403 error is actually a rate limiting error. + + Simply checks if any rate limiting headers are present. + + Args: + headers: HTTP response headers + msg: Error message (unused, kept for compatibility) + + Returns: + True if this 403 should be treated as rate limiting + """ + # Check if any rate limiting headers are present + return any(header.lower() in [h.lower() for h in headers] for header in RATE_HEADERS) + class _HTTPXExceptionHandler: """Handler for httpx-specific exceptions.""" diff --git a/libs/arcade-tdk/arcade_tdk/providers/microsoft/__init__.py b/libs/arcade-tdk/arcade_tdk/providers/microsoft/__init__.py new file mode 100644 index 00000000..9eaf56e2 --- /dev/null +++ b/libs/arcade-tdk/arcade_tdk/providers/microsoft/__init__.py @@ -0,0 +1,5 @@ +"""Microsoft Graph SDK error adapter for Arcade TDK.""" + +from arcade_tdk.providers.microsoft.error_adapter import MicrosoftGraphErrorAdapter + +__all__ = ["MicrosoftGraphErrorAdapter"] diff --git a/libs/arcade-tdk/arcade_tdk/providers/microsoft/error_adapter.py b/libs/arcade-tdk/arcade_tdk/providers/microsoft/error_adapter.py new file mode 100644 index 00000000..d19aabf7 --- /dev/null +++ b/libs/arcade-tdk/arcade_tdk/providers/microsoft/error_adapter.py @@ -0,0 +1,211 @@ +import logging +from datetime import datetime, timezone +from typing import Any +from urllib.parse import urlparse + +from arcade_core.errors import ( + ToolRuntimeError, + UpstreamError, + UpstreamRateLimitError, +) + +logger = logging.getLogger(__name__) + + +class MicrosoftGraphErrorAdapter: + """Error adapter for Microsoft Graph SDK (msgraph-sdk).""" + + slug = "_microsoft_graph" + + def from_exception(self, exc: Exception) -> ToolRuntimeError | None: + """ + Translate a Microsoft Graph SDK exception into a ToolRuntimeError. + """ + # Lazy import kiota abstractions to avoid import errors for toolkits that don't use msgraph-sdk + try: + from kiota_abstractions import api_error + except ImportError: + logger.info( + f"'kiota-abstractions' is not installed in the toolkit's environment, " + f"so the '{self.slug}' adapter was not used to handle the upstream error" + ) + return None + + # Try API errors first + result = self._handle_api_errors(exc, api_error) + if result: + return result + + # Failsafe for any unhandled Microsoft Graph SDK errors that are not mapped above + if ( + hasattr(exc, "__module__") + and exc.__module__ + and ("msgraph" in exc.__module__ or "kiota" in exc.__module__) + ): + return UpstreamError( + message=f"Upstream Microsoft Graph error: {exc}", + status_code=500, + extra={ + "service": self.slug, + "error_type": exc.__class__.__name__, + }, + ) + + # Not a Microsoft Graph SDK error + return None + + def _sanitize_uri(self, uri: str) -> str: + """Strip query params and fragments from URI for privacy.""" + parsed = urlparse(uri) + return f"{parsed.scheme}://{parsed.netloc.strip('/')}/{parsed.path.strip('/')}" + + def _get_retry_after_milliseconds(self, error: Any) -> int: + """ + Extract retry-after from Microsoft Graph API errors. + Returns milliseconds to wait before retry. + Defaults to 1000ms if not found. + + Args: + error: The APIError to parse + + Returns: + The number of milliseconds to wait before retry + """ + if hasattr(error, "response") and hasattr(error.response, "headers"): + headers = error.response.headers + + retry_after = headers.get("Retry-After", headers.get("retry-after")) + if retry_after: + try: + # If it's a number, it's seconds + if retry_after.isdigit(): + return int(retry_after) * 1000 + # Otherwise try to parse as date + dt = datetime.strptime(retry_after, "%a, %d %b %Y %H:%M:%S %Z") + return int((dt - datetime.now(timezone.utc)).total_seconds() * 1000) + except Exception: + logger.warning( + f"Failed to parse retry-after header: {retry_after}. Defaulting to 1000ms." + ) + return 1000 + + return 1000 + + def _extract_error_details(self, error: Any) -> tuple[str, str | None]: + """ + Extract error message and developer details from Microsoft Graph APIError. + + Microsoft Graph errors always have this structure: + { + "error": { + "code": "string", + "message": "string", + "innerError": { + "code": "string", + "request-id": "string", + "date": "string" + } + } + } + + Args: + error: The APIError to extract details from + + Returns: + Tuple of (user_message, developer_message) + """ + message = "Unknown Microsoft Graph error" + code = "UnknownError" + inner_error = None + + # Extract error details + if hasattr(error, "error") and error.error: + if hasattr(error.error, "message"): + message = error.error.message or message + if hasattr(error.error, "code"): + code = error.error.code or code + if hasattr(error.error, "inner_error"): + inner_error = error.error.inner_error + + user_message = f"Upstream Microsoft Graph API error: {message}" + developer_message = f"Microsoft Graph error code: {code}" + + # Add inner error details if present + if inner_error: + inner_error_details = self._format_inner_error_details(inner_error) + if inner_error_details: + developer_message += f" - Inner error: {inner_error_details}" + + return user_message, developer_message + + def _format_inner_error_details(self, inner_error: Any) -> str: + """Format inner error details into a readable string.""" + inner_details = [] + + if hasattr(inner_error, "code") and inner_error.code: + inner_details.append(f"code: {inner_error.code}") + if getattr(inner_error, "request-id", None): + inner_details.append(f"request-id: {getattr(inner_error, 'request-id')}") + elif hasattr(inner_error, "request_id") and inner_error.request_id: + inner_details.append(f"request-id: {inner_error.request_id}") + if hasattr(inner_error, "client_request_id") and inner_error.client_request_id: + inner_details.append(f"client-request-id: {inner_error.client_request_id}") + if hasattr(inner_error, "date") and inner_error.date: + inner_details.append(f"date: {inner_error.date}") + + return ", ".join(inner_details) + + def _map_api_error(self, error: Any) -> ToolRuntimeError | None: + """Map Microsoft Graph APIError to appropriate ToolRuntimeError.""" + + status_code = 500 # Default to server error + if hasattr(error, "response") and error.response and hasattr(error.response, "status_code"): + status_code = error.response.status_code + elif hasattr(error, "response_status_code") and isinstance( + getattr(error, "response_status_code", None), int + ): + status_code = error.response_status_code + + message, developer_message = self._extract_error_details(error) + + extra = { + "service": self.slug, + } + + # Try to extract request details if available + if ( + hasattr(error, "response") + and error.response + and hasattr(error.response, "url") + and error.response.url + ): + extra["endpoint"] = self._sanitize_uri(str(error.response.url)) + + error_code = "UnknownError" + if hasattr(error, "error") and error.error and hasattr(error.error, "code"): + error_code = error.error.code + extra["error_code"] = error_code + + # Special case for rate limiting (429) and quota exceeded (503 with specific error codes) + if status_code == 429 or ( + status_code == 503 and error_code in ["TooManyRequests", "ServiceUnavailable"] + ): + return UpstreamRateLimitError( + retry_after_ms=self._get_retry_after_milliseconds(error), + message=message, + developer_message=developer_message, + extra=extra, + ) + + return UpstreamError( + message=message, + status_code=status_code, + developer_message=developer_message, + extra=extra, + ) + + def _handle_api_errors(self, exc: Exception, api_error_module: Any) -> ToolRuntimeError | None: + """Handle APIError and its subclasses.""" + if isinstance(exc, api_error_module.APIError): + return self._map_api_error(exc) + return None diff --git a/libs/arcade-tdk/pyproject.toml b/libs/arcade-tdk/pyproject.toml index 340db79b..dcf4fba1 100644 --- a/libs/arcade-tdk/pyproject.toml +++ b/libs/arcade-tdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-tdk" -version = "2.3.1" +version = "2.4.0" description = "Arcade TDK - Toolkit Development Kit for building Arcade tools" readme = "README.md" license = {text = "MIT"} diff --git a/libs/tests/sdk/test_httpx_adapter.py b/libs/tests/sdk/test_httpx_adapter.py index 940ae5bc..c83c59a9 100644 --- a/libs/tests/sdk/test_httpx_adapter.py +++ b/libs/tests/sdk/test_httpx_adapter.py @@ -331,3 +331,130 @@ class TestHTTPErrorAdapter: def test_adapter_slug(self): """Test that the adapter has the correct slug.""" assert HTTPErrorAdapter.slug == "_http" + + def test_map_status_to_error_403_with_rate_limit_headers(self): + """Test mapping 403 with rate limit headers to rate limit error.""" + headers = { + "retry-after": "30", + } + result = self.adapter._map_status_to_error( + status=403, + headers=headers, + msg="Forbidden", + request_url="https://api.github.com/user/repos", + request_method="GET", + ) + + assert isinstance(result, UpstreamRateLimitError) + assert result.retry_after_ms == 30_000 + assert result.message == "Forbidden" + assert result.extra["service"] == "_http" + assert result.extra["endpoint"] == "https://api.github.com/user/repos" + assert result.extra["http_method"] == "GET" + + def test_map_status_to_error_403_without_rate_limit_headers(self): + """Test mapping 403 without rate limiting headers to regular upstream error.""" + headers = {} + result = self.adapter._map_status_to_error( + status=403, + headers=headers, + msg="Access denied due to insufficient permissions", + request_url="https://api.github.com/user/repos", + request_method="GET", + ) + + assert isinstance(result, UpstreamError) + assert not isinstance(result, UpstreamRateLimitError) + assert result.status_code == 403 + assert result.message == "Access denied due to insufficient permissions" + + def test_is_rate_limit_403_with_retry_after_header(self): + """Test detecting rate limit 403 based on retry-after header.""" + headers = {"Retry-After": "60"} + msg = "Forbidden" + + result = self.adapter._is_rate_limit_403(headers, msg) + assert result is True + + def test_is_rate_limit_403_with_x_ratelimit_reset_header(self): + """Test detecting rate limit 403 based on x-ratelimit-reset header.""" + headers = {"x-ratelimit-reset": "1640995200"} + msg = "Forbidden" + + result = self.adapter._is_rate_limit_403(headers, msg) + assert result is True + + def test_is_rate_limit_403_with_x_ratelimit_reset_ms_header(self): + """Test detecting rate limit 403 based on x-ratelimit-reset-ms header.""" + headers = {"X-RateLimit-Reset-Ms": "5000"} + msg = "Forbidden" + + result = self.adapter._is_rate_limit_403(headers, msg) + assert result is True + + def test_is_rate_limit_403_without_headers(self): + """Test that 403 without rate limiting headers is not detected as rate limiting.""" + headers = {} + msg = "Access denied due to insufficient permissions" + + result = self.adapter._is_rate_limit_403(headers, msg) + assert result is False + + def test_httpx_403_rate_limit_handling(self): + """Test handling httpx 403 rate limit with rate limiting headers.""" + + # Create a mock HTTPStatusError class + class MockHTTPStatusError(Exception): + pass + + mock_response = Mock() + mock_response.status_code = 403 + mock_response.headers = {"x-ratelimit-reset": "1640995200", "retry-after": "120"} + + mock_request = Mock() + mock_request.url = "https://api.github.com/search/repositories" + mock_request.method = "GET" + + mock_exc = MockHTTPStatusError("403 Forbidden") + mock_exc.response = mock_response + mock_exc.request = mock_request + + with patch("httpx.HTTPStatusError", MockHTTPStatusError): + result = self.adapter.from_exception(mock_exc) + + assert isinstance(result, UpstreamRateLimitError) + assert result.retry_after_ms == 120_000 + assert result.message == "403 Forbidden" + assert result.extra["service"] == "_http" + assert result.extra["endpoint"] == "https://api.github.com/search/repositories" + assert result.extra["http_method"] == "GET" + + def test_requests_403_rate_limit_handling(self): + """Test handling requests 403 rate limit with rate limiting headers.""" + + # Create a mock HTTPError class + class MockHTTPError(Exception): + pass + + mock_response = Mock() + mock_response.status_code = 403 + mock_response.headers = {"x-ratelimit-reset-ms": "30000"} + + mock_request = Mock() + mock_request.url = "https://api.github.com/user/repos" + mock_request.method = "POST" + + mock_response.request = mock_request + + mock_exc = MockHTTPError("403 Forbidden") + mock_exc.response = mock_response + + with patch("requests.exceptions.HTTPError", MockHTTPError): + result = self.adapter.from_exception(mock_exc) + + assert isinstance(result, UpstreamRateLimitError) + assert result.retry_after_ms == 30_000 + assert result.message == "403 Forbidden" + assert result.extra["service"] == "_http" + assert result.extra["endpoint"] == "https://api.github.com/user/repos" + assert result.extra["http_method"] == "POST" diff --git a/libs/tests/sdk/test_microsoft_adapter.py b/libs/tests/sdk/test_microsoft_adapter.py new file mode 100644 index 00000000..7222193a --- /dev/null +++ b/libs/tests/sdk/test_microsoft_adapter.py @@ -0,0 +1,708 @@ +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +from arcade_core.errors import UpstreamError, UpstreamRateLimitError +from arcade_tdk.providers.microsoft.error_adapter import MicrosoftGraphErrorAdapter + + +class TestMicrosoftGraphErrorAdapter: + """Test the Microsoft Graph error adapter functionality.""" + + def setup_method(self): + self.adapter = MicrosoftGraphErrorAdapter() + + def _create_mock_api_error( + self, status_code=500, message=None, code=None, inner_error=None, url=None, headers=None + ): + """ + Create a mock APIError following Microsoft Graph error structure: + { + "error": { + "code": "string", + "message": "string", + "innerError": { + "code": "string", + "request-id": "string", + "date": "string" + } + } + } + """ + mock_error = Mock() + mock_error.__class__.__name__ = "APIError" + + # Mock response + mock_response = Mock() + mock_response.status_code = status_code + if url: + mock_response.url = url + if headers: + mock_response.headers = headers + else: + mock_response.headers = {} + mock_error.response = mock_response + + # Mock error details (always present in Microsoft Graph errors) + mock_error_details = Mock() + mock_error_details.message = message or "Unknown error" + mock_error_details.code = code or "UnknownError" + mock_error_details.inner_error = inner_error # Can be None + mock_error.error = mock_error_details + + return mock_error + + def test_adapter_slug(self): + """Test that the adapter has the correct slug.""" + assert MicrosoftGraphErrorAdapter.slug == "_microsoft_graph" + + def test_sanitize_uri_removes_query_params(self): + """Test URI sanitization removes query parameters.""" + uri = "https://graph.microsoft.com/v1.0/me/messages?$select=id,subject&$top=10" + result = self.adapter._sanitize_uri(uri) + assert result == "https://graph.microsoft.com/v1.0/me/messages" + + def test_sanitize_uri_removes_fragments(self): + """Test URI sanitization removes fragments.""" + uri = "https://graph.microsoft.com/v1.0/users/me#profile" + result = self.adapter._sanitize_uri(uri) + assert result == "https://graph.microsoft.com/v1.0/users/me" + + def test_sanitize_uri_handles_trailing_slashes(self): + """Test URI sanitization handles trailing slashes.""" + uri = "https://graph.microsoft.com///v1.0/me/calendars///" + result = self.adapter._sanitize_uri(uri) + assert result == "https://graph.microsoft.com/v1.0/me/calendars" + + def test_parse_retry_after_with_seconds(self): + """Test parsing retry-after header with seconds value.""" + mock_error = self._create_mock_api_error(headers={"Retry-After": "120"}) + + result = self.adapter._get_retry_after_milliseconds(mock_error) + assert result == 120_000 + + def test_parse_retry_after_with_lowercase_header(self): + """Test parsing retry-after header with lowercase key.""" + mock_error = self._create_mock_api_error(headers={"retry-after": "60"}) + + result = self.adapter._get_retry_after_milliseconds(mock_error) + assert result == 60_000 + + def test_parse_retry_after_with_date_format(self): + """Test parsing retry-after header with absolute date format.""" + future_date = "Mon, 01 Jan 2029 12:00:00 GMT" + mock_error = self._create_mock_api_error(headers={"Retry-After": future_date}) + + with patch("arcade_tdk.providers.microsoft.error_adapter.datetime") as mock_datetime: + parsed_date = datetime(2029, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_datetime.strptime.return_value = parsed_date + + # Mock datetime.now() to return a time before the parsed date + current_time = datetime(2029, 1, 1, 11, 58, 0, tzinfo=timezone.utc) + mock_datetime.now.return_value = current_time + mock_datetime.timezone = timezone + + result = self.adapter._get_retry_after_milliseconds(mock_error) + assert result == 120_000 # 2 minute diff + + def test_parse_retry_after_no_headers(self): + """Test parsing retry-after when no headers are present.""" + mock_error = self._create_mock_api_error(headers={}) + + result = self.adapter._get_retry_after_milliseconds(mock_error) + assert result == 1_000 + + def test_parse_retry_after_no_response_attribute(self): + """Test parsing retry-after when error has no response attribute.""" + mock_error = Mock() + mock_error.__class__.__name__ = "APIError" + # No response attribute + + result = self.adapter._get_retry_after_milliseconds(mock_error) + assert result == 1_000 # defaults to 1 second + + def test_parse_retry_after_invalid_date(self): + """Test parsing retry-after with invalid date format falls back to default.""" + mock_error = self._create_mock_api_error(headers={"Retry-After": "invalid-date"}) + + result = self.adapter._get_retry_after_milliseconds(mock_error) + assert result == 1_000 + + def test_extract_error_details_standard_structure(self): + """Test extracting error details from standard Microsoft Graph error structure.""" + mock_error = self._create_mock_api_error( + message="The request is invalid", code="invalidRequest" + ) + + user_message, developer_message = self.adapter._extract_error_details(mock_error) + + assert user_message == "Upstream Microsoft Graph API error: The request is invalid" + assert developer_message == "Microsoft Graph error code: invalidRequest" + + def test_extract_error_details_with_complete_inner_error(self): + """Test extracting error details with complete inner error structure.""" + # Create mock inner error with all Microsoft Graph fields + mock_inner_error = Mock() + mock_inner_error.code = "invalidSyntax" + setattr(mock_inner_error, "request-id", "12345-67890") + mock_inner_error.date = "2025-09-22T16:08:56Z" + # Explicitly set client_request_id to None to avoid Mock object + mock_inner_error.client_request_id = None + + mock_error = self._create_mock_api_error( + message="The request is invalid", + code="invalidRequest", + inner_error=mock_inner_error, + ) + + user_message, developer_message = self.adapter._extract_error_details(mock_error) + + assert user_message == "Upstream Microsoft Graph API error: The request is invalid" + assert "Microsoft Graph error code: invalidRequest" in developer_message + assert ( + "Inner error: code: invalidSyntax, request-id: 12345-67890, date: 2025-09-22T16:08:56Z" + in developer_message + ) + + def test_extract_error_details_with_partial_inner_error(self): + """Test extracting error details with partial inner error (only some fields).""" + # Create mock inner error with only code and request-id + mock_inner_error = Mock() + mock_inner_error.code = "tokenExpired" + setattr(mock_inner_error, "request-id", "67890-12345") + mock_inner_error.date = None + + mock_error = self._create_mock_api_error( + message="Access is denied", + code="unauthorized", + inner_error=mock_inner_error, + ) + + user_message, developer_message = self.adapter._extract_error_details(mock_error) + + assert user_message == "Upstream Microsoft Graph API error: Access is denied" + assert "Microsoft Graph error code: unauthorized" in developer_message + assert "Inner error: code: tokenExpired, request-id: 67890-12345" in developer_message + assert "date:" not in developer_message + + def test_extract_error_details_without_inner_error(self): + """Test extracting error details when innerError field is missing.""" + mock_error = self._create_mock_api_error( + message="The resource could not be found", + code="notFound", + inner_error=None, + ) + + user_message, developer_message = self.adapter._extract_error_details(mock_error) + + assert user_message == "Upstream Microsoft Graph API error: The resource could not be found" + assert developer_message == "Microsoft Graph error code: notFound" + assert "Inner error:" not in developer_message + + def test_extract_error_details_with_empty_inner_error_fields(self): + """Test extracting error details when inner error has empty/None fields.""" + # Create mock inner error with empty fields + mock_inner_error = Mock() + mock_inner_error.code = None + setattr(mock_inner_error, "request-id", None) + mock_inner_error.date = None + # Explicitly set all other fields to None + mock_inner_error.request_id = None + mock_inner_error.client_request_id = None + + mock_error = self._create_mock_api_error( + message="Service temporarily unavailable", + code="serviceUnavailable", + inner_error=mock_inner_error, + ) + + user_message, developer_message = self.adapter._extract_error_details(mock_error) + + assert user_message == "Upstream Microsoft Graph API error: Service temporarily unavailable" + assert developer_message == "Microsoft Graph error code: serviceUnavailable" + assert "Inner error:" not in developer_message + + def test_extract_error_details_exact_microsoft_graph_structure(self): + """Test with exact Microsoft Graph API error structure from documentation.""" + # Create mock inner error matching exact structure from docs + mock_inner_error = Mock() + mock_inner_error.code = "invalidRange" + setattr(mock_inner_error, "request-id", "request-id") + mock_inner_error.date = "date-time" + # Explicitly set client_request_id to None to avoid Mock object + mock_inner_error.client_request_id = None + + mock_error = self._create_mock_api_error( + message="Uploaded fragment overlaps with existing data.", + code="badRequest", + inner_error=mock_inner_error, + ) + + user_message, developer_message = self.adapter._extract_error_details(mock_error) + + assert ( + user_message + == "Upstream Microsoft Graph API error: Uploaded fragment overlaps with existing data." + ) + assert "Microsoft Graph error code: badRequest" in developer_message + assert ( + "Inner error: code: invalidRange, request-id: request-id, date: date-time" + in developer_message + ) + + def test_map_api_error_basic(self): + """Test mapping basic API error with standard Microsoft Graph structure.""" + mock_error = self._create_mock_api_error( + status_code=404, + message="The resource could not be found", + code="itemNotFound", + url="https://graph.microsoft.com/v1.0/me/messages/missing", + ) + + result = self.adapter._map_api_error(mock_error) + + assert isinstance(result, UpstreamError) + assert not isinstance(result, UpstreamRateLimitError) + assert result.status_code == 404 + assert ( + result.message == "Upstream Microsoft Graph API error: The resource could not be found" + ) + assert result.developer_message == "Microsoft Graph error code: itemNotFound" + assert result.extra["service"] == "_microsoft_graph" + assert result.extra["endpoint"] == "https://graph.microsoft.com/v1.0/me/messages/missing" + assert result.extra["error_code"] == "itemNotFound" + + def test_map_api_error_rate_limit_429(self): + """Test mapping 429 rate limit error with Microsoft Graph structure.""" + mock_error = self._create_mock_api_error( + status_code=429, + message="Rate limit has been exceeded.", + code="tooManyRequests", + url="https://graph.microsoft.com/v1.0/me/messages", + headers={"Retry-After": "30"}, + ) + + result = self.adapter._map_api_error(mock_error) + + assert isinstance(result, UpstreamRateLimitError) + assert result.retry_after_ms == 30_000 + assert result.message == "Upstream Microsoft Graph API error: Rate limit has been exceeded." + assert result.developer_message == "Microsoft Graph error code: tooManyRequests" + assert result.extra["service"] == "_microsoft_graph" + assert result.extra["endpoint"] == "https://graph.microsoft.com/v1.0/me/messages" + assert result.extra["error_code"] == "tooManyRequests" + + def test_map_api_error_rate_limit_503_with_too_many_requests(self): + """Test mapping 503 error with TooManyRequests code.""" + mock_error = self._create_mock_api_error( + status_code=503, + message="Service temporarily unavailable due to high load.", + code="TooManyRequests", + headers={"Retry-After": "60"}, + ) + + result = self.adapter._map_api_error(mock_error) + + assert isinstance(result, UpstreamRateLimitError) + assert result.retry_after_ms == 60_000 + assert ( + result.message + == "Upstream Microsoft Graph API error: Service temporarily unavailable due to high load." + ) + + def test_map_api_error_rate_limit_503_with_service_unavailable(self): + """Test mapping 503 error with ServiceUnavailable code.""" + mock_error = self._create_mock_api_error( + status_code=503, + message="Service unavailable", + code="ServiceUnavailable", + headers={"Retry-After": "120"}, + ) + + result = self.adapter._map_api_error(mock_error) + + assert isinstance(result, UpstreamRateLimitError) + assert result.retry_after_ms == 120_000 + + def test_map_api_error_503_without_rate_limit_code(self): + """Test mapping 503 error without rate limit specific code.""" + mock_error = self._create_mock_api_error( + status_code=503, message="Service unavailable", code="InternalServerError" + ) + + result = self.adapter._map_api_error(mock_error) + + assert isinstance(result, UpstreamError) + assert not isinstance(result, UpstreamRateLimitError) + assert result.status_code == 503 + + def test_map_api_error_default_status_code(self): + """Test mapping API error with default status code.""" + mock_error = self._create_mock_api_error(message="Unknown error", code="UnknownError") + # Set response to None to test default status code + mock_error.response = None + + result = self.adapter._map_api_error(mock_error) + + assert isinstance(result, UpstreamError) + assert result.status_code == 500 # Default + + def test_map_api_error_missing_attributes(self): + """Test mapping API error without url and error code attributes.""" + mock_error = self._create_mock_api_error( + status_code=400, + message="Bad request", + code="BadRequest", # Code is always present in Microsoft Graph errors + # No url + ) + # Remove URL to test missing endpoint + mock_error.response.url = None + + result = self.adapter._map_api_error(mock_error) + + assert isinstance(result, UpstreamError) + assert result.status_code == 400 + assert result.extra["service"] == "_microsoft_graph" + assert "endpoint" not in result.extra + assert result.extra["error_code"] == "BadRequest" + + def test_handle_api_errors_with_api_error(self): + """Test handling APIError exceptions.""" + + # Create mock APIError class + class MockAPIError: + pass + + mock_msgraph = Mock() + mock_msgraph.APIError = MockAPIError + + mock_error = MockAPIError() + # Add the expected attributes + mock_error.response = Mock() + mock_error.response.status_code = 401 + mock_error.response.url = "https://graph.microsoft.com/v1.0/me" + mock_error.response.headers = {} + mock_error.error = Mock() + mock_error.error.message = "Unauthorized" + mock_error.error.code = "Unauthorized" + mock_error.error.inner_error = None + + result = self.adapter._handle_api_errors(mock_error, mock_msgraph) + + assert isinstance(result, UpstreamError) + assert result.status_code == 401 + assert result.message == "Upstream Microsoft Graph API error: Unauthorized" + + def test_handle_api_errors_non_api_error(self): + """Test handling non-APIError exceptions returns None.""" + + # Create mock APIError class + class MockAPIError: + pass + + mock_msgraph = Mock() + mock_msgraph.APIError = MockAPIError + + mock_error = ValueError("Not an API error") + + result = self.adapter._handle_api_errors(mock_error, mock_msgraph) + assert result is None + + def test_handle_api_errors_wrong_class_name(self): + """Test handling exception with wrong class name returns None.""" + + # Create mock APIError class + class MockAPIError: + pass + + mock_msgraph = Mock() + mock_msgraph.APIError = MockAPIError + + mock_error = Mock() + mock_error.__class__.__name__ = "SomeOtherError" + + result = self.adapter._handle_api_errors(mock_error, mock_msgraph) + assert result is None + + def test_from_exception_kiota_not_installed(self, caplog): + """Test handling when kiota-abstractions is not installed.""" + with ( + patch("arcade_tdk.providers.microsoft.error_adapter.logger") as mock_logger, + patch.dict("sys.modules", {"kiota_abstractions.api_error": None}), + patch( + "builtins.__import__", + side_effect=ImportError("No module named 'kiota_abstractions'"), + ), + ): + mock_exc = Exception("test exception") + result = self.adapter.from_exception(mock_exc) + + assert result is None + mock_logger.info.assert_called_once() + warning_message = mock_logger.info.call_args[0][0] + assert "'kiota-abstractions' is not installed" in warning_message + assert "_microsoft_graph" in warning_message + + def test_from_exception_api_error_handling(self): + """Test full from_exception flow with API error.""" + + # Create mock api_error module with APIError class + class MockAPIError: + def __init__(self): + # Initialize with the same structure as _create_mock_api_error + self.response = Mock() + self.response.status_code = 403 + self.response.url = "https://graph.microsoft.com/v1.0/me/messages" + self.response.headers = {} + + self.error = Mock() + self.error.message = "Forbidden" + self.error.code = "Forbidden" + self.error.inner_error = None + + mock_api_error_module = Mock() + mock_api_error_module.APIError = MockAPIError + + # Create parent module mock + mock_kiota_module = Mock() + mock_kiota_module.api_error = mock_api_error_module + + # Create the mock error as an actual instance of MockAPIError + mock_error = MockAPIError() + + with patch.dict( + "sys.modules", + { + "kiota_abstractions": mock_kiota_module, + "kiota_abstractions.api_error": mock_api_error_module, + }, + ): + result = self.adapter.from_exception(mock_error) + + assert isinstance(result, UpstreamError) + assert result.status_code == 403 + assert result.message == "Upstream Microsoft Graph API error: Forbidden" + + def test_from_exception_fallback_for_unhandled_msgraph_error(self): + """Test fallback handling for unhandled Microsoft Graph errors.""" + + # Create an unhandled Microsoft Graph error + class MockUnhandledError(Exception): + pass + + mock_error = MockUnhandledError("Some unhandled Microsoft Graph error") + mock_error.__module__ = "msgraph.generated.models" + + # Create mock APIError class + class MockAPIError: + pass + + # Create mock msgraph module + mock_msgraph = Mock() + mock_msgraph.APIError = MockAPIError + + # Create parent module mock + mock_kiota_module = Mock() + mock_kiota_module.api_error = mock_msgraph + + with patch.dict( + "sys.modules", + { + "msgraph": Mock(), + "kiota_abstractions": mock_kiota_module, + "kiota_abstractions.api_error": mock_msgraph, + }, + ): + result = self.adapter.from_exception(mock_error) + + assert isinstance(result, UpstreamError) + assert result.status_code == 500 + assert ( + result.message == "Upstream Microsoft Graph error: Some unhandled Microsoft Graph error" + ) + assert result.extra["service"] == "_microsoft_graph" + assert result.extra["error_type"] == "MockUnhandledError" + + def test_from_exception_fallback_with_msgraph_core_module(self): + """Test fallback handling for errors from msgraph_core module.""" + + class MockCoreError(Exception): + pass + + mock_error = MockCoreError("Core error") + mock_error.__module__ = "msgraph_core.requests" + + # Create mock APIError class + class MockAPIError: + pass + + # Create mock msgraph module + mock_msgraph = Mock() + mock_msgraph.APIError = MockAPIError + + # Create parent module mock + mock_kiota_module = Mock() + mock_kiota_module.api_error = mock_msgraph + + with patch.dict( + "sys.modules", + { + "msgraph": Mock(), + "kiota_abstractions": mock_kiota_module, + "kiota_abstractions.api_error": mock_msgraph, + }, + ): + result = self.adapter.from_exception(mock_error) + + assert isinstance(result, UpstreamError) + assert result.status_code == 500 + assert result.message == "Upstream Microsoft Graph error: Core error" + + def test_from_exception_non_msgraph_error(self): + """Test handling non-Microsoft Graph errors returns None.""" + # Create a non-Microsoft Graph error + mock_error = ValueError("Not a Microsoft Graph error") + mock_error.__module__ = "builtins" + + # Create mock APIError class + class MockAPIError: + pass + + # Create mock msgraph module + mock_msgraph = Mock() + mock_msgraph.APIError = MockAPIError + + # Create parent module mock + mock_kiota_module = Mock() + mock_kiota_module.api_error = mock_msgraph + + with patch.dict( + "sys.modules", + { + "msgraph": Mock(), + "kiota_abstractions": mock_kiota_module, + "kiota_abstractions.api_error": mock_msgraph, + }, + ): + result = self.adapter.from_exception(mock_error) + + assert result is None + + def test_from_exception_error_without_module(self): + """Test handling error without __module__ attribute.""" + mock_error = Exception("Error without module") + if hasattr(mock_error, "__module__"): + del mock_error.__module__ + + # Create mock APIError class + class MockAPIError: + pass + + # Create mock msgraph module + mock_msgraph = Mock() + mock_msgraph.APIError = MockAPIError + + # Create parent module mock + mock_kiota_module = Mock() + mock_kiota_module.api_error = mock_msgraph + + with patch.dict( + "sys.modules", + { + "msgraph": Mock(), + "kiota_abstractions": mock_kiota_module, + "kiota_abstractions.api_error": mock_msgraph, + }, + ): + result = self.adapter.from_exception(mock_error) + + assert result is None + + def test_from_exception_rate_limit_integration(self): + """Test full integration with rate limit error.""" + + # Create mock api_error module with APIError class + class MockAPIError: + def __init__(self): + # Initialize with the same structure as _create_mock_api_error + self.response = Mock() + self.response.status_code = 429 + self.response.url = "https://graph.microsoft.com/v1.0/me/messages" + self.response.headers = {"Retry-After": "300"} + + self.error = Mock() + self.error.message = "Rate limit exceeded" + self.error.code = "TooManyRequests" + self.error.inner_error = None + + mock_api_error_module = Mock() + mock_api_error_module.APIError = MockAPIError + + # Create parent module mock + mock_kiota_module = Mock() + mock_kiota_module.api_error = mock_api_error_module + + # Create the mock error as an actual instance of MockAPIError + mock_error = MockAPIError() + + with patch.dict( + "sys.modules", + { + "kiota_abstractions": mock_kiota_module, + "kiota_abstractions.api_error": mock_api_error_module, + }, + ): + result = self.adapter.from_exception(mock_error) + + assert isinstance(result, UpstreamRateLimitError) + assert result.retry_after_ms == 300_000 + assert result.message == "Upstream Microsoft Graph API error: Rate limit exceeded" + assert result.extra["service"] == "_microsoft_graph" + assert result.extra["error_code"] == "TooManyRequests" + + def test_from_exception_complex_error_details(self): + """Test handling error with complex nested error details.""" + + # Create mock api_error module with APIError class + class MockAPIError: + def __init__(self): + # Create mock inner error with proper Mock structure + mock_inner_error = Mock() + mock_inner_error.code = "InvalidSyntax" + setattr(mock_inner_error, "request-id", "12345-67890") + mock_inner_error.date = "2025-09-22T16:08:56Z" + + # Initialize with the same structure as _create_mock_api_error + self.response = Mock() + self.response.status_code = 400 + self.response.url = "https://graph.microsoft.com/v1.0/me/messages" + self.response.headers = {} + + self.error = Mock() + self.error.message = "Invalid request syntax" + self.error.code = "BadRequest" + self.error.inner_error = mock_inner_error + + mock_api_error_module = Mock() + mock_api_error_module.APIError = MockAPIError + + # Create parent module mock + mock_kiota_module = Mock() + mock_kiota_module.api_error = mock_api_error_module + + # Create the mock error as an actual instance of MockAPIError + mock_error = MockAPIError() + + with patch.dict( + "sys.modules", + { + "kiota_abstractions": mock_kiota_module, + "kiota_abstractions.api_error": mock_api_error_module, + }, + ): + result = self.adapter.from_exception(mock_error) + + assert isinstance(result, UpstreamError) + assert result.status_code == 400 + assert result.message == "Upstream Microsoft Graph API error: Invalid request syntax" + assert "Microsoft Graph error code: BadRequest" in result.developer_message + assert "Inner error:" in result.developer_message