Skip to content

Commit 167d7a3

Browse files
Merge pull request #8 from Chinchill-AI/fix/slack-client-cache-bugs
fix: Slack client cache — LRU bound, empty-token, revocation eviction
2 parents 896fe58 + e5f49c3 commit 167d7a3

3 files changed

Lines changed: 43 additions & 16 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,4 @@ jobs:
2424
- name: Format check
2525
run: uv run ruff format --check src/
2626
- name: Run tests
27-
run: uv run pytest tests/ -q --tb=short
27+
run: uv run pytest tests/ -q --tb=short --cov=chat_sdk --cov-fail-under=50

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,6 @@ dev = [
6969
"cryptography>=42.0",
7070
"pytest>=8.0",
7171
"pytest-asyncio>=0.23.0",
72+
"pytest-cov>=5.0",
7273
"ruff>=0.4.0",
7374
]

src/chat_sdk/adapters/slack/adapter.py

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import os
1717
import re
1818
import time
19+
from collections import OrderedDict
1920
from collections.abc import AsyncIterable, Awaitable, Callable
2021
from contextvars import ContextVar
2122
from datetime import UTC, datetime
@@ -197,8 +198,9 @@ def __init__(self, config: SlackAdapterConfig | None = None) -> None:
197198
# Channel external/shared cache
198199
self._external_channels: set[str] = set()
199200

200-
# Cache of AsyncWebClient instances keyed by bot token
201-
self._client_cache: dict[str, Any] = {}
201+
# Cache of AsyncWebClient instances keyed by bot token (LRU-bounded)
202+
self._client_cache: OrderedDict[str, Any] = OrderedDict()
203+
self._client_cache_max = 100 # max cached clients
202204

203205
# Multi-workspace OAuth fields
204206
self._client_id: str | None = config.client_id or (os.environ.get("SLACK_CLIENT_ID") if zero_config else None)
@@ -264,18 +266,35 @@ def _get_client(self, token: str | None = None) -> Any:
264266
Clients are cached by token so we avoid creating a new instance on
265267
every request. The import is deferred so that ``slack_sdk`` is only
266268
required at call-time.
269+
270+
When *token* is explicitly passed (even as ``""``) it is used as-is;
271+
only when *token* is ``None`` do we fall back to ``_get_token()``.
267272
"""
268-
resolved_token = token or self._get_token()
269-
cached = self._client_cache.get(resolved_token)
270-
if cached is not None:
271-
return cached
273+
resolved_token = self._get_token() if token is None else token
274+
275+
if resolved_token in self._client_cache:
276+
self._client_cache.move_to_end(resolved_token)
277+
return self._client_cache[resolved_token]
272278

273279
from slack_sdk.web.async_client import AsyncWebClient
274280

275281
client = AsyncWebClient(token=resolved_token)
276282
self._client_cache[resolved_token] = client
283+
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
277292
return client
278293

294+
def _invalidate_client(self, token: str) -> None:
295+
"""Remove a cached client (e.g., on token revocation)."""
296+
self._client_cache.pop(token, None)
297+
279298
# ------------------------------------------------------------------
280299
# Initialization
281300
# ------------------------------------------------------------------
@@ -2670,17 +2689,24 @@ def _handle_slack_error(self, error: Any) -> None:
26702689
Never returns (always raises).
26712690
"""
26722691
slack_error = error
2673-
code = getattr(slack_error, "response", {})
2674-
if isinstance(code, dict):
2675-
code.get("error")
2676-
else:
2677-
getattr(getattr(slack_error, "response", None), "get", lambda *a: None)("error")
2692+
resp = getattr(slack_error, "response", None)
2693+
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")
2698+
2699+
# Invalidate cached client on auth errors (token revocation / invalid_auth)
2700+
if error_code in ("invalid_auth", "token_revoked", "account_inactive"):
2701+
try:
2702+
token = self._get_token()
2703+
self._invalidate_client(token)
2704+
except AuthenticationError:
2705+
pass
26782706

26792707
# Check for rate limiting
2680-
if hasattr(slack_error, "response"):
2681-
resp = slack_error.response
2682-
if isinstance(resp, dict) and resp.get("error") == "ratelimited":
2683-
raise AdapterRateLimitError("slack") from error
2708+
if isinstance(resp, dict) and error_code == "ratelimited":
2709+
raise AdapterRateLimitError("slack") from error
26842710

26852711
raise error # type: ignore[misc]
26862712

0 commit comments

Comments
 (0)