[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
|
import httpx
|
||||||
from arcade_tdk import ToolContext, tool
|
from arcade_tdk import ToolContext, tool
|
||||||
from arcade_tdk.auth import OAuth2
|
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.enums import ArticleSortBy, SortOrder
|
||||||
from arcade_zendesk.utils import (
|
from arcade_zendesk.utils import (
|
||||||
|
|
@ -175,53 +175,26 @@ async def search_articles(
|
||||||
base_params["sort_order"] = sort_order.value
|
base_params["sort_order"] = sort_order.value
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
headers = {
|
||||||
headers = {
|
"Authorization": f"Bearer {auth_token}",
|
||||||
"Authorization": f"Bearer {auth_token}",
|
"Content-Type": "application/json",
|
||||||
"Content-Type": "application/json",
|
"Accept": "application/json",
|
||||||
"Accept": "application/json",
|
}
|
||||||
}
|
|
||||||
|
|
||||||
data = await fetch_paginated_results(
|
data = await fetch_paginated_results(
|
||||||
client=client,
|
client=client,
|
||||||
url=url,
|
url=url,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
params=base_params,
|
params=base_params,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
limit=limit,
|
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:
|
logger.info(f"Article search returned {data.get('count', 0)} results")
|
||||||
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")
|
return data
|
||||||
|
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from typing import Annotated, Any
|
||||||
import httpx
|
import httpx
|
||||||
from arcade_tdk import ToolContext, tool
|
from arcade_tdk import ToolContext, tool
|
||||||
from arcade_tdk.auth import OAuth2
|
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.enums import SortOrder, TicketStatus
|
||||||
from arcade_zendesk.utils import fetch_paginated_results, get_zendesk_subdomain
|
from arcade_zendesk.utils import fetch_paginated_results, get_zendesk_subdomain
|
||||||
|
|
@ -94,68 +94,40 @@ async def list_tickets(
|
||||||
|
|
||||||
# Make the API request
|
# Make the API request
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
headers = {
|
||||||
headers = {
|
"Authorization": f"Bearer {token}",
|
||||||
"Authorization": f"Bearer {token}",
|
"Content-Type": "application/json",
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
}
|
|
||||||
|
|
||||||
# Use the fetch_paginated_results utility
|
# Use the fetch_paginated_results utility
|
||||||
data = await fetch_paginated_results(
|
data = await fetch_paginated_results(
|
||||||
client=client,
|
client=client,
|
||||||
url=url,
|
url=url,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
params=base_params,
|
params=base_params,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process tickets to add html_url and remove api url
|
# Process tickets to add html_url and remove api url
|
||||||
tickets = data.get("results", [])
|
tickets = data.get("results", [])
|
||||||
for ticket in tickets:
|
for ticket in tickets:
|
||||||
if "id" in ticket:
|
if "id" in ticket:
|
||||||
ticket["html_url"] = (
|
ticket["html_url"] = f"https://{subdomain}.zendesk.com/agent/tickets/{ticket['id']}"
|
||||||
f"https://{subdomain}.zendesk.com/agent/tickets/{ticket['id']}"
|
# Remove API url to avoid confusion
|
||||||
)
|
if "url" in ticket:
|
||||||
# Remove API url to avoid confusion
|
del ticket["url"]
|
||||||
if "url" in ticket:
|
|
||||||
del ticket["url"]
|
|
||||||
|
|
||||||
# Build the result with consistent structure
|
# Build the result with consistent structure
|
||||||
result = {
|
result = {
|
||||||
"tickets": tickets,
|
"tickets": tickets,
|
||||||
"count": data.get("count", len(tickets)),
|
"count": data.get("count", len(tickets)),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add next_offset if present
|
# Add next_offset if present
|
||||||
if "next_offset" in data:
|
if "next_offset" in data:
|
||||||
result["next_offset"] = data["next_offset"]
|
result["next_offset"] = data["next_offset"]
|
||||||
|
return result
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
|
|
@ -190,47 +162,23 @@ async def get_ticket_comments(
|
||||||
|
|
||||||
# Make the API request
|
# Make the API request
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
headers = {
|
||||||
headers = {
|
"Authorization": f"Bearer {token}",
|
||||||
"Authorization": f"Bearer {token}",
|
"Content-Type": "application/json",
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
}
|
|
||||||
|
|
||||||
response = await client.get(url, headers=headers)
|
response = await client.get(url, headers=headers)
|
||||||
_handle_ticket_not_found(response, ticket_id)
|
_handle_ticket_not_found(response, ticket_id)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
comments = data.get("comments", [])
|
comments = data.get("comments", [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ticket_id": ticket_id,
|
"ticket_id": ticket_id,
|
||||||
"comments": comments,
|
"comments": comments,
|
||||||
"count": len(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
|
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
|
|
@ -265,56 +213,31 @@ async def add_ticket_comment(
|
||||||
|
|
||||||
# Make the API request
|
# Make the API request
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
headers = {
|
||||||
headers = {
|
"Authorization": f"Bearer {token}",
|
||||||
"Authorization": f"Bearer {token}",
|
"Content-Type": "application/json",
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
}
|
|
||||||
|
|
||||||
response = await client.put(url, headers=headers, json=request_body)
|
response = await client.put(url, headers=headers, json=request_body)
|
||||||
_handle_ticket_not_found(response, ticket_id)
|
_handle_ticket_not_found(response, ticket_id)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
ticket = data.get("ticket", {})
|
ticket = data.get("ticket", {})
|
||||||
|
|
||||||
# Add web interface URL if not present
|
# Add web interface URL if not present
|
||||||
if "id" in ticket and "html_url" not in ticket:
|
if "id" in ticket and "html_url" not in ticket:
|
||||||
ticket["html_url"] = f"https://{subdomain}.zendesk.com/agent/tickets/{ticket['id']}"
|
ticket["html_url"] = f"https://{subdomain}.zendesk.com/agent/tickets/{ticket['id']}"
|
||||||
# Remove API url to avoid confusion
|
# Remove API url to avoid confusion
|
||||||
if "url" in ticket:
|
if "url" in ticket:
|
||||||
del ticket["url"]
|
del ticket["url"]
|
||||||
|
|
||||||
except RetryableToolError:
|
return {
|
||||||
# Re-raise our custom errors
|
"success": True,
|
||||||
raise
|
"ticket_id": ticket_id,
|
||||||
except httpx.HTTPStatusError as e:
|
"comment_type": "public" if public else "internal",
|
||||||
raise ToolExecutionError(
|
"ticket": ticket,
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
|
|
@ -357,58 +280,33 @@ async def mark_ticket_solved(
|
||||||
|
|
||||||
# Make the API request
|
# Make the API request
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
headers = {
|
||||||
headers = {
|
"Authorization": f"Bearer {token}",
|
||||||
"Authorization": f"Bearer {token}",
|
"Content-Type": "application/json",
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
}
|
|
||||||
|
|
||||||
response = await client.put(url, headers=headers, json=request_body)
|
response = await client.put(url, headers=headers, json=request_body)
|
||||||
_handle_ticket_not_found(response, ticket_id)
|
_handle_ticket_not_found(response, ticket_id)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
ticket = data.get("ticket", {})
|
ticket = data.get("ticket", {})
|
||||||
|
|
||||||
# Add web interface URL if not present
|
# Add web interface URL if not present
|
||||||
if "id" in ticket and "html_url" not in ticket:
|
if "id" in ticket and "html_url" not in ticket:
|
||||||
ticket["html_url"] = f"https://{subdomain}.zendesk.com/agent/tickets/{ticket['id']}"
|
ticket["html_url"] = f"https://{subdomain}.zendesk.com/agent/tickets/{ticket['id']}"
|
||||||
# Remove API url to avoid confusion
|
# Remove API url to avoid confusion
|
||||||
if "url" in ticket:
|
if "url" in ticket:
|
||||||
del ticket["url"]
|
del ticket["url"]
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"ticket_id": ticket_id,
|
"ticket_id": ticket_id,
|
||||||
"status": "solved",
|
"status": "solved",
|
||||||
"ticket": ticket,
|
"ticket": ticket,
|
||||||
}
|
}
|
||||||
if comment_body:
|
if comment_body:
|
||||||
result["comment_added"] = True
|
result["comment_added"] = True
|
||||||
result["comment_type"] = "public" if comment_public else "internal"
|
result["comment_type"] = "public" if comment_public else "internal"
|
||||||
|
|
||||||
except RetryableToolError:
|
return result
|
||||||
# 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
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "arcade_zendesk"
|
name = "arcade_zendesk"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arcade-tdk>=2.0.0,<3.0.0",
|
"arcade-tdk>=2.3.1,<3.0.0",
|
||||||
"httpx>=0.25.0,<1.0.0",
|
"httpx>=0.25.0,<1.0.0",
|
||||||
"beautifulsoup4>=4.0.0,<5"
|
"beautifulsoup4>=4.0.0,<5"
|
||||||
]
|
]
|
||||||
|
|
@ -15,8 +15,8 @@ dependencies = [
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"arcade-ai[evals]>=2.0.0,<3.0.0",
|
"arcade-ai[evals]>=2.2.1,<3.0.0",
|
||||||
"arcade-serve>=2.0.0,<3.0.0",
|
"arcade-serve>=2.1.0,<3.0.0",
|
||||||
"pytest>=8.3.0,<8.4.0",
|
"pytest>=8.3.0,<8.4.0",
|
||||||
"pytest-cov>=4.0.0,<4.1.0",
|
"pytest-cov>=4.0.0,<4.1.0",
|
||||||
"pytest-mock>=3.11.1,<3.12.0",
|
"pytest-mock>=3.11.1,<3.12.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
import pytest
|
||||||
from arcade_tdk.errors import RetryableToolError, ToolExecutionError
|
from arcade_tdk.errors import RetryableToolError, ToolExecutionError
|
||||||
|
|
||||||
|
|
@ -312,66 +309,6 @@ class TestSearchArticlesPagination:
|
||||||
assert "next_offset" not in result # No more results
|
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:
|
class TestSearchArticlesContentProcessing:
|
||||||
"""Test content processing and formatting."""
|
"""Test content processing and formatting."""
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue