Skip to content

Commit 3923712

Browse files
feat(slack): expose web_client property on SlackAdapter (#98) (#127)
* feat(slack): expose web_client property on SlackAdapter (#98) Port the Slack adapter's direct WebClient access from upstream vercel/chat (commits 8366b8b / fdebde7 / 2f108bd, PRs #471/#476/#478). - Add ``SlackAdapter.web_client``: a synchronous ``slack_sdk.WebClient`` bound to the current request-context token (multi-workspace) or the configured default token (single-workspace). Token resolution uses the existing 3-level resolver via ``_get_token()``: ContextVar token > static ``bot_token`` config > ``AuthenticationError`` (no ``or`` fallbacks). - Add ``_get_web_client_for_token`` mirroring upstream's ``getClientForToken`` — one cached ``WebClient`` per distinct token. Kept separate from the async ``_client_cache`` (``AsyncWebClient``). ``slack_sdk`` import stays deferred (optional dependency, hazard #10). - Add deprecated ``client`` property alias delegating to ``web_client`` (one-release deprecation; emits ``DeprecationWarning``). Tests (tests/test_slack_web_client.py) mirror upstream's "webClient getter" block: single-tenant binding + per-token caching identity, multi-tenant ContextVar resolution under ``with_bot_token``, no-context and unresolved-async-resolver -> ``AuthenticationError``, and the deprecated alias returning the same object plus emitting the warning. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj * fix(slack): evict web_client cache on invalidation + clarify caching test (review) Two review follow-ups on the web_client port: - Gemini: `_invalidate_client` cleared only the async `_client_cache`, leaving stale/revoked synchronous `WebClient` instances in `_web_client_cache` on token revocation / auth-error eviction. Pop the token from both caches. New `test_invalidate_client_clears_web_client_cache` is load-bearing. - github-code-quality: `assert adapter.web_client is adapter.web_client` tripped "comparison of identical values". The test is a genuine caching check (the property is invoked twice), but binding each access to a name makes that intent explicit and silences the false-positive. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj * fix(slack): invoke sync token resolvers in web_client / _get_token (codex review) The sync ``_get_token`` path only handled the static-string and primed cache cases — a sync ``bot_token`` callable (used e.g. for secret rotation or lazy load from a sync source) raised ``AuthenticationError`` from ``web_client`` outside any webhook / ContextVar scope until an async path had primed the cache. Proactive sends from single-workspace apps using a sync resolver therefore failed. Detect the sync-resolver case via ``inspect.iscoroutinefunction``, invoke the callable, validate the result, and prime ``_default_bot_token_cache`` with the same semantics the async ``_resolve_default_token`` path uses. Async resolvers still raise from the sync property (cannot be awaited). Defensive check for sync callables that *return* a coroutine (rare but real: ``lambda: some_async_fn()``) — refuse to cache the coroutine. The two existing tests that asserted the previous deficient behavior (sync resolver raising before resolution) are updated to assert the new correct behavior; the cache-refresh regression test switches to an async resolver so its sanity precondition still holds. * fix(slack): close orphan coroutine before raising awaitable-resolver error (audit) The defensive `inspect.isawaitable(resolved)` branch in `_get_token`'s sync-callable handler raised AuthenticationError but never closed the coroutine the resolver returned. Callers saw a noisy `RuntimeWarning: coroutine was never awaited` on every triggering call. Close the awaitable via its `close()` method (Coroutine protocol) before raising. The existing regression test `test_sync_callable_returning_coroutine_raises` is strengthened to capture warnings and assert none of "never awaited" kind leaked — confirmed load-bearing under `pytest -W error::RuntimeWarning`. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj * fix(slack): honor bot_token rotation contract in sync _get_token + invalidation Addresses two P2 review findings on PR #127: **Codex P2 — sync resolver rotation broken** The previous sync-callable branch in ``_get_token`` cached the first resolved value in ``_default_bot_token_cache`` and the cache-first early-return prevented re-invocation, freezing rotating resolvers (e.g., secret-manager-backed). The contract on ``SlackAdapterConfig.bot_token`` says callable resolvers are "called on each use to support rotation." Track ``_is_dynamic_bot_token`` at construction time. In ``_get_token``, sync dynamic resolvers now invoke fresh on every call and never write the process-wide cache. Static-string configs keep their cache fast path (nothing to rotate). Async resolvers still require a webhook / ``current_token_async`` entry to be awaited. The previously-added test ``test_sync_current_token_with_sync_resolver_invokes_resolver`` asserted the cache was primed — flipped to assert the inverse, with a cross-reference to the dedicated rotation pin in ``test_sync_callable_invoked_fresh_each_access``. **CodeRabbit P2 — _invalidate_client retained revoked tokens** ``_invalidate_client(token)`` evicted the WebClient and AsyncWebClient caches but left ``_default_bot_token_cache`` / ``_resolved_default_token`` holding the revoked value, so the next ``_get_token`` returned the same token and the adapter just rebuilt clients around it. Now clears the resolved-token caches for dynamic-resolver configs so the next access re-invokes the resolver. Guarded on ``_is_dynamic_bot_token`` so static-string configs retain their cache (no refresh path — clearing would only make subsequent sync access raise with no way to recover). Tests: rewrote the caching test to assert rotation (resolver invoked fresh on every access, cache stays None); added invalidation tests covering dynamic-resolver clearing, ContextVar clearing, static-string no-op, and token-mismatch no-op. Full suite green (4067 passed) under ``-W error::RuntimeWarning``. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj * chore(tests): address CodeRabbit duplicate-test + iter-StopIteration findings CodeRabbit review on the latest #127 HEAD surfaced two test-quality issues: 1. ``TestWebClientAsyncResolver.test_unresolved_async_resolver_raises`` duplicated ``TestWebClientSyncResolver.test_async_callable_in_sync_context_raises`` (same async-resolver-in-sync-context path, but the latter is stronger — it also validates the error message wording). Removed the redundant wrapper class to honor this repo's "no duplicate tests" CLAUDE.md rule. 2. Rotation pin used ``tokens = iter(["xoxb-sync-1", ...])`` which would ``StopIteration`` if the test grew to a 4th access. Switched to ``f"xoxb-sync-{calls['n']}"`` so the resolver scales with call count; existing assertions on the literal values still hold. 68 tests still pass under ``pytest -W error::RuntimeWarning``. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ac6c6f4 commit 3923712

3 files changed

Lines changed: 625 additions & 28 deletions

File tree

src/chat_sdk/adapters/slack/adapter.py

Lines changed: 153 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import os
2020
import re
2121
import time
22+
import warnings
2223
from collections import OrderedDict
2324
from collections.abc import AsyncIterable, Awaitable, Callable
2425
from contextvars import ContextVar
@@ -410,6 +411,12 @@ def __init__(self, config: SlackAdapterConfig | None = None) -> None:
410411
# on each subsequent webhook entry. Static-string configs prime this at
411412
# construction time so sync access works before any webhook fires.
412413
self._default_bot_token_cache: str | None = bot_token_config if isinstance(bot_token_config, str) else None
414+
# True when the user passed a callable ``bot_token`` (sync or async).
415+
# The config contract says callable resolvers are invoked on each use
416+
# to support rotation, so sync access goes through a fresh-invoke
417+
# branch instead of reading ``_default_bot_token_cache``. Static
418+
# strings have nothing to rotate and stay on the cached fast path.
419+
self._is_dynamic_bot_token: bool = callable(bot_token_config)
413420

414421
# ------------------------------------------------------------------
415422
# Socket mode wiring (PR #86). Resolved AFTER the bot-token resolver
@@ -481,6 +488,16 @@ def __init__(self, config: SlackAdapterConfig | None = None) -> None:
481488
self._client_cache: OrderedDict[str, Any] = OrderedDict()
482489
self._client_cache_max = config.client_cache_max if config.client_cache_max is not None else 100
483490

491+
# Cache of synchronous slack_sdk.WebClient instances keyed by bot
492+
# token, backing the public ``web_client`` property (the direct port
493+
# of upstream's ``getClientForToken``). Kept separate from
494+
# ``_client_cache`` because that one holds async ``AsyncWebClient``
495+
# instances used by the adapter's own API calls; the two client types
496+
# are not interchangeable. Mirrors upstream's plain (unbounded) Map —
497+
# one entry per distinct token — since callers reach for this escape
498+
# hatch rarely and tokens are low-cardinality.
499+
self._web_client_cache: dict[str, Any] = {}
500+
484501
# Multi-workspace OAuth fields.
485502
# ``is not None`` (not truthiness) so an explicit empty-string user
486503
# config does not silently fall back to env (hazard #1). Empty env
@@ -608,6 +625,61 @@ def current_client(self) -> Any:
608625
"""
609626
return self._get_client()
610627

628+
@property
629+
def web_client(self) -> Any:
630+
"""Direct access to a synchronous ``slack_sdk.WebClient``.
631+
632+
Bound to the bot token for the current request context
633+
(multi-workspace) or the configured default token
634+
(single-workspace). Use for any Slack Web API call not covered by
635+
the adapter's high-level methods — e.g.
636+
``adapter.web_client.pins_add(...)`` or
637+
``adapter.web_client.usergroups_list(...)``.
638+
639+
Resolution order (the standard 3-level resolver):
640+
641+
1. Token from the current request context (set during webhook
642+
handling, or by :meth:`with_bot_token` / :meth:`with_bot_token_async`).
643+
2. The default bot token, when configured as a static string or
644+
already-resolved value.
645+
3. Otherwise raise :class:`AuthenticationError`.
646+
647+
Raises :class:`AuthenticationError` if neither is available —
648+
typical causes are accessing ``web_client`` outside any
649+
webhook / :meth:`with_bot_token` context in multi-workspace mode,
650+
or having configured ``bot_token`` as an async resolver that has
651+
not run yet. In the latter case await
652+
:meth:`current_token_async` (or process the work inside the
653+
webhook flow) so the resolver primes the token first.
654+
655+
Return type is ``Any`` (rather than the concrete ``WebClient``)
656+
because ``slack_sdk`` is an optional dependency — consumers who do
657+
not install the ``slack`` extra should not pay an import cost.
658+
659+
This is the direct port of upstream's ``adapter.webClient`` getter
660+
(vercel/chat ``2f108bd``). Unlike :attr:`current_client` it returns
661+
the *synchronous* ``WebClient`` (the analog of the single TS
662+
``WebClient``), so its methods are not awaitables.
663+
"""
664+
return self._get_web_client_for_token(self._get_token())
665+
666+
@property
667+
def client(self) -> Any:
668+
"""Deprecated alias for :attr:`web_client`.
669+
670+
.. deprecated::
671+
Use :attr:`web_client` instead. This alias mirrors upstream's
672+
pre-rename ``adapter.client`` (vercel/chat ``8366b8b``) and is
673+
kept for one release for backwards compatibility; it will be
674+
removed in a future version. Emits :class:`DeprecationWarning`.
675+
"""
676+
warnings.warn(
677+
"SlackAdapter.client is deprecated; use SlackAdapter.web_client instead.",
678+
DeprecationWarning,
679+
stacklevel=2,
680+
)
681+
return self.web_client
682+
611683
# ------------------------------------------------------------------
612684
# Token management
613685
# ------------------------------------------------------------------
@@ -620,34 +692,65 @@ def _get_token(self) -> str:
620692
1. Multi-workspace request context (``_request_context``)
621693
2. Per-request resolved default token (``_resolved_default_token``)
622694
— primed by ``handle_webhook`` after invoking the resolver
623-
3. Static default token cache (set by the constructor for
624-
string-typed ``bot_token`` configs)
625-
4. Raises :class:`AuthenticationError`
626-
627-
Synchronous: the token resolver is *not* invoked here. Webhook entry
628-
paths call :meth:`_resolve_default_token` first so the per-request
629-
cache is primed; static-string ``bot_token`` configs prime the
630-
process-wide cache at construction time so this is a no-op for the
631-
common case. Use :meth:`_resolve_token_async` from new async call
632-
sites that need to invoke a resolver outside webhook flow (e.g.
633-
cron jobs).
695+
3. Sync dynamic resolver — invoked **fresh on every call** to honor
696+
the rotation contract in :attr:`SlackAdapterConfig.bot_token`
697+
("called on each use to support rotation")
698+
4. Static default token cache (set by the constructor for
699+
string-typed ``bot_token`` configs, or by the async path after
700+
``_resolve_default_token`` runs)
701+
5. Raises :class:`AuthenticationError`
702+
703+
Async resolvers cannot be awaited from a sync context — sync access
704+
outside a webhook scope falls back to the process-wide cache, or
705+
raises if the async path has not run yet. Use
706+
:meth:`current_token_async` or enter via :meth:`handle_webhook` so
707+
the resolver runs first.
634708
"""
635709
ctx = self._request_context.get()
636710
if ctx and ctx.token:
637711
return ctx.token
638712
per_request = self._resolved_default_token.get()
639713
if per_request is not None:
640714
return per_request
715+
# Sync dynamic resolver: invoke fresh every call to honor rotation.
716+
# Static strings (no rotation possible) and async resolvers (which
717+
# need a webhook entry to be awaited) fall through to the cache.
718+
provider = self._default_bot_token_provider
719+
if self._is_dynamic_bot_token and provider is not None and not inspect.iscoroutinefunction(provider):
720+
resolved = provider()
721+
# Defensive: a "sync" callable may still *return* a coroutine
722+
# (e.g. ``lambda: some_async_fn()``) and ``iscoroutinefunction``
723+
# would not catch that. Refuse to use such a value in a sync
724+
# context — and close the awaitable to suppress the
725+
# ``coroutine was never awaited`` RuntimeWarning before raising.
726+
if inspect.isawaitable(resolved):
727+
close = getattr(resolved, "close", None)
728+
if callable(close):
729+
close()
730+
raise AuthenticationError(
731+
"slack",
732+
"Bot token resolver returned an awaitable in a sync "
733+
"context. Use the async API (handle_webhook / "
734+
"current_token_async) so the resolver can be awaited.",
735+
)
736+
if not isinstance(resolved, str) or not resolved:
737+
raise AuthenticationError(
738+
"slack",
739+
"Bot token resolver returned an empty or non-string value.",
740+
)
741+
# Intentionally do NOT write ``_default_bot_token_cache``: caching
742+
# would break the rotation contract for the next sync access.
743+
return resolved
641744
if self._default_bot_token_cache is not None:
642745
return self._default_bot_token_cache
643-
if self._default_bot_token_provider is not None:
644-
# Resolver-based default token configured but never resolved. This
645-
# is a programming error: the async entry path should have awaited
646-
# ``_resolve_default_token()`` before reaching here.
746+
if provider is not None:
747+
# Async resolver configured but never awaited. ``handle_webhook``
748+
# or ``current_token_async`` must run first to prime the cache.
647749
raise AuthenticationError(
648750
"slack",
649-
"Bot token resolver has not been invoked yet. Use the async API "
650-
"(handle_webhook / current_token_async) so the resolver runs first.",
751+
"Async bot token resolver has not been invoked yet. Use the "
752+
"async API (handle_webhook / current_token_async) so the "
753+
"resolver runs first.",
651754
)
652755
raise AuthenticationError(
653756
"slack",
@@ -758,8 +861,40 @@ def _get_client(self, token: str | None = None) -> Any:
758861
return client
759862

760863
def _invalidate_client(self, token: str) -> None:
761-
"""Remove a cached client (e.g., on token revocation)."""
864+
"""Remove a cached client (e.g., on token revocation).
865+
866+
For dynamic-resolver configs also clears the resolved-token caches
867+
so the next access re-invokes the resolver instead of serving the
868+
revoked token. Static-string configs intentionally retain their
869+
cache: there is no refresh path, so clearing would just make every
870+
subsequent sync access raise.
871+
"""
762872
self._client_cache.pop(token, None)
873+
self._web_client_cache.pop(token, None)
874+
if self._is_dynamic_bot_token:
875+
if self._default_bot_token_cache == token:
876+
self._default_bot_token_cache = None
877+
if self._resolved_default_token.get() == token:
878+
self._resolved_default_token.set(None)
879+
880+
def _get_web_client_for_token(self, token: str) -> Any:
881+
"""Return a synchronous ``slack_sdk.WebClient`` for *token*, cached.
882+
883+
Backs the public :attr:`web_client` property and is the direct port
884+
of upstream's ``getClientForToken`` (vercel/chat ``2f108bd``): one
885+
cached ``WebClient`` instance per distinct token. The import is
886+
deferred so ``slack_sdk`` stays an optional dependency (hazard #10).
887+
888+
Distinct from :meth:`_get_client`, which caches the *async*
889+
``AsyncWebClient`` used by the adapter's own API calls.
890+
"""
891+
client = self._web_client_cache.get(token)
892+
if client is None:
893+
from slack_sdk import WebClient
894+
895+
client = WebClient(token=token)
896+
self._web_client_cache[token] = client
897+
return client
763898

764899
# ------------------------------------------------------------------
765900
# Initialization

tests/test_slack_dynamic_token_and_verifier.py

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -181,18 +181,41 @@ def resolver() -> str:
181181
assert await adapter.current_token_async() == "xoxb-token-3"
182182
assert i[0] == 3
183183

184-
def test_sync_current_token_with_resolver_before_resolution_raises(self):
185-
"""Sync ``current_token`` access before the resolver has run raises a clear error."""
184+
def test_sync_current_token_with_sync_resolver_invokes_resolver(self):
185+
"""Sync ``current_token`` invokes a sync resolver directly (Codex P2 fix).
186+
187+
Previously the sync path raised ``AuthenticationError`` for sync
188+
resolvers too — single-workspace apps using a sync resolver for
189+
secret rotation could not read ``current_token`` / ``web_client``
190+
outside a webhook context until an async path had primed the cache.
191+
``_get_token`` now invokes the sync resolver directly — fresh on
192+
every call, to honor the rotation contract on
193+
:attr:`SlackAdapterConfig.bot_token`. The
194+
async-resolver-in-sync-context case still raises (see
195+
``test_sync_current_token_with_async_resolver_raises`` below).
196+
"""
186197

187198
def resolver() -> str:
188199
return "xoxb-resolved"
189200

190201
adapter = SlackAdapter(SlackAdapterConfig(signing_secret="s", bot_token=resolver))
191-
# Tightened: error message must mention ``current_token_async`` so
192-
# callers know the right async accessor to use, not just that "the
193-
# resolver hasn't run". Substring check on "current_token_async"
194-
# is escaped via ``re.escape`` so the underscore isn't treated as a
195-
# regex token (it isn't, but be explicit).
202+
assert adapter.current_token == "xoxb-resolved"
203+
# Dynamic resolvers must NOT prime ``_default_bot_token_cache``;
204+
# caching would suppress subsequent resolver calls and break the
205+
# rotation contract. Rotation behavior is pinned by
206+
# ``test_sync_callable_invoked_fresh_each_access`` in
207+
# tests/test_slack_web_client.py.
208+
assert adapter._default_bot_token_cache is None
209+
210+
def test_sync_current_token_with_async_resolver_raises(self):
211+
"""Async resolvers still cannot be awaited from the sync property."""
212+
213+
async def resolver() -> str:
214+
return "xoxb-async-resolved"
215+
216+
adapter = SlackAdapter(SlackAdapterConfig(signing_secret="s", bot_token=resolver))
217+
# Error message must point at the async entry point so callers know
218+
# the right accessor to use.
196219
with pytest.raises(AuthenticationError, match=r"current_token_async"):
197220
_ = adapter.current_token
198221

@@ -316,20 +339,26 @@ async def test_resolver_refreshes_sync_token_cache(self):
316339
refresh the process-wide ``_default_bot_token_cache`` so the sync
317340
path returns the freshly resolved value.
318341
342+
Uses an *async* resolver so the sanity precondition (sync access
343+
before any resolution raises) still holds — sync resolvers now
344+
resolve directly on the sync path (Codex P2 fix), so the regression
345+
scenario this test guards is specifically the async path priming the
346+
sync cache.
347+
319348
What to fix if this fails: in ``_resolve_default_token``
320349
(``adapters/slack/adapter.py``), after the
321350
``isinstance(token, str)`` / non-empty validation and before
322351
``self._resolved_default_token.set(token)``, assign
323352
``self._default_bot_token_cache = token``.
324353
"""
325354

326-
def resolver() -> str:
355+
async def resolver() -> str:
327356
return "xoxb-resolved-token"
328357

329358
adapter = SlackAdapter(SlackAdapterConfig(signing_secret="s", bot_token=resolver))
330359

331-
# Sanity: before the resolver runs, sync ``current_token`` must raise
332-
# (matches ``test_sync_current_token_with_resolver_before_resolution_raises``).
360+
# Sanity: before the async resolver runs, sync ``current_token`` must
361+
# raise — async resolvers cannot be awaited from the sync property.
333362
with pytest.raises(AuthenticationError, match=r"current_token_async"):
334363
_ = adapter.current_token
335364

0 commit comments

Comments
 (0)