python-zendesk-sdk 0.12.0__tar.gz → 0.13.0__tar.gz

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.
Files changed (63) hide show
  1. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/.gitignore +2 -1
  2. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/PKG-INFO +42 -3
  3. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/README.md +41 -2
  4. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/pyproject.toml +1 -1
  5. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/__init__.py +1 -1
  6. python_zendesk_sdk-0.13.0/src/zendesk_sdk/config.py +175 -0
  7. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/http_client.py +51 -2
  8. python_zendesk_sdk-0.13.0/tests/test_config.py +216 -0
  9. python_zendesk_sdk-0.13.0/tests/test_http_client.py +368 -0
  10. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/test_package_import.py +1 -1
  11. python_zendesk_sdk-0.12.0/src/zendesk_sdk/config.py +0 -126
  12. python_zendesk_sdk-0.12.0/tests/test_config.py +0 -92
  13. python_zendesk_sdk-0.12.0/tests/test_http_client.py +0 -180
  14. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/.flake8 +0 -0
  15. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/.github/workflows/publish.yml +0 -0
  16. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/.python-version +0 -0
  17. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/LICENSE +0 -0
  18. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/context7.json +0 -0
  19. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/basic_usage.py +0 -0
  20. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/caching.py +0 -0
  21. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/enriched_tickets.py +0 -0
  22. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/error_handling.py +0 -0
  23. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/groups.py +0 -0
  24. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/help_center.py +0 -0
  25. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/organizations.py +0 -0
  26. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/pagination_example.py +0 -0
  27. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/search.py +0 -0
  28. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/users.py +0 -0
  29. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/client.py +0 -0
  30. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/__init__.py +0 -0
  31. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/attachments.py +0 -0
  32. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/base.py +0 -0
  33. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/groups.py +0 -0
  34. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/help_center/__init__.py +0 -0
  35. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/help_center/articles.py +0 -0
  36. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/help_center/categories.py +0 -0
  37. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/help_center/sections.py +0 -0
  38. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/organizations.py +0 -0
  39. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/search.py +0 -0
  40. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/ticket_fields.py +0 -0
  41. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/tickets.py +0 -0
  42. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/users.py +0 -0
  43. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/exceptions.py +0 -0
  44. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/__init__.py +0 -0
  45. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/base.py +0 -0
  46. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/comment.py +0 -0
  47. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/enriched_ticket.py +0 -0
  48. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/group.py +0 -0
  49. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/group_membership.py +0 -0
  50. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/help_center.py +0 -0
  51. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/organization.py +0 -0
  52. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/search.py +0 -0
  53. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/ticket.py +0 -0
  54. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/user.py +0 -0
  55. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/pagination.py +0 -0
  56. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/__init__.py +0 -0
  57. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/test_client.py +0 -0
  58. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/test_clients.py +0 -0
  59. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/test_exceptions.py +0 -0
  60. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/test_help_center_client.py +0 -0
  61. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/test_models.py +0 -0
  62. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/test_pagination.py +0 -0
  63. {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/test_search_query_config.py +0 -0
@@ -190,4 +190,5 @@ debug_*.py
190
190
 
191
191
  # Local directories
192
192
  .claude/
193
- llm/
193
+ llm/
194
+ CLAUDE.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-zendesk-sdk
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: Modern Python SDK for Zendesk API
5
5
  Project-URL: Homepage, https://github.com/bormog/python-zendesk-sdk
6
6
  Project-URL: Repository, https://github.com/bormog/python-zendesk-sdk
@@ -68,6 +68,7 @@ Modern Python SDK for Zendesk API, designed for automation and AI agents.
68
68
  - [Search](#search)
69
69
  - [Help Center](#help-center)
70
70
  - [Error Handling](#error-handling)
71
+ - [Proactive Rate Limiting](#proactive-rate-limiting)
71
72
  - [Caching](#caching)
72
73
  - [Examples](#examples)
73
74
 
@@ -102,6 +103,8 @@ Zendesk has a powerful REST API, but using it directly is painful:
102
103
  - **Caching**: TTL-based caching for users, organizations, and Help Center
103
104
  - **Help Center**: Full CRUD for Categories, Sections, and Articles
104
105
  - **Async HTTP**: Built on httpx with retry logic, rate limiting, exponential backoff
106
+ - **Proactive Rate Limiting**: Monitors `X-Rate-Limit-Remaining` and throttles before hitting limits
107
+ - **Authentication**: Token auth (Basic Auth) and OAuth (Bearer token)
105
108
  - **Configuration**: Environment variables or direct instantiation
106
109
 
107
110
  ## Installation
@@ -141,20 +144,33 @@ asyncio.run(main())
141
144
 
142
145
  ## Configuration
143
146
 
144
- ### Direct instantiation
147
+ ### Token Authentication
145
148
  ```python
146
149
  config = ZendeskConfig(
147
150
  subdomain="mycompany",
148
151
  email="user@example.com",
149
- token="api_token_here"
152
+ token="api_token_here",
153
+ )
154
+ ```
155
+
156
+ ### OAuth Authentication
157
+ ```python
158
+ config = ZendeskConfig(
159
+ subdomain="mycompany",
160
+ oauth_token="your_oauth_token",
150
161
  )
151
162
  ```
152
163
 
153
164
  ### Environment variables
154
165
  ```bash
166
+ # Token auth
155
167
  export ZENDESK_SUBDOMAIN=mycompany
156
168
  export ZENDESK_EMAIL=user@example.com
157
169
  export ZENDESK_TOKEN=api_token_here
170
+
171
+ # Or OAuth
172
+ export ZENDESK_SUBDOMAIN=mycompany
173
+ export ZENDESK_OAUTH_TOKEN=your_oauth_token
158
174
  ```
159
175
 
160
176
  ```python
@@ -656,6 +672,29 @@ config = ZendeskConfig(
656
672
  )
657
673
  ```
658
674
 
675
+ ### Proactive Rate Limiting
676
+
677
+ By default, the SDK reacts to rate limiting after hitting a 429 response. With proactive rate limiting, the SDK reads the `X-Rate-Limit-Remaining` header from every response and starts throttling **before** hitting the limit:
678
+
679
+ ```python
680
+ config = ZendeskConfig(
681
+ subdomain="mycompany",
682
+ email="user@example.com",
683
+ token="api_token",
684
+ proactive_ratelimit=50, # Start throttling when < 50 requests remaining
685
+ proactive_ratelimit_request_interval=10, # Wait 10s between requests when throttling
686
+ )
687
+ ```
688
+
689
+ When `X-Rate-Limit-Remaining` drops below the threshold, the SDK pauses between requests to let the quota recover. Once remaining goes back above the threshold, requests resume at full speed.
690
+
691
+ | Parameter | Default | Description |
692
+ |-----------|---------|-------------|
693
+ | `proactive_ratelimit` | None (disabled) | Threshold to start throttling |
694
+ | `proactive_ratelimit_request_interval` | 10 | Seconds to wait between requests when throttling |
695
+
696
+ Zendesk typically allows 400 requests per minute. A threshold of 40-60 provides a good safety margin.
697
+
659
698
  ## Caching
660
699
 
661
700
  The SDK includes built-in caching for frequently accessed resources. Caching is enabled by default and can be configured or disabled.
@@ -30,6 +30,7 @@ Modern Python SDK for Zendesk API, designed for automation and AI agents.
30
30
  - [Search](#search)
31
31
  - [Help Center](#help-center)
32
32
  - [Error Handling](#error-handling)
33
+ - [Proactive Rate Limiting](#proactive-rate-limiting)
33
34
  - [Caching](#caching)
34
35
  - [Examples](#examples)
35
36
 
@@ -64,6 +65,8 @@ Zendesk has a powerful REST API, but using it directly is painful:
64
65
  - **Caching**: TTL-based caching for users, organizations, and Help Center
65
66
  - **Help Center**: Full CRUD for Categories, Sections, and Articles
66
67
  - **Async HTTP**: Built on httpx with retry logic, rate limiting, exponential backoff
68
+ - **Proactive Rate Limiting**: Monitors `X-Rate-Limit-Remaining` and throttles before hitting limits
69
+ - **Authentication**: Token auth (Basic Auth) and OAuth (Bearer token)
67
70
  - **Configuration**: Environment variables or direct instantiation
68
71
 
69
72
  ## Installation
@@ -103,20 +106,33 @@ asyncio.run(main())
103
106
 
104
107
  ## Configuration
105
108
 
106
- ### Direct instantiation
109
+ ### Token Authentication
107
110
  ```python
108
111
  config = ZendeskConfig(
109
112
  subdomain="mycompany",
110
113
  email="user@example.com",
111
- token="api_token_here"
114
+ token="api_token_here",
115
+ )
116
+ ```
117
+
118
+ ### OAuth Authentication
119
+ ```python
120
+ config = ZendeskConfig(
121
+ subdomain="mycompany",
122
+ oauth_token="your_oauth_token",
112
123
  )
113
124
  ```
114
125
 
115
126
  ### Environment variables
116
127
  ```bash
128
+ # Token auth
117
129
  export ZENDESK_SUBDOMAIN=mycompany
118
130
  export ZENDESK_EMAIL=user@example.com
119
131
  export ZENDESK_TOKEN=api_token_here
132
+
133
+ # Or OAuth
134
+ export ZENDESK_SUBDOMAIN=mycompany
135
+ export ZENDESK_OAUTH_TOKEN=your_oauth_token
120
136
  ```
121
137
 
122
138
  ```python
@@ -618,6 +634,29 @@ config = ZendeskConfig(
618
634
  )
619
635
  ```
620
636
 
637
+ ### Proactive Rate Limiting
638
+
639
+ By default, the SDK reacts to rate limiting after hitting a 429 response. With proactive rate limiting, the SDK reads the `X-Rate-Limit-Remaining` header from every response and starts throttling **before** hitting the limit:
640
+
641
+ ```python
642
+ config = ZendeskConfig(
643
+ subdomain="mycompany",
644
+ email="user@example.com",
645
+ token="api_token",
646
+ proactive_ratelimit=50, # Start throttling when < 50 requests remaining
647
+ proactive_ratelimit_request_interval=10, # Wait 10s between requests when throttling
648
+ )
649
+ ```
650
+
651
+ When `X-Rate-Limit-Remaining` drops below the threshold, the SDK pauses between requests to let the quota recover. Once remaining goes back above the threshold, requests resume at full speed.
652
+
653
+ | Parameter | Default | Description |
654
+ |-----------|---------|-------------|
655
+ | `proactive_ratelimit` | None (disabled) | Threshold to start throttling |
656
+ | `proactive_ratelimit_request_interval` | 10 | Seconds to wait between requests when throttling |
657
+
658
+ Zendesk typically allows 400 requests per minute. A threshold of 40-60 provides a good safety margin.
659
+
621
660
  ## Caching
622
661
 
623
662
  The SDK includes built-in caching for frequently accessed resources. Caching is enabled by default and can be configured or disabled.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-zendesk-sdk"
3
- version = "0.12.0"
3
+ version = "0.13.0"
4
4
  description = "Modern Python SDK for Zendesk API"
5
5
  authors = [
6
6
  {name = "bormog"}
@@ -5,7 +5,7 @@ This package provides a clean, async-first interface to the Zendesk API
5
5
  with full type safety and comprehensive error handling.
6
6
  """
7
7
 
8
- __version__ = "0.12.0"
8
+ __version__ = "0.13.0"
9
9
 
10
10
  from .client import ZendeskClient
11
11
  from .clients import (
@@ -0,0 +1,175 @@
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, model_validator
7
+
8
+
9
+ class CacheConfig(BaseModel):
10
+ """Cache configuration for Zendesk SDK.
11
+
12
+ Controls TTL (time-to-live) and max size for different resource caches.
13
+ Set enabled=False to disable caching entirely.
14
+ """
15
+
16
+ enabled: bool = Field(default=True, description="Enable/disable caching")
17
+
18
+ # Users cache
19
+ user_ttl: int = Field(default=300, description="User cache TTL in seconds (default: 5 min)", ge=0)
20
+ user_maxsize: int = Field(default=1000, description="Max cached users", ge=1)
21
+
22
+ # Organizations cache
23
+ org_ttl: int = Field(default=600, description="Organization cache TTL in seconds (default: 10 min)", ge=0)
24
+ org_maxsize: int = Field(default=500, description="Max cached organizations", ge=1)
25
+
26
+ # Groups cache
27
+ group_ttl: int = Field(default=600, description="Group cache TTL in seconds (default: 10 min)", ge=0)
28
+ group_maxsize: int = Field(default=500, description="Max cached groups", ge=1)
29
+
30
+ # Help Center cache
31
+ article_ttl: int = Field(default=900, description="Article cache TTL in seconds (default: 15 min)", ge=0)
32
+ article_maxsize: int = Field(default=500, description="Max cached articles", ge=1)
33
+
34
+ category_ttl: int = Field(default=1800, description="Category cache TTL in seconds (default: 30 min)", ge=0)
35
+ category_maxsize: int = Field(default=200, description="Max cached categories", ge=1)
36
+
37
+ section_ttl: int = Field(default=1800, description="Section cache TTL in seconds (default: 30 min)", ge=0)
38
+ section_maxsize: int = Field(default=200, description="Max cached sections", ge=1)
39
+
40
+
41
+ class ZendeskConfig(BaseModel):
42
+ """Configuration for Zendesk API client.
43
+
44
+ Supports two authentication methods (mutually exclusive):
45
+ - Token auth: email + token (Basic Auth)
46
+ - OAuth: oauth_token (Bearer token)
47
+
48
+ Environment variables: ZENDESK_SUBDOMAIN, ZENDESK_EMAIL, ZENDESK_TOKEN, ZENDESK_OAUTH_TOKEN.
49
+ """
50
+
51
+ subdomain: str = Field(
52
+ ...,
53
+ description="Zendesk subdomain (e.g., 'mycompany' for mycompany.zendesk.com)",
54
+ min_length=1,
55
+ )
56
+ email: Optional[str] = Field(
57
+ default=None,
58
+ description="User email for token authentication",
59
+ )
60
+ token: Optional[str] = Field(
61
+ default=None,
62
+ description="API token for token authentication",
63
+ )
64
+ oauth_token: Optional[str] = Field(
65
+ default=None,
66
+ description="OAuth token for Bearer authentication",
67
+ )
68
+ timeout: float = Field(
69
+ default=30.0,
70
+ description="HTTP request timeout in seconds",
71
+ gt=0,
72
+ )
73
+ max_retries: int = Field(
74
+ default=3,
75
+ description="Maximum number of retry attempts",
76
+ ge=0,
77
+ )
78
+ proactive_ratelimit: Optional[int] = Field(
79
+ default=None,
80
+ description="Start sleeping when X-Rate-Limit-Remaining drops below this threshold",
81
+ ge=1,
82
+ )
83
+ proactive_ratelimit_request_interval: int = Field(
84
+ default=10,
85
+ description="Seconds to wait between requests when proactive rate limit threshold is reached",
86
+ ge=1,
87
+ )
88
+ cache: CacheConfig = Field(
89
+ default_factory=CacheConfig,
90
+ description="Cache configuration",
91
+ )
92
+
93
+ def __init__(self, **data: Any) -> None:
94
+ # Load from environment variables if not provided
95
+ if "subdomain" not in data:
96
+ data["subdomain"] = os.getenv("ZENDESK_SUBDOMAIN", data.get("subdomain"))
97
+
98
+ # Only load env vars for one auth method — explicit args take precedence
99
+ has_explicit_token_auth = "email" in data or "token" in data
100
+ has_explicit_oauth = "oauth_token" in data
101
+
102
+ if not has_explicit_token_auth and not has_explicit_oauth:
103
+ # Nothing explicit — try env vars, token auth first
104
+ env_email = os.getenv("ZENDESK_EMAIL")
105
+ env_token = os.getenv("ZENDESK_TOKEN")
106
+ env_oauth = os.getenv("ZENDESK_OAUTH_TOKEN")
107
+ if env_email or env_token:
108
+ data.setdefault("email", env_email)
109
+ data.setdefault("token", env_token)
110
+ elif env_oauth:
111
+ data.setdefault("oauth_token", env_oauth)
112
+ elif has_explicit_token_auth and not has_explicit_oauth:
113
+ # Token auth explicit — fill missing from env
114
+ data.setdefault("email", os.getenv("ZENDESK_EMAIL"))
115
+ data.setdefault("token", os.getenv("ZENDESK_TOKEN"))
116
+ elif has_explicit_oauth and not has_explicit_token_auth:
117
+ # OAuth explicit — only fill oauth from env
118
+ data.setdefault("oauth_token", os.getenv("ZENDESK_OAUTH_TOKEN"))
119
+
120
+ super().__init__(**data)
121
+
122
+ @model_validator(mode="after")
123
+ def validate_auth(self) -> "ZendeskConfig":
124
+ """Validate that exactly one auth method is provided."""
125
+ has_token_auth = self.email is not None or self.token is not None
126
+ has_oauth = self.oauth_token is not None
127
+
128
+ if has_token_auth and has_oauth:
129
+ raise ValueError("Cannot use both token auth (email/token) and oauth_token simultaneously")
130
+
131
+ if not has_token_auth and not has_oauth:
132
+ raise ValueError("Either email/token or oauth_token must be provided")
133
+
134
+ if has_token_auth:
135
+ if not self.email or not self.token:
136
+ raise ValueError("Both email and token are required for token authentication")
137
+ if "@" not in self.email:
138
+ raise ValueError("Invalid email format")
139
+
140
+ return self
141
+
142
+ @field_validator("subdomain")
143
+ @classmethod
144
+ def validate_subdomain(cls, v: str) -> str:
145
+ """Validate subdomain format."""
146
+ if not v.replace("-", "").replace("_", "").isalnum():
147
+ raise ValueError("Subdomain can only contain letters, numbers, hyphens and underscores")
148
+ return v.lower()
149
+
150
+ @computed_field # type: ignore[prop-decorator]
151
+ @property
152
+ def endpoint(self) -> str:
153
+ """Generate the base API endpoint URL."""
154
+ return f"https://{self.subdomain}.zendesk.com/api/v2"
155
+
156
+ @computed_field # type: ignore[prop-decorator]
157
+ @property
158
+ def auth_tuple(self) -> Optional[tuple[str, str]]:
159
+ """Generate authentication tuple for HTTP requests. None for OAuth mode."""
160
+ if self.email and self.token:
161
+ return f"{self.email}/token", self.token
162
+ return None
163
+
164
+ def __repr__(self) -> str:
165
+ """String representation without exposing credentials."""
166
+ auth_info = f"email='{self.email}'" if self.email else "oauth=True"
167
+ parts = [
168
+ f"subdomain='{self.subdomain}'",
169
+ auth_info,
170
+ f"timeout={self.timeout}",
171
+ f"max_retries={self.max_retries}",
172
+ ]
173
+ if self.proactive_ratelimit is not None:
174
+ parts.append(f"proactive_ratelimit={self.proactive_ratelimit}")
175
+ return f"ZendeskConfig({', '.join(parts)})"
@@ -2,6 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  import logging
5
+ from time import monotonic
5
6
  from typing import Any, Dict, Optional
6
7
  from urllib.parse import urljoin
7
8
 
@@ -31,6 +32,10 @@ class HTTPClient:
31
32
  self._client: Optional[httpx.AsyncClient] = None
32
33
  self._closed = False
33
34
 
35
+ # Proactive rate limit tracking
36
+ self._last_call_time: Optional[float] = None
37
+ self._last_limit_remaining: Optional[int] = None
38
+
34
39
  @property
35
40
  def client(self) -> httpx.AsyncClient:
36
41
  """Get or create httpx async client."""
@@ -40,14 +45,18 @@ class HTTPClient:
40
45
 
41
46
  def _create_client(self) -> httpx.AsyncClient:
42
47
  """Create configured httpx async client."""
43
- auth = httpx.BasicAuth(username=self.config.auth_tuple[0], password=self.config.auth_tuple[1])
44
-
45
48
  headers = {
46
49
  "User-Agent": "python-zendesk-sdk/0.1.0",
47
50
  "Content-Type": "application/json",
48
51
  "Accept": "application/json",
49
52
  }
50
53
 
54
+ auth: Optional[httpx.BasicAuth] = None
55
+ if self.config.auth_tuple:
56
+ auth = httpx.BasicAuth(username=self.config.auth_tuple[0], password=self.config.auth_tuple[1])
57
+ elif self.config.oauth_token:
58
+ headers["Authorization"] = f"Bearer {self.config.oauth_token}"
59
+
51
60
  return httpx.AsyncClient(
52
61
  auth=auth,
53
62
  headers=headers,
@@ -55,6 +64,40 @@ class HTTPClient:
55
64
  limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
56
65
  )
57
66
 
67
+ async def _apply_proactive_ratelimit(self) -> None:
68
+ """Apply proactive rate limiting by sleeping if remaining requests are below threshold."""
69
+ if self.config.proactive_ratelimit is None:
70
+ return
71
+ if self._last_limit_remaining is None or self._last_call_time is None:
72
+ return
73
+
74
+ if self._last_limit_remaining >= self.config.proactive_ratelimit: # type: ignore[operator]
75
+ return
76
+
77
+ time_since_last = monotonic() - self._last_call_time
78
+ interval = self.config.proactive_ratelimit_request_interval
79
+ if time_since_last >= interval:
80
+ return
81
+
82
+ remaining_sleep = interval - time_since_last
83
+ logger.warning(
84
+ f"Proactive rate limit: {self._last_limit_remaining} remaining "
85
+ f"(threshold: {self.config.proactive_ratelimit}), sleeping {remaining_sleep:.1f}s"
86
+ )
87
+ await asyncio.sleep(remaining_sleep)
88
+
89
+ def _update_rate_limit_state(self, response: httpx.Response) -> None:
90
+ """Update rate limit tracking state from response headers."""
91
+ if self.config.proactive_ratelimit is None:
92
+ return
93
+ self._last_call_time = monotonic()
94
+ remaining_header = response.headers.get("X-Rate-Limit-Remaining")
95
+ if remaining_header is not None:
96
+ try:
97
+ self._last_limit_remaining = int(remaining_header)
98
+ except (ValueError, TypeError):
99
+ pass
100
+
58
101
  async def _make_request_with_retry(
59
102
  self,
60
103
  method: str,
@@ -72,6 +115,9 @@ class HTTPClient:
72
115
 
73
116
  for attempt in range(max_retries + 1):
74
117
  try:
118
+ # Apply proactive rate limiting before request
119
+ await self._apply_proactive_ratelimit()
120
+
75
121
  # Make the actual request
76
122
  response = await self.client.request(
77
123
  method=method,
@@ -80,6 +126,9 @@ class HTTPClient:
80
126
  json=json,
81
127
  )
82
128
 
129
+ # Update rate limit tracking state
130
+ self._update_rate_limit_state(response)
131
+
83
132
  # Handle different response types
84
133
  retry_info = await self._handle_response(response, attempt, max_retries)
85
134
  if retry_info:
@@ -0,0 +1,216 @@
1
+ """Tests for ZendeskConfig."""
2
+
3
+ import os
4
+ from unittest.mock import patch
5
+
6
+ import pytest
7
+ from pydantic import ValidationError
8
+
9
+ from zendesk_sdk.config import ZendeskConfig
10
+
11
+
12
+ class TestZendeskConfig:
13
+ """Test cases for ZendeskConfig class."""
14
+
15
+ def test_basic_config_with_token(self):
16
+ """Test creating config with email/token authentication."""
17
+ config = ZendeskConfig(
18
+ subdomain="test",
19
+ email="user@example.com",
20
+ token="api_token_123",
21
+ )
22
+
23
+ assert config.subdomain == "test"
24
+ assert config.email == "user@example.com"
25
+ assert config.token == "api_token_123"
26
+ assert config.oauth_token is None
27
+ assert config.endpoint == "https://test.zendesk.com/api/v2"
28
+ assert config.auth_tuple == ("user@example.com/token", "api_token_123")
29
+
30
+ def test_basic_config_with_oauth_token(self):
31
+ """Test creating config with OAuth token authentication."""
32
+ config = ZendeskConfig(
33
+ subdomain="test",
34
+ oauth_token="oauth_token_123",
35
+ )
36
+
37
+ assert config.subdomain == "test"
38
+ assert config.oauth_token == "oauth_token_123"
39
+ assert config.email is None
40
+ assert config.token is None
41
+ assert config.auth_tuple is None
42
+ assert config.endpoint == "https://test.zendesk.com/api/v2"
43
+
44
+ def test_invalid_timeout(self):
45
+ """Test invalid timeout values."""
46
+ with pytest.raises(ValidationError):
47
+ ZendeskConfig(
48
+ subdomain="test",
49
+ email="user@example.com",
50
+ token="api_token_123",
51
+ timeout=0.0, # Must be > 0
52
+ )
53
+
54
+ def test_invalid_max_retries(self):
55
+ """Test invalid max_retries values."""
56
+ with pytest.raises(ValidationError):
57
+ ZendeskConfig(
58
+ subdomain="test",
59
+ email="user@example.com",
60
+ token="api_token_123",
61
+ max_retries=-1, # Must be >= 0
62
+ )
63
+
64
+ def test_missing_all_auth(self):
65
+ """Test that at least one auth method is required."""
66
+ with patch.dict(os.environ, {}, clear=True):
67
+ with pytest.raises(ValidationError, match="Either email/token or oauth_token must be provided"):
68
+ ZendeskConfig(subdomain="test")
69
+
70
+ def test_both_auth_methods(self):
71
+ """Test that token and oauth_token are mutually exclusive."""
72
+ with pytest.raises(ValidationError, match="Cannot use both"):
73
+ ZendeskConfig(
74
+ subdomain="test",
75
+ email="user@example.com",
76
+ token="api_token_123",
77
+ oauth_token="oauth_token_123",
78
+ )
79
+
80
+ def test_email_without_token(self):
81
+ """Test that email requires token."""
82
+ with patch.dict(os.environ, {}, clear=True):
83
+ with pytest.raises(ValidationError, match="Both email and token are required"):
84
+ ZendeskConfig(
85
+ subdomain="test",
86
+ email="user@example.com",
87
+ )
88
+
89
+ def test_token_without_email(self):
90
+ """Test that token requires email."""
91
+ with patch.dict(os.environ, {}, clear=True):
92
+ with pytest.raises(ValidationError, match="Both email and token are required"):
93
+ ZendeskConfig(
94
+ subdomain="test",
95
+ token="api_token_123",
96
+ )
97
+
98
+ def test_invalid_email(self):
99
+ """Test invalid email format."""
100
+ with pytest.raises(ValidationError, match="Invalid email format"):
101
+ ZendeskConfig(
102
+ subdomain="test",
103
+ email="invalid-email",
104
+ token="api_token_123",
105
+ )
106
+
107
+ def test_env_variables_token_auth(self):
108
+ """Test loading token auth config from environment variables."""
109
+ with patch.dict(
110
+ os.environ,
111
+ {
112
+ "ZENDESK_SUBDOMAIN": "env-test",
113
+ "ZENDESK_EMAIL": "env@example.com",
114
+ "ZENDESK_TOKEN": "env_token_123",
115
+ },
116
+ ):
117
+ config = ZendeskConfig()
118
+ assert config.subdomain == "env-test"
119
+ assert config.email == "env@example.com"
120
+ assert config.token == "env_token_123"
121
+
122
+ def test_env_variables_oauth(self):
123
+ """Test loading OAuth config from environment variables."""
124
+ with patch.dict(
125
+ os.environ,
126
+ {
127
+ "ZENDESK_SUBDOMAIN": "env-test",
128
+ "ZENDESK_OAUTH_TOKEN": "env_oauth_123",
129
+ },
130
+ clear=True,
131
+ ):
132
+ config = ZendeskConfig()
133
+ assert config.subdomain == "env-test"
134
+ assert config.oauth_token == "env_oauth_123"
135
+ assert config.auth_tuple is None
136
+
137
+ def test_repr_hides_token(self):
138
+ """Test that repr doesn't expose token."""
139
+ config = ZendeskConfig(
140
+ subdomain="test",
141
+ email="user@example.com",
142
+ token="secret_token",
143
+ )
144
+ repr_str = repr(config)
145
+ assert "secret_token" not in repr_str
146
+ assert "test" in repr_str
147
+ assert "user@example.com" in repr_str
148
+
149
+ def test_repr_hides_oauth_token(self):
150
+ """Test that repr doesn't expose oauth_token."""
151
+ config = ZendeskConfig(
152
+ subdomain="test",
153
+ oauth_token="secret_oauth_token",
154
+ )
155
+ repr_str = repr(config)
156
+ assert "secret_oauth_token" not in repr_str
157
+ assert "oauth=True" in repr_str
158
+
159
+
160
+ class TestRateLimitConfig:
161
+ """Test cases for proactive rate limiting configuration."""
162
+
163
+ def test_default_ratelimit_config(self):
164
+ """Test default values for rate limiting fields."""
165
+ config = ZendeskConfig(subdomain="test", email="user@example.com", token="abc123")
166
+ assert config.proactive_ratelimit is None
167
+ assert config.proactive_ratelimit_request_interval == 10
168
+
169
+ def test_proactive_ratelimit(self):
170
+ """Test setting proactive_ratelimit threshold."""
171
+ config = ZendeskConfig(subdomain="test", email="user@example.com", token="abc123", proactive_ratelimit=50)
172
+ assert config.proactive_ratelimit == 50
173
+
174
+ def test_proactive_ratelimit_with_custom_interval(self):
175
+ """Test setting proactive_ratelimit with custom interval."""
176
+ config = ZendeskConfig(
177
+ subdomain="test",
178
+ email="user@example.com",
179
+ token="abc123",
180
+ proactive_ratelimit=100,
181
+ proactive_ratelimit_request_interval=15,
182
+ )
183
+ assert config.proactive_ratelimit == 100
184
+ assert config.proactive_ratelimit_request_interval == 15
185
+
186
+ def test_proactive_ratelimit_invalid_zero(self):
187
+ """Test that proactive_ratelimit=0 raises ValidationError."""
188
+ with pytest.raises(ValidationError):
189
+ ZendeskConfig(subdomain="test", email="user@example.com", token="abc123", proactive_ratelimit=0)
190
+
191
+ def test_proactive_ratelimit_invalid_negative(self):
192
+ """Test that proactive_ratelimit=-1 raises ValidationError."""
193
+ with pytest.raises(ValidationError):
194
+ ZendeskConfig(subdomain="test", email="user@example.com", token="abc123", proactive_ratelimit=-1)
195
+
196
+ def test_proactive_ratelimit_interval_invalid_zero(self):
197
+ """Test that proactive_ratelimit_request_interval=0 raises ValidationError."""
198
+ with pytest.raises(ValidationError):
199
+ ZendeskConfig(
200
+ subdomain="test",
201
+ email="user@example.com",
202
+ token="abc123",
203
+ proactive_ratelimit_request_interval=0,
204
+ )
205
+
206
+ def test_repr_includes_proactive_ratelimit(self):
207
+ """Test that repr shows proactive_ratelimit when set."""
208
+ config = ZendeskConfig(subdomain="test", email="user@example.com", token="abc123", proactive_ratelimit=50)
209
+ repr_str = repr(config)
210
+ assert "proactive_ratelimit=50" in repr_str
211
+
212
+ def test_repr_excludes_proactive_ratelimit_when_none(self):
213
+ """Test that repr omits proactive_ratelimit when None."""
214
+ config = ZendeskConfig(subdomain="test", email="user@example.com", token="abc123")
215
+ repr_str = repr(config)
216
+ assert "proactive_ratelimit" not in repr_str