[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:
jottakka 2025-09-24 15:09:37 -03:00 committed by GitHub
parent be0e0f39d7
commit b446390acf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 115 additions and 307 deletions

View file

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

View file

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

View file

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

View file

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