python-zendesk-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_zendesk_sdk-0.1.0.dist-info/METADATA +218 -0
- python_zendesk_sdk-0.1.0.dist-info/RECORD +16 -0
- python_zendesk_sdk-0.1.0.dist-info/WHEEL +4 -0
- python_zendesk_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- zendesk_sdk/__init__.py +28 -0
- zendesk_sdk/client.py +321 -0
- zendesk_sdk/config.py +111 -0
- zendesk_sdk/exceptions.py +178 -0
- zendesk_sdk/http_client.py +256 -0
- zendesk_sdk/models/__init__.py +40 -0
- zendesk_sdk/models/base.py +50 -0
- zendesk_sdk/models/comment.py +62 -0
- zendesk_sdk/models/organization.py +59 -0
- zendesk_sdk/models/ticket.py +169 -0
- zendesk_sdk/models/user.py +107 -0
- zendesk_sdk/pagination.py +296 -0
zendesk_sdk/config.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Configuration management for Zendesk SDK."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field, computed_field, field_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ZendeskConfig(BaseModel):
|
|
10
|
+
"""Configuration for Zendesk API client.
|
|
11
|
+
|
|
12
|
+
This class handles authentication and connection settings for the Zendesk API.
|
|
13
|
+
It supports both email/password and email/token authentication methods.
|
|
14
|
+
Environment variables can be used for configuration.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
subdomain: str = Field(
|
|
18
|
+
...,
|
|
19
|
+
description="Zendesk subdomain (e.g., 'mycompany' for mycompany.zendesk.com)",
|
|
20
|
+
min_length=1,
|
|
21
|
+
)
|
|
22
|
+
email: str = Field(
|
|
23
|
+
...,
|
|
24
|
+
description="User email for authentication",
|
|
25
|
+
min_length=1,
|
|
26
|
+
)
|
|
27
|
+
password: Optional[str] = Field(
|
|
28
|
+
default=None,
|
|
29
|
+
description="User password (for email/password auth)",
|
|
30
|
+
)
|
|
31
|
+
token: Optional[str] = Field(
|
|
32
|
+
default=None,
|
|
33
|
+
description="API token (for email/token auth)",
|
|
34
|
+
)
|
|
35
|
+
timeout: float = Field(
|
|
36
|
+
default=30.0,
|
|
37
|
+
description="HTTP request timeout in seconds",
|
|
38
|
+
gt=0,
|
|
39
|
+
)
|
|
40
|
+
max_retries: int = Field(
|
|
41
|
+
default=3,
|
|
42
|
+
description="Maximum number of retry attempts",
|
|
43
|
+
ge=0,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def __init__(self, **data: Any) -> None:
|
|
47
|
+
# Load from environment variables if not provided
|
|
48
|
+
if "subdomain" not in data:
|
|
49
|
+
data["subdomain"] = os.getenv("ZENDESK_SUBDOMAIN", data.get("subdomain"))
|
|
50
|
+
if "email" not in data:
|
|
51
|
+
data["email"] = os.getenv("ZENDESK_EMAIL", data.get("email"))
|
|
52
|
+
if "password" not in data:
|
|
53
|
+
data["password"] = os.getenv("ZENDESK_PASSWORD", data.get("password"))
|
|
54
|
+
if "token" not in data:
|
|
55
|
+
data["token"] = os.getenv("ZENDESK_TOKEN", data.get("token"))
|
|
56
|
+
|
|
57
|
+
super().__init__(**data)
|
|
58
|
+
|
|
59
|
+
@field_validator("email")
|
|
60
|
+
@classmethod
|
|
61
|
+
def validate_email(cls, v: str) -> str:
|
|
62
|
+
"""Basic email validation."""
|
|
63
|
+
if "@" not in v:
|
|
64
|
+
raise ValueError("Invalid email format")
|
|
65
|
+
return v
|
|
66
|
+
|
|
67
|
+
@field_validator("subdomain")
|
|
68
|
+
@classmethod
|
|
69
|
+
def validate_subdomain(cls, v: str) -> str:
|
|
70
|
+
"""Validate subdomain format."""
|
|
71
|
+
if not v.replace("-", "").replace("_", "").isalnum():
|
|
72
|
+
raise ValueError("Subdomain can only contain letters, numbers, hyphens and underscores")
|
|
73
|
+
return v.lower()
|
|
74
|
+
|
|
75
|
+
def model_post_init(self, __context: Any) -> None:
|
|
76
|
+
"""Validate that either password or token is provided."""
|
|
77
|
+
if not self.password and not self.token:
|
|
78
|
+
raise ValueError("Either password or token must be provided")
|
|
79
|
+
if self.password and self.token:
|
|
80
|
+
raise ValueError("Cannot provide both password and token")
|
|
81
|
+
|
|
82
|
+
@computed_field # type: ignore[prop-decorator]
|
|
83
|
+
@property
|
|
84
|
+
def endpoint(self) -> str:
|
|
85
|
+
"""Generate the base API endpoint URL."""
|
|
86
|
+
return f"https://{self.subdomain}.zendesk.com/api/v2"
|
|
87
|
+
|
|
88
|
+
@computed_field # type: ignore[prop-decorator]
|
|
89
|
+
@property
|
|
90
|
+
def auth_tuple(self) -> tuple[str, str]:
|
|
91
|
+
"""Generate authentication tuple for HTTP requests."""
|
|
92
|
+
if self.token:
|
|
93
|
+
return (f"{self.email}/token", self.token)
|
|
94
|
+
return (self.email, self.password or "")
|
|
95
|
+
|
|
96
|
+
@computed_field # type: ignore[prop-decorator]
|
|
97
|
+
@property
|
|
98
|
+
def auth_type(self) -> str:
|
|
99
|
+
"""Return the authentication type being used."""
|
|
100
|
+
return "token" if self.token else "password"
|
|
101
|
+
|
|
102
|
+
def __repr__(self) -> str:
|
|
103
|
+
"""String representation without exposing credentials."""
|
|
104
|
+
return (
|
|
105
|
+
f"ZendeskConfig("
|
|
106
|
+
f"subdomain='{self.subdomain}', "
|
|
107
|
+
f"email='{self.email}', "
|
|
108
|
+
f"auth_type='{self.auth_type}', "
|
|
109
|
+
f"timeout={self.timeout}, "
|
|
110
|
+
f"max_retries={self.max_retries})"
|
|
111
|
+
)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Exception classes for Zendesk SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ZendeskBaseException(Exception):
|
|
9
|
+
"""Base exception for all Zendesk SDK errors."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str) -> None:
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.message = message
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ZendeskHTTPException(ZendeskBaseException):
|
|
17
|
+
"""Exception raised for HTTP-related errors."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
message: str,
|
|
22
|
+
status_code: int,
|
|
23
|
+
response: Optional[httpx.Response] = None,
|
|
24
|
+
request: Optional[httpx.Request] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status_code = status_code
|
|
28
|
+
self.response = response
|
|
29
|
+
self.request = request
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
return f"HTTP {self.status_code}: {self.message}"
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_response(cls, response: httpx.Response) -> "ZendeskHTTPException":
|
|
36
|
+
"""Create exception from HTTP response."""
|
|
37
|
+
try:
|
|
38
|
+
# Try to extract error message from JSON response
|
|
39
|
+
json_data = response.json()
|
|
40
|
+
if isinstance(json_data, dict):
|
|
41
|
+
if "error" in json_data:
|
|
42
|
+
message = json_data["error"]
|
|
43
|
+
elif "description" in json_data:
|
|
44
|
+
message = json_data["description"]
|
|
45
|
+
elif "message" in json_data:
|
|
46
|
+
message = json_data["message"]
|
|
47
|
+
else:
|
|
48
|
+
message = f"HTTP {response.status_code} error"
|
|
49
|
+
else:
|
|
50
|
+
message = f"HTTP {response.status_code} error"
|
|
51
|
+
except Exception:
|
|
52
|
+
# If JSON parsing fails, use status text or generic message
|
|
53
|
+
message = response.reason_phrase or f"HTTP {response.status_code} error"
|
|
54
|
+
|
|
55
|
+
return cls(
|
|
56
|
+
message=message,
|
|
57
|
+
status_code=response.status_code,
|
|
58
|
+
response=response,
|
|
59
|
+
request=response.request,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ZendeskAuthException(ZendeskHTTPException):
|
|
64
|
+
"""Exception raised for authentication-related errors (401, 403)."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
message: str = "Authentication failed",
|
|
69
|
+
status_code: int = 401,
|
|
70
|
+
response: Optional[httpx.Response] = None,
|
|
71
|
+
request: Optional[httpx.Request] = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
super().__init__(message, status_code, response, request)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ZendeskRateLimitException(ZendeskHTTPException):
|
|
77
|
+
"""Exception raised when rate limits are exceeded (429)."""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
message: str = "Rate limit exceeded",
|
|
82
|
+
status_code: int = 429,
|
|
83
|
+
response: Optional[httpx.Response] = None,
|
|
84
|
+
request: Optional[httpx.Request] = None,
|
|
85
|
+
retry_after: Optional[int] = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
super().__init__(message, status_code, response, request)
|
|
88
|
+
self.retry_after = retry_after
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_response(cls, response: httpx.Response) -> "ZendeskRateLimitException":
|
|
92
|
+
"""Create rate limit exception from HTTP response."""
|
|
93
|
+
# Extract retry-after header if present
|
|
94
|
+
retry_after = None
|
|
95
|
+
retry_after_header = response.headers.get("retry-after")
|
|
96
|
+
if retry_after_header:
|
|
97
|
+
try:
|
|
98
|
+
retry_after = int(retry_after_header)
|
|
99
|
+
except ValueError:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
json_data = response.json()
|
|
104
|
+
message = json_data.get("description") or json_data.get("error") or "Rate limit exceeded"
|
|
105
|
+
except Exception:
|
|
106
|
+
message = "Rate limit exceeded"
|
|
107
|
+
|
|
108
|
+
return cls(
|
|
109
|
+
message=message,
|
|
110
|
+
status_code=response.status_code,
|
|
111
|
+
response=response,
|
|
112
|
+
request=response.request,
|
|
113
|
+
retry_after=retry_after,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def __str__(self) -> str:
|
|
117
|
+
base_str = super().__str__()
|
|
118
|
+
if self.retry_after:
|
|
119
|
+
return f"{base_str} (retry after {self.retry_after}s)"
|
|
120
|
+
return base_str
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ZendeskPaginationException(ZendeskBaseException):
|
|
124
|
+
"""Exception raised for pagination-related errors."""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
message: str,
|
|
129
|
+
page_info: Optional[dict[str, Any]] = None,
|
|
130
|
+
) -> None:
|
|
131
|
+
super().__init__(message)
|
|
132
|
+
self.page_info = page_info or {}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ZendeskValidationException(ZendeskBaseException):
|
|
136
|
+
"""Exception raised for data validation errors."""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
message: str,
|
|
141
|
+
field: Optional[str] = None,
|
|
142
|
+
value: Optional[Any] = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
super().__init__(message)
|
|
145
|
+
self.field = field
|
|
146
|
+
self.value = value
|
|
147
|
+
|
|
148
|
+
def __str__(self) -> str:
|
|
149
|
+
if self.field:
|
|
150
|
+
return f"Validation error for field '{self.field}': {self.message}"
|
|
151
|
+
return f"Validation error: {self.message}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ZendeskTimeoutException(ZendeskBaseException):
|
|
155
|
+
"""Exception raised when requests timeout."""
|
|
156
|
+
|
|
157
|
+
def __init__(
|
|
158
|
+
self,
|
|
159
|
+
message: str = "Request timed out",
|
|
160
|
+
timeout: Optional[float] = None,
|
|
161
|
+
) -> None:
|
|
162
|
+
super().__init__(message)
|
|
163
|
+
self.timeout = timeout
|
|
164
|
+
|
|
165
|
+
def __str__(self) -> str:
|
|
166
|
+
if self.timeout:
|
|
167
|
+
return f"Request timed out after {self.timeout}s"
|
|
168
|
+
return self.message
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def create_exception_from_response(response: httpx.Response) -> ZendeskHTTPException:
|
|
172
|
+
"""Create appropriate exception based on response status code."""
|
|
173
|
+
if response.status_code in (401, 403):
|
|
174
|
+
return ZendeskAuthException.from_response(response)
|
|
175
|
+
elif response.status_code == 429:
|
|
176
|
+
return ZendeskRateLimitException.from_response(response)
|
|
177
|
+
else:
|
|
178
|
+
return ZendeskHTTPException.from_response(response)
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""HTTP client for Zendesk API with retry, rate limiting, and error handling."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
from urllib.parse import urljoin
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .config import ZendeskConfig
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
ZendeskHTTPException,
|
|
13
|
+
ZendeskRateLimitException,
|
|
14
|
+
ZendeskTimeoutException,
|
|
15
|
+
create_exception_from_response,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HTTPClient:
|
|
22
|
+
"""Async HTTP client for Zendesk API with advanced error handling and retry logic."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, config: ZendeskConfig) -> None:
|
|
25
|
+
"""Initialize HTTP client with configuration.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
config: Zendesk configuration containing auth and connection settings
|
|
29
|
+
"""
|
|
30
|
+
self.config = config
|
|
31
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
32
|
+
self._closed = False
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def client(self) -> httpx.AsyncClient:
|
|
36
|
+
"""Get or create httpx async client."""
|
|
37
|
+
if self._client is None:
|
|
38
|
+
self._client = self._create_client()
|
|
39
|
+
return self._client
|
|
40
|
+
|
|
41
|
+
def _create_client(self) -> httpx.AsyncClient:
|
|
42
|
+
"""Create configured httpx async client."""
|
|
43
|
+
auth = httpx.BasicAuth(username=self.config.auth_tuple[0], password=self.config.auth_tuple[1])
|
|
44
|
+
|
|
45
|
+
headers = {
|
|
46
|
+
"User-Agent": "python-zendesk-sdk/0.1.0",
|
|
47
|
+
"Content-Type": "application/json",
|
|
48
|
+
"Accept": "application/json",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return httpx.AsyncClient(
|
|
52
|
+
auth=auth,
|
|
53
|
+
headers=headers,
|
|
54
|
+
timeout=httpx.Timeout(self.config.timeout),
|
|
55
|
+
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
async def _make_request_with_retry(
|
|
59
|
+
self,
|
|
60
|
+
method: str,
|
|
61
|
+
url: str,
|
|
62
|
+
*,
|
|
63
|
+
params: Optional[Dict[str, Any]] = None,
|
|
64
|
+
json: Optional[Dict[str, Any]] = None,
|
|
65
|
+
max_retries: Optional[int] = None,
|
|
66
|
+
) -> httpx.Response:
|
|
67
|
+
"""Make HTTP request with retry logic and rate limiting handling."""
|
|
68
|
+
if max_retries is None:
|
|
69
|
+
max_retries = self.config.max_retries
|
|
70
|
+
|
|
71
|
+
last_exception: Optional[Exception] = None
|
|
72
|
+
|
|
73
|
+
for attempt in range(max_retries + 1):
|
|
74
|
+
try:
|
|
75
|
+
# Make the actual request
|
|
76
|
+
response = await self.client.request(
|
|
77
|
+
method=method,
|
|
78
|
+
url=url,
|
|
79
|
+
params=params,
|
|
80
|
+
json=json,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Handle different response types
|
|
84
|
+
retry_info = await self._handle_response(response, attempt, max_retries)
|
|
85
|
+
if retry_info:
|
|
86
|
+
last_exception = retry_info
|
|
87
|
+
continue
|
|
88
|
+
return response
|
|
89
|
+
|
|
90
|
+
except httpx.TimeoutException:
|
|
91
|
+
last_exception = await self._handle_timeout_exception(attempt, max_retries)
|
|
92
|
+
if last_exception:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
except (httpx.NetworkError, httpx.ConnectError) as e:
|
|
96
|
+
last_exception = await self._handle_network_exception(e, attempt, max_retries)
|
|
97
|
+
if last_exception:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
# This shouldn't happen, but just in case
|
|
101
|
+
if last_exception:
|
|
102
|
+
raise last_exception
|
|
103
|
+
raise ZendeskHTTPException("Unexpected error: no response after retries", 0)
|
|
104
|
+
|
|
105
|
+
async def _handle_response(self, response: httpx.Response, attempt: int, max_retries: int) -> Optional[Exception]:
|
|
106
|
+
"""Handle HTTP response based on status code. Return exception if should retry, None if success."""
|
|
107
|
+
# Handle rate limiting (429)
|
|
108
|
+
if response.status_code == 429:
|
|
109
|
+
return await self._handle_rate_limit(response, attempt, max_retries)
|
|
110
|
+
|
|
111
|
+
# Handle server errors (5xx) - retry these
|
|
112
|
+
if 500 <= response.status_code < 600:
|
|
113
|
+
return await self._handle_server_error(response, attempt, max_retries)
|
|
114
|
+
|
|
115
|
+
# Handle other HTTP errors (4xx, etc.) - don't retry these
|
|
116
|
+
if not response.is_success:
|
|
117
|
+
raise create_exception_from_response(response)
|
|
118
|
+
|
|
119
|
+
# Success!
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
async def _handle_rate_limit(self, response: httpx.Response, attempt: int, max_retries: int) -> Optional[Exception]:
|
|
123
|
+
"""Handle rate limiting response. Return exception if should retry, otherwise raise."""
|
|
124
|
+
rate_limit_exc = ZendeskRateLimitException.from_response(response)
|
|
125
|
+
|
|
126
|
+
if attempt < max_retries:
|
|
127
|
+
# Wait based on retry-after header or default backoff
|
|
128
|
+
wait_time = rate_limit_exc.retry_after or self._calculate_backoff(attempt)
|
|
129
|
+
logger.warning(f"Rate limited, waiting {wait_time}s before retry {attempt + 1}/{max_retries}")
|
|
130
|
+
await asyncio.sleep(wait_time)
|
|
131
|
+
return rate_limit_exc
|
|
132
|
+
else:
|
|
133
|
+
raise rate_limit_exc
|
|
134
|
+
|
|
135
|
+
async def _handle_server_error(
|
|
136
|
+
self, response: httpx.Response, attempt: int, max_retries: int
|
|
137
|
+
) -> Optional[Exception]:
|
|
138
|
+
"""Handle server error response. Return exception if should retry, otherwise raise."""
|
|
139
|
+
server_error = create_exception_from_response(response)
|
|
140
|
+
|
|
141
|
+
if attempt < max_retries:
|
|
142
|
+
wait_time = self._calculate_backoff(attempt)
|
|
143
|
+
logger.warning(
|
|
144
|
+
f"Server error {response.status_code}, retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
|
|
145
|
+
)
|
|
146
|
+
await asyncio.sleep(wait_time)
|
|
147
|
+
return server_error
|
|
148
|
+
else:
|
|
149
|
+
raise server_error
|
|
150
|
+
|
|
151
|
+
async def _handle_timeout_exception(self, attempt: int, max_retries: int) -> Optional[ZendeskTimeoutException]:
|
|
152
|
+
"""Handle timeout exception. Return exception if should retry, otherwise raise."""
|
|
153
|
+
timeout_exc = ZendeskTimeoutException(f"Request timed out after {self.config.timeout}s", self.config.timeout)
|
|
154
|
+
|
|
155
|
+
if attempt < max_retries:
|
|
156
|
+
wait_time = self._calculate_backoff(attempt)
|
|
157
|
+
logger.warning(f"Request timeout, retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})")
|
|
158
|
+
await asyncio.sleep(wait_time)
|
|
159
|
+
return timeout_exc
|
|
160
|
+
else:
|
|
161
|
+
raise timeout_exc
|
|
162
|
+
|
|
163
|
+
async def _handle_network_exception(
|
|
164
|
+
self, exc: Exception, attempt: int, max_retries: int
|
|
165
|
+
) -> Optional[ZendeskHTTPException]:
|
|
166
|
+
"""Handle network exception. Return exception if should retry, otherwise raise."""
|
|
167
|
+
network_exc = ZendeskHTTPException(f"Network error: {str(exc)}", 0)
|
|
168
|
+
|
|
169
|
+
if attempt < max_retries:
|
|
170
|
+
wait_time = self._calculate_backoff(attempt)
|
|
171
|
+
logger.warning(f"Network error, retrying in {wait_time}s (attempt {attempt + 1}/{max_retries}): {exc}")
|
|
172
|
+
await asyncio.sleep(wait_time)
|
|
173
|
+
return network_exc
|
|
174
|
+
else:
|
|
175
|
+
raise network_exc
|
|
176
|
+
|
|
177
|
+
def _calculate_backoff(self, attempt: int) -> float:
|
|
178
|
+
"""Calculate exponential backoff delay."""
|
|
179
|
+
return min(2**attempt, 60.0) # Max 60 seconds
|
|
180
|
+
|
|
181
|
+
def _build_url(self, path: str) -> str:
|
|
182
|
+
"""Build full URL from path."""
|
|
183
|
+
if path.startswith("http"):
|
|
184
|
+
return path
|
|
185
|
+
return urljoin(f"{self.config.endpoint}/", path.lstrip("/"))
|
|
186
|
+
|
|
187
|
+
async def get(
|
|
188
|
+
self,
|
|
189
|
+
path: str,
|
|
190
|
+
*,
|
|
191
|
+
params: Optional[Dict[str, Any]] = None,
|
|
192
|
+
max_retries: Optional[int] = None,
|
|
193
|
+
) -> Dict[str, Any]:
|
|
194
|
+
"""Make GET request and return JSON response."""
|
|
195
|
+
url = self._build_url(path)
|
|
196
|
+
response = await self._make_request_with_retry("GET", url, params=params, max_retries=max_retries)
|
|
197
|
+
return response.json()
|
|
198
|
+
|
|
199
|
+
async def post(
|
|
200
|
+
self,
|
|
201
|
+
path: str,
|
|
202
|
+
*,
|
|
203
|
+
json: Optional[Dict[str, Any]] = None,
|
|
204
|
+
max_retries: Optional[int] = None,
|
|
205
|
+
) -> Dict[str, Any]:
|
|
206
|
+
"""Make POST request and return JSON response."""
|
|
207
|
+
url = self._build_url(path)
|
|
208
|
+
response = await self._make_request_with_retry("POST", url, json=json, max_retries=max_retries)
|
|
209
|
+
return response.json()
|
|
210
|
+
|
|
211
|
+
async def put(
|
|
212
|
+
self,
|
|
213
|
+
path: str,
|
|
214
|
+
*,
|
|
215
|
+
json: Optional[Dict[str, Any]] = None,
|
|
216
|
+
max_retries: Optional[int] = None,
|
|
217
|
+
) -> Dict[str, Any]:
|
|
218
|
+
"""Make PUT request and return JSON response."""
|
|
219
|
+
url = self._build_url(path)
|
|
220
|
+
response = await self._make_request_with_retry("PUT", url, json=json, max_retries=max_retries)
|
|
221
|
+
return response.json()
|
|
222
|
+
|
|
223
|
+
async def delete(
|
|
224
|
+
self,
|
|
225
|
+
path: str,
|
|
226
|
+
*,
|
|
227
|
+
max_retries: Optional[int] = None,
|
|
228
|
+
) -> Optional[Dict[str, Any]]:
|
|
229
|
+
"""Make DELETE request and return JSON response if any."""
|
|
230
|
+
url = self._build_url(path)
|
|
231
|
+
response = await self._make_request_with_retry("DELETE", url, max_retries=max_retries)
|
|
232
|
+
|
|
233
|
+
# Some DELETE requests return empty responses
|
|
234
|
+
if response.content:
|
|
235
|
+
return response.json()
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
async def close(self) -> None:
|
|
239
|
+
"""Close the HTTP client."""
|
|
240
|
+
if self._client and not self._closed:
|
|
241
|
+
await self._client.aclose()
|
|
242
|
+
self._closed = True
|
|
243
|
+
|
|
244
|
+
async def __aenter__(self) -> "HTTPClient":
|
|
245
|
+
"""Async context manager entry."""
|
|
246
|
+
return self
|
|
247
|
+
|
|
248
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no-untyped-def]
|
|
249
|
+
"""Async context manager exit."""
|
|
250
|
+
await self.close()
|
|
251
|
+
|
|
252
|
+
def __del__(self) -> None:
|
|
253
|
+
"""Destructor to ensure client is closed."""
|
|
254
|
+
if self._client and not self._closed:
|
|
255
|
+
# Can't call async method in __del__, so we log a warning
|
|
256
|
+
logger.warning("HTTPClient was not properly closed. Use 'async with' or call close() explicitly.")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Zendesk API data models."""
|
|
2
|
+
|
|
3
|
+
from .base import ZendeskModel
|
|
4
|
+
from .comment import Comment, CommentAttachment, CommentMetadata, CommentVia
|
|
5
|
+
from .organization import Organization, OrganizationField, OrganizationSubscription
|
|
6
|
+
from .ticket import (
|
|
7
|
+
SatisfactionRating,
|
|
8
|
+
Ticket,
|
|
9
|
+
TicketCustomField,
|
|
10
|
+
TicketField,
|
|
11
|
+
TicketMetrics,
|
|
12
|
+
TicketVia,
|
|
13
|
+
)
|
|
14
|
+
from .user import User, UserField, UserIdentity, UserPhoto
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
# Base
|
|
18
|
+
"ZendeskModel",
|
|
19
|
+
# User models
|
|
20
|
+
"User",
|
|
21
|
+
"UserField",
|
|
22
|
+
"UserIdentity",
|
|
23
|
+
"UserPhoto",
|
|
24
|
+
# Organization models
|
|
25
|
+
"Organization",
|
|
26
|
+
"OrganizationField",
|
|
27
|
+
"OrganizationSubscription",
|
|
28
|
+
# Ticket models
|
|
29
|
+
"Ticket",
|
|
30
|
+
"TicketField",
|
|
31
|
+
"TicketMetrics",
|
|
32
|
+
"TicketCustomField",
|
|
33
|
+
"TicketVia",
|
|
34
|
+
"SatisfactionRating",
|
|
35
|
+
# Comment models
|
|
36
|
+
"Comment",
|
|
37
|
+
"CommentAttachment",
|
|
38
|
+
"CommentMetadata",
|
|
39
|
+
"CommentVia",
|
|
40
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Base model for all Zendesk API data models."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, field_serializer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ZendeskModel(BaseModel):
|
|
10
|
+
"""Base model for all Zendesk API responses."""
|
|
11
|
+
|
|
12
|
+
model_config = ConfigDict(
|
|
13
|
+
# Allow field aliases (id -> uid, etc.)
|
|
14
|
+
populate_by_name=True,
|
|
15
|
+
# Use enum values instead of enum names
|
|
16
|
+
use_enum_values=True,
|
|
17
|
+
# Validate assignment to fields
|
|
18
|
+
validate_assignment=True,
|
|
19
|
+
# Allow extra fields that aren't defined in the model
|
|
20
|
+
extra="ignore",
|
|
21
|
+
# Convert string dates to datetime objects
|
|
22
|
+
str_to_lower=False,
|
|
23
|
+
# Allow arbitrary types (for complex nested structures)
|
|
24
|
+
arbitrary_types_allowed=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@field_serializer("*", when_used="json")
|
|
28
|
+
def serialize_datetime(self, value: Any) -> Any:
|
|
29
|
+
"""Serialize datetime objects to ISO format strings."""
|
|
30
|
+
if isinstance(value, datetime):
|
|
31
|
+
return value.isoformat()
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
"""String representation showing class name and key fields."""
|
|
36
|
+
class_name = self.__class__.__name__
|
|
37
|
+
# Try to find an ID field to display
|
|
38
|
+
id_field = None
|
|
39
|
+
for field in ["uid", "id", "name", "subject"]:
|
|
40
|
+
if hasattr(self, field):
|
|
41
|
+
id_field = f"{field}={getattr(self, field)!r}"
|
|
42
|
+
break
|
|
43
|
+
|
|
44
|
+
if id_field:
|
|
45
|
+
return f"{class_name}({id_field})"
|
|
46
|
+
return f"{class_name}()"
|
|
47
|
+
|
|
48
|
+
def __repr__(self) -> str:
|
|
49
|
+
"""Detailed string representation."""
|
|
50
|
+
return f"{self.__class__.__name__}({self.model_dump()})"
|