Skip to content

Accept a redis-py credential provider in place of URL credentials - #484

Open
uesleilima wants to merge 8 commits into
chrisguidry:mainfrom
uesleilima:credential-provider
Open

uesleilima wants to merge 8 commits into
chrisguidry:mainfrom
uesleilima:credential-provider

Conversation

@uesleilima

Copy link
Copy Markdown

Closes #483

Adds an optional credential_provider (a redis-py CredentialProvider) to Docket, StrikeList and RedisConnection. It replaces URL credentials for rotating tokens such as Azure Entra ID via redis-entraid.

  • RedisConnection passes it to every pool it builds: standalone, pub/sub, Sentinel data nodes, RedisCluster, and the cluster pub/sub node pool.
  • It also passes it to every Redis(connection_pool=...) client that wraps those pools, including the result store's. redis-py only registers re-authentication of pooled connections for a StreamingCredentialProvider when the client is given the provider, and without that Docket's long-lived blocking-read and pub/sub connections would be dropped when a token expires.
  • Docket hands it to its StrikeList.
  • Without a provider nothing changes. The key is only passed when one is set, and the cluster node pool keeps using the URL's username and password.
  • Docs: a "Credential providers" subsection under Authentication in docs/production.md.
  • loq.toml: _redis.py baseline goes up by 19 lines.

Tests (tests/test_credential_provider.py):

  • Lazy pool construction checks, which run on every backend: both standalone pools and the Sentinel pool carry the provider, and the client and the pub/sub client each register a re-auth callback.
  • Against real standalone Redis, with credentials removed from the URL: a task round-trips through a Worker authenticated only by the provider, and a provider with wrong credentials is refused. In the ACL legs this only passes if the provider's credentials are actually used.

Ran locally: prek run --all-files, and the full suite with --cov-fail-under=100 on memory, Redis 8.6, and Redis 8.6 with ACL. The new tests also pass with redis-py 5.3. On Redis 8.6 cluster I ran a focused subset (results, pub/sub, strikes, credential tests), because the full cluster run hit local Docker container-start failures. CI's cluster leg is the real check there.

🤖 Generated with Claude Code

Docket, StrikeList, and RedisConnection take an optional
credential_provider and hand it to every pool and client Docket opens,
so rotating credentials such as Azure Entra ID tokens from
redis-entraid work.  The clients that wrap a pool get it too, since
redis-py only re-authenticates pooled connections for a streaming
provider when the client is given it.

Closes chrisguidry#483

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@read-the-docs-community

read-the-docs-community Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Documentation build overview

📚 docket | 🛠️ Build #34940228 | 📁 Comparing f380792 against latest (04eb5d7)

  🔍 Preview build  

7 files changed · ± 7 modified

± Modified

chrisguidry and others added 2 commits September 29, 2026 17:05
The ruff format hook in prek failed on two assertions in these tests that
ran past the line limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y2QtufEUxxm9XgTqCWySHG
@codecov-commenter

codecov-commenter commented Sep 29, 2026 •

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
1066 1 1065 8
View the top 2 failed test(s) by shortest run time
tests/cli/test_snapshot.py::test_snapshot_stats_with_running_tasks_only
Stack Traces | 30s run time
self = <Coroutine test_snapshot_stats_with_running_tasks_only>

    def runtest(self) -> None:
        runner_fixture_id = f"_{self._loop_scope}_scoped_runner"
        runner = self._request.getfixturevalue(runner_fixture_id)
        context = contextvars.copy_context()
        synchronized_obj = _synchronize_coroutine(
            getattr(*self._synchronization_target_attr), runner, context
        )
        with MonkeyPatch.context() as c:
            c.setattr(*self._synchronization_target_attr, synchronized_obj)
>           super().runtest()

.venv/lib/python3.10....../site-packages/pytest_asyncio/plugin.py:569: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.venv/lib/python3.10....../site-packages/pytest_asyncio/plugin.py:905: in inner
    runner.run(coro, context=context)
.venv/lib/python3.10.../asyncio/runner/runner.py:175: in run
    return self._loop.run_until_complete(task)  # type: ignore[union-attr, no-any-return]
../............../_temp/uv-python-dir/cpython-3.10-linux-x86_64-gnu/lib/python3.10/asyncio/base_events.py:636: in run_until_complete
    self.run_forever()
../............../_temp/uv-python-dir/cpython-3.10-linux-x86_64-gnu/lib/python3.10/asyncio/base_events.py:603: in run_forever
    self._run_once()
../............../_temp/uv-python-dir/cpython-3.10-linux-x86_64-gnu/lib/python3.10/asyncio/base_events.py:1871: in _run_once
    event_list = self._selector.select(timeout)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <selectors.EpollSelector object at 0x75320d421630>, timeout = 0.015

    def select(self, timeout=None):
        if timeout is None:
            timeout = -1
        elif timeout <= 0:
            timeout = 0
        else:
            # epoll_wait() has a resolution of 1 millisecond, round away
            # from zero to wait *at least* timeout seconds.
            timeout = math.ceil(timeout * 1e3) * 1e-3
    
        # epoll_wait() expects `maxevents` to be greater than zero;
        # we want to make sure that `select()` can be called when no
        # FD is registered.
        max_ev = max(len(self._fd_to_key), 1)
    
        ready = []
        try:
>           fd_event_list = self._selector.poll(timeout, max_ev)
E           Failed: Timeout (>30.0s) from pytest-timeout.

../............../_temp/uv-python-dir/cpython-3.10-linux-x86_64-gnu/lib/python3.10/selectors.py:469: Failed
tests/worker/test_lifecycle.py::test_worker_rapid_start_cancel_cycles
Stack Traces | 30.1s run time
self = <Coroutine test_worker_rapid_start_cancel_cycles>

    def runtest(self) -> None:
        runner_fixture_id = f"_{self._loop_scope}_scoped_runner"
        runner = self._request.getfixturevalue(runner_fixture_id)
        context = contextvars.copy_context()
        synchronized_obj = _synchronize_coroutine(
            getattr(*self._synchronization_target_attr), runner, context
        )
        with MonkeyPatch.context() as c:
            c.setattr(*self._synchronization_target_attr, synchronized_obj)
>           super().runtest()

.venv/lib/python3.10....../site-packages/pytest_asyncio/plugin.py:569: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.venv/lib/python3.10....../site-packages/pytest_asyncio/plugin.py:905: in inner
    runner.run(coro, context=context)
.venv/lib/python3.10.../asyncio/runner/runner.py:175: in run
    return self._loop.run_until_complete(task)  # type: ignore[union-attr, no-any-return]
../............../_temp/uv-python-dir/cpython-3.10-linux-x86_64-gnu/lib/python3.10/asyncio/base_events.py:636: in run_until_complete
    self.run_forever()
../............../_temp/uv-python-dir/cpython-3.10-linux-x86_64-gnu/lib/python3.10/asyncio/base_events.py:603: in run_forever
    self._run_once()
../............../_temp/uv-python-dir/cpython-3.10-linux-x86_64-gnu/lib/python3.10/asyncio/base_events.py:1871: in _run_once
    event_list = self._selector.select(timeout)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <selectors.EpollSelector object at 0x77088e04a1d0>, timeout = 0.1

    def select(self, timeout=None):
        if timeout is None:
            timeout = -1
        elif timeout <= 0:
            timeout = 0
        else:
            # epoll_wait() has a resolution of 1 millisecond, round away
            # from zero to wait *at least* timeout seconds.
            timeout = math.ceil(timeout * 1e3) * 1e-3
    
        # epoll_wait() expects `maxevents` to be greater than zero;
        # we want to make sure that `select()` can be called when no
        # FD is registered.
        max_ev = max(len(self._fd_to_key), 1)
    
        ready = []
        try:
>           fd_event_list = self._selector.poll(timeout, max_ev)
E           Failed: Timeout (>30.0s) from pytest-timeout.

../............../_temp/uv-python-dir/cpython-3.10-linux-x86_64-gnu/lib/python3.10/selectors.py:469: Failed

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@uesleilima

Copy link
Copy Markdown
Author

I checked the CI failures from the previous run (30c6b1c) against the current head (2bf7d00, after merging main) and couldn't reproduce either timeout locally:

tests/cli/test_snapshot.py::test_snapshot_stats_with_running_tasks_only (CLI, Python 3.10): 5/5 passes alone, and 3/3 full CLI suite runs pass with CI's xdist settings.
tests/worker/test_lifecycle.py::test_worker_rapid_start_cancel_cycles (Core, Python 3.10, Redis 8 Cluster): 6/6 passes against 8.6-cluster.
The Windows job failed with fetch failed during setup, before any tests ran.
On the current head, the full core suite on Redis 8.6 passes (974 passed, 7 skipped, 100% coverage), tests/test_credential_provider.py passes on 8.6, 8.6-cluster and memory, and prek run --all-files is clean.

Neither timing-out test sets a credential provider, and without one this change passes nothing new to redis-py, so these look like intermittent timeouts rather than something this PR introduced. CI hasn't run on 2bf7d00 yet (it's waiting for workflow approval).

uesleilima and others added 3 commits October 2, 2026 10:30
Keep credential_provider on the standalone pub/sub client and take
main's coverage pragma for that branch.

Co-authored-by: Cursor <[email protected]>
Resolve monorepo move conflicts: keep the credential-provider
_redis.py line budget under python/, and relocate the new tests
with the rest of the suite.

Co-authored-by: Cursor <[email protected]>
Those methods are only exercised by the ACL/standalone Redis legs,
so the memory core suite failed the 100% coverage gate after the
monorepo move kept measuring this test module.

Co-authored-by: Cursor <[email protected]>
@uesleilima

Copy link
Copy Markdown
Author

@chrisguidry we are currently implementing FastMCP Docket integration pinned to this commit, currently working in prod for azure entraid credential provider, your review would be really appreciated so we could rely on an officially released version with this capability, thanks!

@chrisguidry

Copy link
Copy Markdown
Owner

@chrisguidry we are currently implementing FastMCP Docket integration pinned to this commit, currently working in prod for azure entraid credential provider, your review would be really appreciated so we could rely on an officially released version with this capability, thanks!

Hi @uesleilima, I'll take a look today to cut a patch release with these changes. Thanks for the PR!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support redis-py credential providers (e.g. Azure Entra ID / managed identity)

3 participants