Skip to content

Commit 6d0340a

Browse files
Merge pull request #13 from Chinchill-AI/fix/pr-review-comments
fix: Address unresolved PR review comments from #1, #7, #8
2 parents 620506f + 9519a81 commit 6d0340a

4 files changed

Lines changed: 124 additions & 21 deletions

File tree

src/chat_sdk/adapters/slack/adapter.py

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def __init__(self, config: SlackAdapterConfig | None = None) -> None:
200200

201201
# Cache of AsyncWebClient instances keyed by bot token (LRU-bounded)
202202
self._client_cache: OrderedDict[str, Any] = OrderedDict()
203-
self._client_cache_max = 100 # max cached clients
203+
self._client_cache_max = config.client_cache_max or 100
204204

205205
# Multi-workspace OAuth fields
206206
self._client_id: str | None = config.client_id or (os.environ.get("SLACK_CLIENT_ID") if zero_config else None)
@@ -281,14 +281,12 @@ def _get_client(self, token: str | None = None) -> Any:
281281
client = AsyncWebClient(token=resolved_token)
282282
self._client_cache[resolved_token] = client
283283
if len(self._client_cache) > self._client_cache_max:
284-
# Evict oldest (LRU)
285-
evicted_token, evicted_client = self._client_cache.popitem(last=False)
286-
# Close the evicted client's session if possible
287-
try:
288-
if hasattr(evicted_client, "session") and evicted_client.session:
289-
asyncio.get_running_loop().create_task(evicted_client.session.close())
290-
except RuntimeError:
291-
pass
284+
# Evict oldest (LRU). We intentionally do NOT close the evicted
285+
# client's session here because other concurrent requests may still
286+
# hold a reference to the evicted AsyncWebClient instance. The
287+
# underlying aiohttp.ClientSession will be closed by the garbage
288+
# collector (via __del__) once all references are released.
289+
self._client_cache.popitem(last=False)
292290
return client
293291

294292
def _invalidate_client(self, token: str) -> None:
@@ -2688,13 +2686,16 @@ def _handle_slack_error(self, error: Any) -> None:
26882686
26892687
Never returns (always raises).
26902688
"""
2691-
slack_error = error
2692-
resp = getattr(slack_error, "response", None)
2689+
# slack_sdk's SlackApiError has a .response attribute (SlackResponse)
2690+
# SlackResponse has a .data dict and an .get() method
2691+
resp = getattr(error, "response", None)
26932692
error_code: str | None = None
2694-
if isinstance(resp, dict):
2695-
error_code = resp.get("error")
2696-
elif resp is not None:
2697-
error_code = getattr(resp, "get", lambda *a: None)("error")
2693+
if resp is not None:
2694+
# SlackResponse has .data dict or direct attribute access
2695+
if hasattr(resp, "data") and isinstance(resp.data, dict):
2696+
error_code = resp.data.get("error")
2697+
elif isinstance(resp, dict):
2698+
error_code = resp.get("error")
26982699

26992700
# Invalidate cached client on auth errors (token revocation / invalid_auth)
27002701
if error_code in ("invalid_auth", "token_revoked", "account_inactive"):
@@ -2705,8 +2706,13 @@ def _handle_slack_error(self, error: Any) -> None:
27052706
pass
27062707

27072708
# Check for rate limiting
2708-
if isinstance(resp, dict) and error_code == "ratelimited":
2709-
raise AdapterRateLimitError("slack") from error
2709+
if error_code == "ratelimited":
2710+
retry_after = None
2711+
if hasattr(resp, "headers"):
2712+
retry_after = resp.headers.get("Retry-After")
2713+
elif isinstance(resp, dict):
2714+
retry_after = resp.get("headers", {}).get("Retry-After")
2715+
raise AdapterRateLimitError("slack", int(retry_after) if retry_after else None) from error
27102716

27112717
raise error # type: ignore[misc]
27122718

src/chat_sdk/adapters/slack/types.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ class SlackAdapterConfig:
3434
logger: Logger | None = None
3535
# Signing secret for webhook verification. Defaults to SLACK_SIGNING_SECRET env var.
3636
signing_secret: str | None = None
37+
# Maximum number of cached AsyncWebClient instances (LRU-bounded).
38+
# Defaults to 100. Increase for large multi-workspace deployments.
39+
client_cache_max: int | None = None
3740
# Override bot username (optional)
3841
user_name: str | None = None
3942

src/chat_sdk/adapters/teams/adapter.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import asyncio
1112
import base64
1213
import json
1314
import os
@@ -1707,7 +1708,7 @@ async def _verify_bot_framework_token(self, request: Any) -> Any | None:
17071708
return self._make_response("Unauthorized", 401)
17081709
self._jwks_client = PyJWKClient(jwks_uri)
17091710

1710-
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
1711+
signing_key = await asyncio.to_thread(self._jwks_client.get_signing_key_from_jwt, token)
17111712
payload = pyjwt.decode(
17121713
token,
17131714
signing_key.key,

tests/test_slack_client_cache.py

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,25 @@ def test_invalidate_client_removes_entry(self):
151151
# ---------------------------------------------------------------------------
152152

153153

154-
def _make_slack_api_error(error_code: str) -> Exception:
155-
"""Build a mock SlackApiError whose response contains *error_code*."""
154+
def _make_slack_api_error(error_code: str, *, use_slack_response: bool = False, retry_after: str | None = None) -> Exception:
155+
"""Build a mock SlackApiError whose response contains *error_code*.
156+
157+
When *use_slack_response* is True, the response mimics a ``SlackResponse``
158+
object (with ``.data`` dict and ``.headers``) instead of a plain dict.
159+
"""
156160
err = Exception(f"Slack error: {error_code}")
157-
err.response = {"error": error_code} # type: ignore[attr-defined]
161+
if use_slack_response:
162+
163+
class _FakeSlackResponse:
164+
def __init__(self):
165+
self.data = {"error": error_code}
166+
self.headers = {}
167+
if retry_after is not None:
168+
self.headers["Retry-After"] = retry_after
169+
170+
err.response = _FakeSlackResponse() # type: ignore[attr-defined]
171+
else:
172+
err.response = {"error": error_code} # type: ignore[attr-defined]
158173
return err
159174

160175

@@ -193,3 +208,81 @@ def test_handle_slack_error_non_auth_error_keeps_cache(self):
193208
adapter._handle_slack_error(_make_slack_api_error("channel_not_found"))
194209

195210
assert "xoxb-tok" in adapter._client_cache, "Non-auth error should not evict the client"
211+
212+
def test_handle_slack_error_slack_response_object(self):
213+
"""Auth eviction must work when resp is a SlackResponse (not a dict)."""
214+
adapter = _make_adapter(bot_token="xoxb-tok")
215+
adapter._get_client("xoxb-tok")
216+
assert "xoxb-tok" in adapter._client_cache
217+
218+
with pytest.raises(Exception, match="invalid_auth"):
219+
adapter._handle_slack_error(
220+
_make_slack_api_error("invalid_auth", use_slack_response=True)
221+
)
222+
223+
assert "xoxb-tok" not in adapter._client_cache
224+
225+
226+
# ---------------------------------------------------------------------------
227+
# _handle_slack_error — rate limiting
228+
# ---------------------------------------------------------------------------
229+
230+
231+
class TestHandleSlackErrorRateLimit:
232+
"""_handle_slack_error should raise AdapterRateLimitError on ratelimited."""
233+
234+
def test_rate_limit_from_dict_response(self):
235+
"""Rate limit detection with a plain dict response."""
236+
from chat_sdk.shared.errors import AdapterRateLimitError
237+
238+
adapter = _make_adapter(bot_token="xoxb-tok")
239+
with pytest.raises(AdapterRateLimitError):
240+
adapter._handle_slack_error(_make_slack_api_error("ratelimited"))
241+
242+
def test_rate_limit_from_slack_response(self):
243+
"""Rate limit detection with a SlackResponse-like object."""
244+
from chat_sdk.shared.errors import AdapterRateLimitError
245+
246+
adapter = _make_adapter(bot_token="xoxb-tok")
247+
with pytest.raises(AdapterRateLimitError) as exc_info:
248+
adapter._handle_slack_error(
249+
_make_slack_api_error("ratelimited", use_slack_response=True, retry_after="30")
250+
)
251+
assert exc_info.value.retry_after == 30
252+
253+
def test_rate_limit_without_retry_after(self):
254+
"""Rate limit with no Retry-After header should still raise."""
255+
from chat_sdk.shared.errors import AdapterRateLimitError
256+
257+
adapter = _make_adapter(bot_token="xoxb-tok")
258+
with pytest.raises(AdapterRateLimitError) as exc_info:
259+
adapter._handle_slack_error(
260+
_make_slack_api_error("ratelimited", use_slack_response=True)
261+
)
262+
assert exc_info.value.retry_after is None
263+
264+
265+
# ---------------------------------------------------------------------------
266+
# Configurable client_cache_max
267+
# ---------------------------------------------------------------------------
268+
269+
270+
class TestConfigurableCacheMax:
271+
"""client_cache_max should be configurable via SlackAdapterConfig."""
272+
273+
def test_default_cache_max(self):
274+
"""Default cache max should be 100."""
275+
adapter = _make_adapter()
276+
assert adapter._client_cache_max == 100
277+
278+
def test_custom_cache_max(self):
279+
"""Custom cache max should override the default."""
280+
adapter = _make_adapter(client_cache_max=50)
281+
assert adapter._client_cache_max == 50
282+
283+
# Fill to capacity + 1
284+
for i in range(51):
285+
adapter._get_client(f"tok-{i}")
286+
287+
assert len(adapter._client_cache) == 50
288+
assert "tok-0" not in adapter._client_cache

0 commit comments

Comments
 (0)