-
Notifications
You must be signed in to change notification settings - Fork 1
fix: Slack client cache — LRU bound, empty-token, revocation eviction #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -69,5 +69,6 @@ dev = [ | |
| "cryptography>=42.0", | ||
| "pytest>=8.0", | ||
| "pytest-asyncio>=0.23.0", | ||
| "pytest-cov>=5.0", | ||
| "ruff>=0.4.0", | ||
| ] | ||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -16,6 +16,7 @@ | |||||||||||
| import os | ||||||||||||
| import re | ||||||||||||
| import time | ||||||||||||
| from collections import OrderedDict | ||||||||||||
| from collections.abc import AsyncIterable, Awaitable, Callable | ||||||||||||
| from contextvars import ContextVar | ||||||||||||
| from datetime import UTC, datetime | ||||||||||||
|
|
@@ -197,8 +198,9 @@ def __init__(self, config: SlackAdapterConfig | None = None) -> None: | |||||||||||
| # Channel external/shared cache | ||||||||||||
| self._external_channels: set[str] = set() | ||||||||||||
|
|
||||||||||||
| # Cache of AsyncWebClient instances keyed by bot token | ||||||||||||
| self._client_cache: dict[str, Any] = {} | ||||||||||||
| # Cache of AsyncWebClient instances keyed by bot token (LRU-bounded) | ||||||||||||
| self._client_cache: OrderedDict[str, Any] = OrderedDict() | ||||||||||||
| self._client_cache_max = 100 # max cached clients | ||||||||||||
|
|
||||||||||||
| # Multi-workspace OAuth fields | ||||||||||||
| 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: | |||||||||||
| Clients are cached by token so we avoid creating a new instance on | ||||||||||||
| every request. The import is deferred so that ``slack_sdk`` is only | ||||||||||||
| required at call-time. | ||||||||||||
|
|
||||||||||||
| When *token* is explicitly passed (even as ``""``) it is used as-is; | ||||||||||||
| only when *token* is ``None`` do we fall back to ``_get_token()``. | ||||||||||||
| """ | ||||||||||||
| resolved_token = token or self._get_token() | ||||||||||||
| cached = self._client_cache.get(resolved_token) | ||||||||||||
| if cached is not None: | ||||||||||||
| return cached | ||||||||||||
| resolved_token = self._get_token() if token is None else token | ||||||||||||
|
|
||||||||||||
| if resolved_token in self._client_cache: | ||||||||||||
| self._client_cache.move_to_end(resolved_token) | ||||||||||||
| return self._client_cache[resolved_token] | ||||||||||||
|
|
||||||||||||
| from slack_sdk.web.async_client import AsyncWebClient | ||||||||||||
|
|
||||||||||||
| client = AsyncWebClient(token=resolved_token) | ||||||||||||
| self._client_cache[resolved_token] = client | ||||||||||||
| if len(self._client_cache) > self._client_cache_max: | ||||||||||||
| # Evict oldest (LRU) | ||||||||||||
| evicted_token, evicted_client = self._client_cache.popitem(last=False) | ||||||||||||
| # Close the evicted client's session if possible | ||||||||||||
| try: | ||||||||||||
| if hasattr(evicted_client, "session") and evicted_client.session: | ||||||||||||
| asyncio.get_running_loop().create_task(evicted_client.session.close()) | ||||||||||||
| except RuntimeError: | ||||||||||||
| pass | ||||||||||||
|
Comment on lines
+286
to
+291
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Closing the session of an evicted client using A safer approach would be to share a single
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in PR #13 -- removed the |
||||||||||||
| return client | ||||||||||||
|
|
||||||||||||
| def _invalidate_client(self, token: str) -> None: | ||||||||||||
| """Remove a cached client (e.g., on token revocation).""" | ||||||||||||
| self._client_cache.pop(token, None) | ||||||||||||
|
|
||||||||||||
| # ------------------------------------------------------------------ | ||||||||||||
| # Initialization | ||||||||||||
| # ------------------------------------------------------------------ | ||||||||||||
|
|
@@ -2670,17 +2689,24 @@ def _handle_slack_error(self, error: Any) -> None: | |||||||||||
| Never returns (always raises). | ||||||||||||
| """ | ||||||||||||
| slack_error = error | ||||||||||||
| code = getattr(slack_error, "response", {}) | ||||||||||||
| if isinstance(code, dict): | ||||||||||||
| code.get("error") | ||||||||||||
| else: | ||||||||||||
| getattr(getattr(slack_error, "response", None), "get", lambda *a: None)("error") | ||||||||||||
| resp = getattr(slack_error, "response", None) | ||||||||||||
| error_code: str | None = None | ||||||||||||
| if isinstance(resp, dict): | ||||||||||||
| error_code = resp.get("error") | ||||||||||||
| elif resp is not None: | ||||||||||||
| error_code = getattr(resp, "get", lambda *a: None)("error") | ||||||||||||
|
|
||||||||||||
| # Invalidate cached client on auth errors (token revocation / invalid_auth) | ||||||||||||
| if error_code in ("invalid_auth", "token_revoked", "account_inactive"): | ||||||||||||
| try: | ||||||||||||
| token = self._get_token() | ||||||||||||
| self._invalidate_client(token) | ||||||||||||
| except AuthenticationError: | ||||||||||||
| pass | ||||||||||||
|
|
||||||||||||
| # Check for rate limiting | ||||||||||||
| if hasattr(slack_error, "response"): | ||||||||||||
| resp = slack_error.response | ||||||||||||
| if isinstance(resp, dict) and resp.get("error") == "ratelimited": | ||||||||||||
| raise AdapterRateLimitError("slack") from error | ||||||||||||
| if isinstance(resp, dict) and error_code == "ratelimited": | ||||||||||||
| raise AdapterRateLimitError("slack") from error | ||||||||||||
|
Comment on lines
+2708
to
+2709
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The rate limit check is currently broken for standard Slack API errors. When using
Suggested change
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in PR #13 -- |
||||||||||||
|
|
||||||||||||
| raise error # type: ignore[misc] | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The maximum cache size for Slack clients is hardcoded to 100. In large-scale multi-workspace deployments, this limit might be reached frequently, leading to cache churn and unnecessary session recreations. Consider making this value configurable via
SlackAdapterConfigto allow users to tune it based on their needs.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in PR #13 -- added
client_cache_max: int | NonetoSlackAdapterConfig(defaults to 100). Users can now tune the LRU cache size for their deployment scale.