[READY][PROD-215][toolkits/ZENDESK]updating error handling (#578)
# [PROD-215](https://app.clickup.com/t/9014390315/PROD-215) 🎫 ## Changes - Removed some try catch blocks from Zendesk tools so the default Httpx error adaptor can deal with them. Co-authored-by: Francisco Liberal <francisco@arcade.dev>
This commit is contained in:
parent
be0e0f39d7
commit
b446390acf
4 changed files with 115 additions and 307 deletions
|
|
@ -4,7 +4,7 @@ from typing import Annotated, Any
|
|||
import httpx
|
||||
from arcade_tdk import ToolContext, tool
|
||||
from arcade_tdk.auth import OAuth2
|
||||
from arcade_tdk.errors import RetryableToolError, ToolExecutionError
|
||||
from arcade_tdk.errors import RetryableToolError
|
||||
|
||||
from arcade_zendesk.enums import ArticleSortBy, SortOrder
|
||||
from arcade_zendesk.utils import (
|
||||
|
|
@ -175,53 +175,26 @@ async def search_articles(
|
|||
base_params["sort_order"] = sort_order.value
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {auth_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {auth_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
data = await fetch_paginated_results(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=headers,
|
||||
params=base_params,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
data = await fetch_paginated_results(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=headers,
|
||||
params=base_params,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
if "results" in data:
|
||||
data["results"] = process_search_results(
|
||||
data["results"], include_body=include_body, max_body_length=max_article_length
|
||||
)
|
||||
|
||||
if "results" in data:
|
||||
data["results"] = process_search_results(
|
||||
data["results"], include_body=include_body, max_body_length=max_article_length
|
||||
)
|
||||
logger.info(f"Article search returned {data.get('count', 0)} results")
|
||||
|
||||
logger.info(f"Article search returned {data.get('count', 0)} results")
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.exception(f"HTTP error during article search: {e.response.status_code}")
|
||||
raise ToolExecutionError(
|
||||
message=f"Failed to search articles: HTTP {e.response.status_code}",
|
||||
developer_message=(
|
||||
f"HTTP {e.response.status_code} error: {e.response.text}. "
|
||||
f"URL: {url}, base_params: {base_params}"
|
||||
),
|
||||
) from e
|
||||
except httpx.TimeoutException as e:
|
||||
logger.exception("Timeout during article search")
|
||||
raise RetryableToolError(
|
||||
message="Request timed out while searching articles.",
|
||||
developer_message=f"Timeout occurred. URL: {url}, base_params: {base_params}",
|
||||
retry_after_ms=5000,
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.exception("Unexpected error during article search")
|
||||
raise ToolExecutionError(
|
||||
message=f"Failed to search articles: {e!s}",
|
||||
developer_message=(
|
||||
f"Unexpected error: {type(e).__name__}: {e!s}. "
|
||||
f"URL: {url}, base_params: {base_params}"
|
||||
),
|
||||
) from e
|
||||
else:
|
||||
return data
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from typing import Annotated, Any
|
|||
import httpx
|
||||
from arcade_tdk import ToolContext, tool
|
||||
from arcade_tdk.auth import OAuth2
|
||||
from arcade_tdk.errors import RetryableToolError, ToolExecutionError
|
||||
from arcade_tdk.errors import RetryableToolError
|
||||
|
||||
from arcade_zendesk.enums import SortOrder, TicketStatus
|
||||
from arcade_zendesk.utils import fetch_paginated_results, get_zendesk_subdomain
|
||||
|
|
@ -94,68 +94,40 @@ async def list_tickets(
|
|||
|
||||
# Make the API request
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Use the fetch_paginated_results utility
|
||||
data = await fetch_paginated_results(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=headers,
|
||||
params=base_params,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
# Use the fetch_paginated_results utility
|
||||
data = await fetch_paginated_results(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=headers,
|
||||
params=base_params,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Process tickets to add html_url and remove api url
|
||||
tickets = data.get("results", [])
|
||||
for ticket in tickets:
|
||||
if "id" in ticket:
|
||||
ticket["html_url"] = (
|
||||
f"https://{subdomain}.zendesk.com/agent/tickets/{ticket['id']}"
|
||||
)
|
||||
# Remove API url to avoid confusion
|
||||
if "url" in ticket:
|
||||
del ticket["url"]
|
||||
# Process tickets to add html_url and remove api url
|
||||
tickets = data.get("results", [])
|
||||
for ticket in tickets:
|
||||
if "id" in ticket:
|
||||
ticket["html_url"] = f"https://{subdomain}.zendesk.com/agent/tickets/{ticket['id']}"
|
||||
# Remove API url to avoid confusion
|
||||
if "url" in ticket:
|
||||
del ticket["url"]
|
||||
|
||||
# Build the result with consistent structure
|
||||
result = {
|
||||
"tickets": tickets,
|
||||
"count": data.get("count", len(tickets)),
|
||||
}
|
||||
# Build the result with consistent structure
|
||||
result = {
|
||||
"tickets": tickets,
|
||||
"count": data.get("count", len(tickets)),
|
||||
}
|
||||
|
||||
# Add next_offset if present
|
||||
if "next_offset" in data:
|
||||
result["next_offset"] = data["next_offset"]
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise ToolExecutionError(
|
||||
message=f"Failed to list tickets: HTTP {e.response.status_code}",
|
||||
developer_message=(
|
||||
f"HTTP {e.response.status_code} error: {e.response.text}. "
|
||||
f"URL: {url}, params: {base_params}"
|
||||
),
|
||||
) from e
|
||||
except httpx.TimeoutException as e:
|
||||
raise RetryableToolError(
|
||||
message="Request timed out while listing tickets.",
|
||||
developer_message=f"Timeout occurred. URL: {url}, params: {base_params}",
|
||||
retry_after_ms=5000,
|
||||
additional_prompt_content="Try reducing limit or using more specific filters.",
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise ToolExecutionError(
|
||||
message=f"Failed to list tickets: {e!s}",
|
||||
developer_message=(
|
||||
f"Unexpected error: {type(e).__name__}: {e!s}. "
|
||||
f"URL: {url}, params: {base_params}"
|
||||
),
|
||||
) from e
|
||||
else:
|
||||
return result
|
||||
# Add next_offset if present
|
||||
if "next_offset" in data:
|
||||
result["next_offset"] = data["next_offset"]
|
||||
return result
|
||||
|
||||
|
||||
@tool(
|
||||
|
|
@ -190,47 +162,23 @@ async def get_ticket_comments(
|
|||
|
||||
# Make the API request
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
response = await client.get(url, headers=headers)
|
||||
_handle_ticket_not_found(response, ticket_id)
|
||||
response.raise_for_status()
|
||||
response = await client.get(url, headers=headers)
|
||||
_handle_ticket_not_found(response, ticket_id)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
comments = data.get("comments", [])
|
||||
data = response.json()
|
||||
comments = data.get("comments", [])
|
||||
|
||||
return {
|
||||
"ticket_id": ticket_id,
|
||||
"comments": comments,
|
||||
"count": len(comments),
|
||||
}
|
||||
|
||||
except RetryableToolError:
|
||||
# Re-raise our custom errors
|
||||
raise
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise ToolExecutionError(
|
||||
message=f"Failed to get ticket comments: HTTP {e.response.status_code}",
|
||||
developer_message=(
|
||||
f"HTTP {e.response.status_code} error: {e.response.text}. URL: {url}"
|
||||
),
|
||||
) from e
|
||||
except httpx.TimeoutException as e:
|
||||
raise RetryableToolError(
|
||||
message="Request timed out while getting ticket comments.",
|
||||
developer_message=f"Timeout occurred. URL: {url}",
|
||||
retry_after_ms=5000,
|
||||
additional_prompt_content="Try again in a few moments.",
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise ToolExecutionError(
|
||||
message=f"Failed to get ticket comments: {e!s}",
|
||||
developer_message=f"Unexpected error: {type(e).__name__}: {e!s}. URL: {url}",
|
||||
) from e
|
||||
return {
|
||||
"ticket_id": ticket_id,
|
||||
"comments": comments,
|
||||
"count": len(comments),
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
|
|
@ -265,56 +213,31 @@ async def add_ticket_comment(
|
|||
|
||||
# Make the API request
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
response = await client.put(url, headers=headers, json=request_body)
|
||||
_handle_ticket_not_found(response, ticket_id)
|
||||
response.raise_for_status()
|
||||
response = await client.put(url, headers=headers, json=request_body)
|
||||
_handle_ticket_not_found(response, ticket_id)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
ticket = data.get("ticket", {})
|
||||
data = response.json()
|
||||
ticket = data.get("ticket", {})
|
||||
|
||||
# Add web interface URL if not present
|
||||
if "id" in ticket and "html_url" not in ticket:
|
||||
ticket["html_url"] = f"https://{subdomain}.zendesk.com/agent/tickets/{ticket['id']}"
|
||||
# Remove API url to avoid confusion
|
||||
if "url" in ticket:
|
||||
del ticket["url"]
|
||||
# Add web interface URL if not present
|
||||
if "id" in ticket and "html_url" not in ticket:
|
||||
ticket["html_url"] = f"https://{subdomain}.zendesk.com/agent/tickets/{ticket['id']}"
|
||||
# Remove API url to avoid confusion
|
||||
if "url" in ticket:
|
||||
del ticket["url"]
|
||||
|
||||
except RetryableToolError:
|
||||
# Re-raise our custom errors
|
||||
raise
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise ToolExecutionError(
|
||||
message=f"Failed to add ticket comment: HTTP {e.response.status_code}",
|
||||
developer_message=(
|
||||
f"HTTP {e.response.status_code} error: {e.response.text}. "
|
||||
f"URL: {url}, body: {request_body}"
|
||||
),
|
||||
) from e
|
||||
except httpx.TimeoutException as e:
|
||||
raise RetryableToolError(
|
||||
message="Request timed out while adding ticket comment.",
|
||||
developer_message=f"Timeout occurred. URL: {url}",
|
||||
retry_after_ms=5000,
|
||||
additional_prompt_content="Try again in a few moments.",
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise ToolExecutionError(
|
||||
message=f"Failed to add ticket comment: {e!s}",
|
||||
developer_message=f"Unexpected error: {type(e).__name__}: {e!s}. URL: {url}",
|
||||
) from e
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"ticket_id": ticket_id,
|
||||
"comment_type": "public" if public else "internal",
|
||||
"ticket": ticket,
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"ticket_id": ticket_id,
|
||||
"comment_type": "public" if public else "internal",
|
||||
"ticket": ticket,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
|
|
@ -357,58 +280,33 @@ async def mark_ticket_solved(
|
|||
|
||||
# Make the API request
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
response = await client.put(url, headers=headers, json=request_body)
|
||||
_handle_ticket_not_found(response, ticket_id)
|
||||
response.raise_for_status()
|
||||
response = await client.put(url, headers=headers, json=request_body)
|
||||
_handle_ticket_not_found(response, ticket_id)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
ticket = data.get("ticket", {})
|
||||
data = response.json()
|
||||
ticket = data.get("ticket", {})
|
||||
|
||||
# Add web interface URL if not present
|
||||
if "id" in ticket and "html_url" not in ticket:
|
||||
ticket["html_url"] = f"https://{subdomain}.zendesk.com/agent/tickets/{ticket['id']}"
|
||||
# Remove API url to avoid confusion
|
||||
if "url" in ticket:
|
||||
del ticket["url"]
|
||||
# Add web interface URL if not present
|
||||
if "id" in ticket and "html_url" not in ticket:
|
||||
ticket["html_url"] = f"https://{subdomain}.zendesk.com/agent/tickets/{ticket['id']}"
|
||||
# Remove API url to avoid confusion
|
||||
if "url" in ticket:
|
||||
del ticket["url"]
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"ticket_id": ticket_id,
|
||||
"status": "solved",
|
||||
"ticket": ticket,
|
||||
}
|
||||
if comment_body:
|
||||
result["comment_added"] = True
|
||||
result["comment_type"] = "public" if comment_public else "internal"
|
||||
result = {
|
||||
"success": True,
|
||||
"ticket_id": ticket_id,
|
||||
"status": "solved",
|
||||
"ticket": ticket,
|
||||
}
|
||||
if comment_body:
|
||||
result["comment_added"] = True
|
||||
result["comment_type"] = "public" if comment_public else "internal"
|
||||
|
||||
except RetryableToolError:
|
||||
# Re-raise our custom errors
|
||||
raise
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise ToolExecutionError(
|
||||
message=f"Failed to mark ticket as solved: HTTP {e.response.status_code}",
|
||||
developer_message=(
|
||||
f"HTTP {e.response.status_code} error: {e.response.text}. "
|
||||
f"URL: {url}, body: {request_body}"
|
||||
),
|
||||
) from e
|
||||
except httpx.TimeoutException as e:
|
||||
raise RetryableToolError(
|
||||
message="Request timed out while marking ticket as solved.",
|
||||
developer_message=f"Timeout occurred. URL: {url}",
|
||||
retry_after_ms=5000,
|
||||
additional_prompt_content="Try again in a few moments.",
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise ToolExecutionError(
|
||||
message=f"Failed to mark ticket as solved: {e!s}",
|
||||
developer_message=f"Unexpected error: {type(e).__name__}: {e!s}. URL: {url}",
|
||||
) from e
|
||||
else:
|
||||
return result
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "arcade_zendesk"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-tdk>=2.0.0,<3.0.0",
|
||||
"arcade-tdk>=2.3.1,<3.0.0",
|
||||
"httpx>=0.25.0,<1.0.0",
|
||||
"beautifulsoup4>=4.0.0,<5"
|
||||
]
|
||||
|
|
@ -15,8 +15,8 @@ dependencies = [
|
|||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-ai[evals]>=2.0.0,<3.0.0",
|
||||
"arcade-serve>=2.0.0,<3.0.0",
|
||||
"arcade-ai[evals]>=2.2.1,<3.0.0",
|
||||
"arcade-serve>=2.1.0,<3.0.0",
|
||||
"pytest>=8.3.0,<8.4.0",
|
||||
"pytest-cov>=4.0.0,<4.1.0",
|
||||
"pytest-mock>=3.11.1,<3.12.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from arcade_tdk.errors import RetryableToolError, ToolExecutionError
|
||||
|
||||
|
|
@ -312,66 +309,6 @@ class TestSearchArticlesPagination:
|
|||
assert "next_offset" not in result # No more results
|
||||
|
||||
|
||||
class TestSearchArticlesErrors:
|
||||
"""Test error handling scenarios."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code,error_key",
|
||||
[
|
||||
(400, "HTTP 400"),
|
||||
(401, "HTTP 401"),
|
||||
(403, "HTTP 403"),
|
||||
(404, "HTTP 404"),
|
||||
(500, "HTTP 500"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_errors(self, mock_context, mock_httpx_client, status_code, error_key):
|
||||
"""Test handling of HTTP errors."""
|
||||
mock_context.get_secret.return_value = "test-subdomain"
|
||||
|
||||
# Create mock error response
|
||||
error_response = MagicMock()
|
||||
error_response.status_code = status_code
|
||||
error_response.text = f"Error message for {status_code}"
|
||||
error_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
message=f"HTTP {status_code}", request=MagicMock(), response=error_response
|
||||
)
|
||||
|
||||
mock_httpx_client.get.return_value = error_response
|
||||
|
||||
with pytest.raises(ToolExecutionError) as exc_info:
|
||||
await search_articles(context=mock_context, query="test")
|
||||
|
||||
assert "Failed to search articles" in str(exc_info.value.message)
|
||||
assert f"HTTP {status_code}" in str(exc_info.value.message)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_error(self, mock_context, mock_httpx_client):
|
||||
"""Test handling of timeout errors."""
|
||||
mock_context.get_secret.return_value = "test-subdomain"
|
||||
|
||||
mock_httpx_client.get.side_effect = httpx.TimeoutException("Request timed out")
|
||||
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await search_articles(context=mock_context, query="test")
|
||||
|
||||
assert "timed out" in str(exc_info.value.message)
|
||||
assert exc_info.value.retry_after_ms == 5000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexpected_error(self, mock_context, mock_httpx_client):
|
||||
"""Test handling of unexpected errors."""
|
||||
mock_context.get_secret.return_value = "test-subdomain"
|
||||
|
||||
mock_httpx_client.get.side_effect = Exception("Unexpected error occurred")
|
||||
|
||||
with pytest.raises(ToolExecutionError) as exc_info:
|
||||
await search_articles(context=mock_context, query="test")
|
||||
|
||||
assert "Unexpected error occurred" in str(exc_info.value.message)
|
||||
|
||||
|
||||
class TestSearchArticlesContentProcessing:
|
||||
"""Test content processing and formatting."""
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue