Skip to content

Commit abdaad2

Browse files
committed
zendesk rate limiting
1 parent f8e9060 commit abdaad2

File tree

3 files changed

+128
-6
lines changed

3 files changed

+128
-6
lines changed

backend/onyx/connectors/zendesk/connector.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import copy
22
import time
3+
from collections.abc import Callable
34
from collections.abc import Iterator
45
from typing import Any
56
from typing import cast
@@ -14,6 +15,9 @@
1415
from onyx.connectors.cross_connector_utils.miscellaneous_utils import (
1516
time_str_to_utc,
1617
)
18+
from onyx.connectors.cross_connector_utils.rate_limit_wrapper import (
19+
rate_limit_builder,
20+
)
1721
from onyx.connectors.exceptions import ConnectorValidationError
1822
from onyx.connectors.exceptions import CredentialExpiredError
1923
from onyx.connectors.exceptions import InsufficientPermissionsError
@@ -47,14 +51,30 @@ def __init__(self) -> None:
4751

4852

4953
class ZendeskClient:
50-
def __init__(self, subdomain: str, email: str, token: str):
54+
def __init__(
55+
self,
56+
subdomain: str,
57+
email: str,
58+
token: str,
59+
calls_per_minute: int | None = None,
60+
):
5161
self.base_url = f"https://{subdomain}.zendesk.com/api/v2"
5262
self.auth = (f"{email}/token", token)
63+
self.make_request = request_with_rate_limit(self, calls_per_minute)
5364

65+
66+
def request_with_rate_limit(
67+
client: ZendeskClient, max_calls_per_minute: int | None = None
68+
) -> Callable[[str, dict[str, Any]], dict[str, Any]]:
5469
@retry_builder()
55-
def make_request(self, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
70+
@(
71+
rate_limit_builder(max_calls=max_calls_per_minute, period=60)
72+
if max_calls_per_minute
73+
else lambda x: x
74+
)
75+
def make_request(endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
5676
response = requests.get(
57-
f"{self.base_url}/{endpoint}", auth=self.auth, params=params
77+
f"{client.base_url}/{endpoint}", auth=client.auth, params=params
5878
)
5979

6080
if response.status_code == 429:
@@ -72,6 +92,8 @@ def make_request(self, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
7292
response.raise_for_status()
7393
return response.json()
7494

95+
return make_request
96+
7597

7698
class ZendeskPageResponse(BaseModel):
7799
data: list[dict[str, Any]]
@@ -359,11 +381,13 @@ class ZendeskConnector(
359381
def __init__(
360382
self,
361383
content_type: str = "articles",
384+
calls_per_minute: int | None = None,
362385
) -> None:
363386
self.content_type = content_type
364387
self.subdomain = ""
365388
# Fetch all tags ahead of time
366389
self.content_tags: dict[str, str] = {}
390+
self.calls_per_minute = calls_per_minute
367391

368392
def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
369393
# Subdomain is actually the whole URL
@@ -375,7 +399,10 @@ def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None
375399
self.subdomain = subdomain
376400

377401
self.client = ZendeskClient(
378-
subdomain, credentials["zendesk_email"], credentials["zendesk_token"]
402+
subdomain,
403+
credentials["zendesk_email"],
404+
credentials["zendesk_token"],
405+
calls_per_minute=self.calls_per_minute,
379406
)
380407
return None
381408

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from __future__ import annotations
2+
3+
import types
4+
from typing import Any
5+
from typing import Dict
6+
7+
import pytest
8+
9+
10+
class _FakeTime:
11+
"""A controllable time module replacement.
12+
13+
- monotonic(): returns an internal counter (seconds)
14+
- sleep(x): advances the internal counter by x seconds
15+
"""
16+
17+
def __init__(self) -> None:
18+
self._t = 0.0
19+
20+
def monotonic(self) -> float: # type: ignore[override]
21+
return self._t
22+
23+
def sleep(self, seconds: float) -> None: # type: ignore[override]
24+
# advance time without real waiting
25+
self._t += float(seconds)
26+
27+
28+
class _FakeResponse:
29+
def __init__(self, json_payload: Dict[str, Any], status_code: int = 200) -> None:
30+
self._json = json_payload
31+
self.status_code = status_code
32+
self.headers: Dict[str, str] = {}
33+
34+
def json(self) -> Dict[str, Any]:
35+
return self._json
36+
37+
def raise_for_status(self) -> None:
38+
# simulate OK
39+
return None
40+
41+
42+
@pytest.mark.unit
43+
def test_zendesk_client_per_minute_rate_limiting(
44+
monkeypatch: pytest.MonkeyPatch,
45+
) -> None:
46+
# Import here to allow monkeypatching modules safely
47+
from onyx.connectors.zendesk.connector import ZendeskClient
48+
import onyx.connectors.cross_connector_utils.rate_limit_wrapper as rlw
49+
import onyx.connectors.zendesk.connector as zendesk_mod
50+
51+
fake_time = _FakeTime()
52+
53+
# Patch time in both the rate limit wrapper and the zendesk connector module
54+
monkeypatch.setattr(rlw, "time", fake_time, raising=True)
55+
monkeypatch.setattr(zendesk_mod, "time", fake_time, raising=True)
56+
57+
# Stub out requests.get to avoid network and return a minimal valid payload
58+
calls: list[str] = []
59+
60+
def _fake_get(url: str, auth: Any, params: Dict[str, Any]) -> _FakeResponse:
61+
calls.append(url)
62+
# minimal Zendesk list response (articles path)
63+
return _FakeResponse({"articles": [], "meta": {"has_more": False}})
64+
65+
monkeypatch.setattr(
66+
zendesk_mod, "requests", types.SimpleNamespace(get=_fake_get), raising=True
67+
)
68+
69+
# Build client with a small limit: 2 calls per 60 seconds
70+
client = ZendeskClient("subd", "e", "t", calls_per_minute=2)
71+
72+
# Make three calls in quick succession. The third should be rate limited
73+
client.make_request("help_center/articles", {"page[size]": 1})
74+
client.make_request("help_center/articles", {"page[size]": 1})
75+
76+
# At this point we've used up the 2 allowed calls within the 60s window
77+
# The next call should trigger sleeps with exponential backoff until >60s elapsed
78+
client.make_request("help_center/articles", {"page[size]": 1})
79+
80+
# Ensure we did not actually wait in real time but logically advanced beyond a minute
81+
assert fake_time.monotonic() >= 60
82+
# Ensure the HTTP function was invoked three times
83+
assert len(calls) == 3

web/src/lib/connectors/connectors.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,7 +1071,16 @@ For example, specifying .*-support.* as a "channel" will cause the connector to
10711071
default: "articles",
10721072
},
10731073
],
1074-
advanced_values: [],
1074+
advanced_values: [
1075+
{
1076+
type: "number",
1077+
label: "API Calls per Minute",
1078+
name: "calls_per_minute",
1079+
optional: true,
1080+
description:
1081+
"Restricts how many Zendesk API calls this connector can make per minute (applies only to this connector). See defaults: https://developer.zendesk.com/api-reference/introduction/rate-limits/",
1082+
},
1083+
],
10751084
},
10761085
linear: {
10771086
description: "Configure Linear connector",
@@ -1770,7 +1779,10 @@ export interface XenforoConfig {
17701779
base_url: string;
17711780
}
17721781

1773-
export interface ZendeskConfig {}
1782+
export interface ZendeskConfig {
1783+
content_type?: "articles" | "tickets";
1784+
calls_per_minute?: number;
1785+
}
17741786

17751787
export interface DropboxConfig {}
17761788

0 commit comments

Comments
 (0)