Skip to content

fix(connection): convert TLS and unix-socket clients to async - #792

Merged
vishal-bala merged 2 commits into
redis:mainfrom
Yarmoluk:fix/sync-to-async-connection-classes
Nov 3, 2026
Merged

vishal-bala merged 2 commits into
redis:mainfrom
Yarmoluk:fix/sync-to-async-connection-classes

Conversation

@Yarmoluk

@Yarmoluk Yarmoluk commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes #789.

Motivation

RedisConnectionFactory.sync_to_async_redis passed the sync pool's connection_kwargs straight into AsyncConnectionPool and picked the TLS class with an exact-type check. A client built with Redis(ssl=True, ...), Redis(unix_socket_path=...) or Sentinel.master_for(...) therefore raised TypeError on its first async command (SemanticCache.acheck, and SemanticRouter.aroute once #784 lands), and a subclass of SSLConnection got a plaintext async connection with the password still set.

Changes

  • The async connection class is chosen with issubclass: TLS connections (and subclasses) map to AsyncSSLConnection, unix-socket connections to the async UnixDomainSocketConnection, everything else to AsyncConnection.
  • _async_connection_kwargs keeps only the keywords the chosen async class accepts, found by walking its __init__ chain (_accepted_init_kwargs), so the filter follows whatever redis-py version is installed rather than a hard-coded key list. A sync-only keyword at its default (None or False) is dropped; one that is set, such as ssl_validate_ocsp=True, raises a ValueError naming it, because the async client cannot honour it and dropping it would silently weaken TLS verification.
  • Sentinel-managed pools are rejected up front with a clear ValueError, as RedisCluster already was.
  • Seven unit tests in tests/unit/test_sync_to_async_redis.py, none needing a server; five fail on main.

Release Notes

SemanticCache, AsyncSearchIndex and any other async path that converts a caller's sync client now work with clients configured with TLS keywords (Redis(ssl=True, ...)), unix sockets, and custom SSLConnection subclasses. Sentinel-managed clients, RedisCluster clients, and clients with a sync-only setting enabled (OCSP validation) raise ValueError at conversion time with a pointer to redis_url; previously they failed with TypeError on the first command.

Reviewer Brief

Start here: redisvl/redis/connection.py, _async_connection_kwargs. It decides which of the sync pool's settings reach the async connection and which abort the conversion; sync_to_async_redis only picks the class and calls it.

Where it could fail

  • redisvl/redis/connection.py, _SYNC_POOL_PLUMBING: these four keywords are dropped by name because the sync pool computes them from the live settings; if a future redis-py adds another pool-computed keyword, every conversion on a version whose async class rejects it would raise again. Check against redis-py's ConnectionPool._update_connection_kwargs.
  • redisvl/redis/connection.py, _is_unset: maint_notifications_config is dropped unless enabled is True, so a user who set enabled="auto" on purpose gets an async client without notifications on 7.4/8.0; on 8.1 it passes through.
  • redisvl/redis/connection.py, _init_parameters: stops at the first __init__ without **kwargs; for the sync side that is the user's connection_class, so a subclass with an explicit-keyword __init__ contributes only its own defaults.

Not verified: cluster mode (unchanged path, raises as before); a live TLS or unix-socket server (the tests stop at make_connection(), where every reported failure occurred); the sync Retry pass-through at the end of the function is pre-existing and left for a follow-up.

Beyond CI: the ten tests pass on redis-py 7.4.1, 8.0.1 and 8.1.0; the resp3 case fails on 7.4.1 and 8.0.1 without the fix, and the tls-subclass case fails on 8.0.1 when either the plumbing filter or the maint_notifications_config rule is removed.

Since the last brief: 7c856ed rebases on 0.30.0, drops the pool-computed keywords, compares sync-only keywords to their own defaults, trims the aroute caveat, and folds the tests into two parametrised ones.

Reviewed 7c856ed · Claude Code (claude-fable-5-1) · lenses: correctness, security, design, tests, docs

Notes

🤖 Generated with Claude Code


Note

Medium Risk
Changes how sync clients are translated into async pools across index/cache/router async entry points, including TLS and socket settings; incorrect filtering could misconfigure connections, though explicit errors protect sync-only security options.

Overview
Fixes RedisConnectionFactory.sync_to_async_redis so async paths (SemanticRouter.aroute, AsyncSearchIndex, semantic caches) can reuse a caller’s sync client when it uses TLS, TLS subclasses, or unix sockets, instead of failing on the first command with TypeError or building a plaintext async pool.

Conversion now picks the async connection class with issubclass (TLS → AsyncSSLConnection, unix → AsyncUnixDomainSocketConnection), filters pool kwargs through _async_connection_kwargs (version-aware via _init_parameters, strips RESP3 pool plumbing, drops sync-only defaults), and raises ValueError for Sentinel-managed pools and for user-set sync-only TLS options (e.g. ssl_validate_ocsp) rather than silently weakening security. SemanticRouter.aroute docs now say only Sentinel and Cluster need redis_url for async routing.

Adds tests/unit/test_sync_to_async_redis.py covering successful mappings and failure modes without a live Redis server.

Reviewed by Cursor Bugbot for commit 7c856ed. Bugbot is set up for automated code reviews on this repo. Configure here.

@vishal-bala
vishal-bala self-requested a review October 30, 2026 13:26
@vishal-bala vishal-bala added the auto:patch Increment the patch version when merged label Oct 30, 2026
Comment thread redisvl/redis/connection.py Resolved
Comment thread redisvl/redis/connection.py Resolved
Comment thread tests/unit/test_sync_to_async_redis.py Resolved
Comment thread redisvl/redis/connection.py Resolved
@vishal-bala vishal-bala self-assigned this Oct 30, 2026

@paoValle paoValle left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the defaults problem you found at connection.py:350, a variant that fixes it without enumerating keywords: compare the value against the sync class's own default instead of against (None, False).

inspect.signature(SyncConnection.__init__) (walked the same way _accepted_init_kwargs already walks the async class) gives the default each keyword has for the class the pool was built from. A keyword the pool injected, such as maint_notifications_config or orig_host_address, arrives with exactly that default and is dropped; one the user set, even to a falsy value, differs and still raises. That covers redis-py 7.4.1 and 8.0.1 without a version list, and it removes the second thing this line gets wrong: value not in (None, False) compares with ==, so 0 is treated as unset alongside False.

Two smaller notes, both cheap:

  • test_tls_url_client_still_converts does not test anything the keyword tests do not, and it passes on main; the unix-socket URL form (Redis.from_url("unix:///tmp/redis.sock")) is the one that is not covered and is a different code path in redis-py (it sets connection_class, not unix_socket_path).
  • _accepted_init_kwargs stops at the first __init__ without **kwargs, which is right for redis-py's own chain; for a user subclass whose __init__ takes explicit keywords and no **kwargs, the loop stops there and drops the parent's keywords. Worth a one-line comment saying that is deliberate, or continue walking and intersect instead.

sync_to_async_redis passed the sync pool's connection_kwargs straight to
the async pool and picked the TLS class with an exact-type check, so a
client built with Redis(ssl=True, ...), Redis(unix_socket_path=...) or
Sentinel.master_for(...) raised TypeError on its first async command,
and a subclass of SSLConnection got a plaintext async connection.

The async connection class is now chosen with issubclass for TLS and
unix-socket connections, keywords the async class does not accept are
dropped when they hold their default, a sync-only setting that is set
(such as OCSP validation) raises a clear ValueError instead of being
lost, and Sentinel-managed pools are rejected up front like RedisCluster.

Closes redis#789
… to their own defaults

The sync pool injects maintenance-notification keywords (the pool handler,
maint_notifications_config and the orig_* copies of host and timeouts) for
its own connections. Older async classes don't accept them, so on
redis-py 7.4 with RESP3, and on 8.0 for every client, the conversion raised
naming settings the user never touched. Those keywords describe the sync
pool, not the user's settings, and the async pool computes its own, so they
are never carried over; maint_notifications_config is dropped unless the
user enabled notifications explicitly.

A remaining sync-only keyword now counts as unset when it still holds the
sync class's own declared default, found by inspect.signature, rather than
when it is None or False. That stops 0 being treated as unset and covers
keywords whose default is not falsy.

Also trims the SemanticRouter.aroute caveat to Sentinel and Cluster now
that redis#784 is in, notes that a custom SSLConnection subclass's behaviour is
not carried over, and folds the tests into two parametrised ones with
RESP3 and unix-URL cases.
@Yarmoluk
Yarmoluk force-pushed the fix/sync-to-async-connection-classes branch from d23003e to 7c856ed Compare November 2, 2026 16:35
@Yarmoluk

Yarmoluk commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, the signature-default comparison is what 7c856ed does now, with one addition: the keywords the sync pool computes (the pool handler and the orig_* copies) never equal the declared default, since the pool fills them from the live settings, so those are named as plumbing and always dropped. Took the unix-URL case, and _init_parameters has a comment saying the stop at the first __init__ without **kwargs is deliberate.

@vishal-bala
vishal-bala merged commit 59d36e1 into redis:main Nov 3, 2026
55 checks passed
@Yarmoluk
Yarmoluk deleted the fix/sync-to-async-connection-classes branch November 3, 2026 12:17
Yarmoluk added a commit to Yarmoluk/redis-vl-python that referenced this pull request Nov 5, 2026
Seed the other session through astore, block every sync round trip by
patching execute_command and pipeline on the sync client, and pin a tool
message's tool_call_id and metadata through an async write, adrop(id),
the reused async index and adisconnect closing the converted client.
adrop now goes through AsyncSearchIndex.drop_keys rather than the private
client, and the connection caveat drops SSL and unix sockets, which
convert since redis#792.
vishal-bala pushed a commit that referenced this pull request Nov 6, 2026
…edis (#817)

Follow-up to #792, from [this review
comment](#792 (comment)).

## Motivation

`sync_to_async_redis` passed the sync client's `retry` keyword through
unchanged. The async connection awaits `retry.call_with_retry(...)`, and
a sync `redis.retry.Retry` returns the coroutine without awaiting it, so
the retry policy never ran: a converted client failed on the first
command after `CLIENT KILL`, where a native async client recovers.

## Changes

- `_async_connection_kwargs` rebuilds a sync `Retry` as
`redis.asyncio.retry.Retry` with the same backoff object, retry count
and supported error set (`_async_retry`). `retry=None` and a missing
keyword are left alone.
- Unit tests in `tests/unit/test_sync_to_async_redis.py` pin the
conversion; an integration test in
`tests/integration/test_connection.py` kills the converted client's
connection and checks the next command succeeds.

## Release Notes

Async paths that convert a caller's sync client (`SemanticCache`,
`AsyncSearchIndex`, and so on) now keep that client's retry policy, so
they reconnect after a dropped connection the way a native async client
does.

## Reviewer Brief

**Start here:** `redisvl/redis/connection.py`, `_async_retry`. A client
converted from sync to async keeps its retry policy only if this
rebuilds it, and `_async_connection_kwargs` calls it for every
conversion, including a plain `Redis()`, which carries a `Retry` by
default.

**Where it could fail**
- `redisvl/redis/connection.py:383`: `_async_retry` reads
`Retry._backoff` and `Retry._supported_errors`, private attributes with
no public getter in redis-py 6.3 through 8.1; if a future release
renames one, every conversion raises `AttributeError` instead of quietly
dropping the policy.
- `redisvl/redis/connection.py:421`: a user's `Retry` subclass is
rebuilt as the stock async `Retry`, so an overridden `call_with_retry`
is not carried over (it could not have run on the async connection
before this change either).

**Not verified:** cluster and Sentinel clients (both are rejected before
this code runs); redis-py versions between 6.3.0 and 8.1.0 other than
7.4.1 and 8.0.1.

**Beyond CI:** `test_converted_client_recovers_after_client_kill` failed
20 of 20 runs on `main` and passed 20 of 20 with this change, on both
redis-py 6.3.0 and 8.1.0. The unit tests pass on redis-py 6.3.0, 7.4.1,
8.0.1 and 8.1.0, and hardcoding the retry count in `_async_retry` makes
`test_sync_retry_is_rebuilt_as_the_async_retry` fail. A sync client
built with `retry_on_timeout=True` or
`retry_on_error=[BusyLoadingError]` converts to an async connection
whose `Retry` includes those errors, on 6.3.0 and 8.1.0.

**Since the last brief:** `57e898e` tightens the tests only: a
non-default retry count of 5, two redundant tests and assertions
dropped, and a clearer CLIENT KILL test docstring.

<sub>Reviewed `57e898e` · Claude Code (claude-opus-5-5) · lenses:
correctness, security, design, tests, docs, Redis behaviour</sub>

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Reliability fix on the sync-to-async path used by async cache/index
flows; conversion relies on redis-py private **`Retry`** fields that
could break on a future redis-py release.
> 
> **Overview**
> **`sync_to_async_redis`** no longer forwards the sync client’s
**`Retry`** unchanged. It now rebuilds it as
**`redis.asyncio.retry.Retry`** via **`_async_retry`**, preserving
backoff, retry count, and supported errors so reconnect-and-retry
actually runs on async connections (sync **`Retry`** would leave
coroutines unawaited and skip the policy).
> 
> **`_async_connection_kwargs`** applies that conversion when
**`retry`** is a sync **`Retry`**; **`retry=None`** or a missing keyword
is unchanged. Docs for **`sync_to_async_redis`** note the behavior.
> 
> Unit tests assert the async **`Retry`** matches the sync policy; an
integration test **`CLIENT KILL`**s a converted client and expects the
next **`ping`** to succeed.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
57e898e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
@applied-ai-release-bot

Copy link
Copy Markdown

🚀 PR was released in v0.31.0 🚀

@applied-ai-release-bot applied-ai-release-bot Bot added the released This issue/pull request has been released. label Nov 6, 2026
vishal-bala pushed a commit that referenced this pull request Nov 6, 2026
Branch: `feat/message-history-async` (fork `Yarmoluk/redis-vl-python`),
rebased onto `main` at `506b961` (includes #792 and #785).
Commits: `82f91e4` feat, `93657d7` docs, `c1b5d75` test. Label:
`auto:minor`.

## Summary

Follow-up to #784, which gave `SemanticRouter` `aroute` / `aroute_many`.
`MessageHistory` had the same gap: an agent running on asyncio blocked
its event loop on every history read or write. This PR adds the async
twins -- `aget_recent`, `astore`, `aadd_messages`, `aadd_message`,
`acount`, `adrop`, `aclear`, `adisconnect` and an `aindex` property --
using the same pattern #784 landed:

- The `AsyncSearchIndex` is built lazily on the first async call, from
the sync index's connection settings. A history built from `redis_url`
gets its own async connection from that URL and `connection_kwargs`; a
history given a sync `redis_client` gets an async client converted from
that client's connection settings by
`RedisConnectionFactory.sync_to_async_redis`, owned by the async index,
so `adisconnect` closes only the converted client and the caller's sync
client is never touched or rebuilt.
- Every async method goes through the `AsyncSearchIndex`; none reaches
into a Redis client directly (`adrop` deletes with
`aindex.drop_keys(aindex.key(id))`).
- Query building (`_recent_query`, `_count_query`) and message
construction (`_chat_messages`, `_exchange`) move into helpers shared by
the sync and async paths, so the two cannot drift.
- The docstrings say what is and is not supported, in the router's
wording: since #792 a sync client over TLS or a unix socket converts;
Sentinel and Redis Cluster clients do not (pass `redis_url` instead);
the async client is bound to the loop that made the first call, so call
`adisconnect` before switching loops.

No sync behaviour changes. `SemanticMessageHistory` is not covered here
(see Notes).

## Reviewer Brief

**Start here:** `redisvl/extensions/message_history/message_history.py`,
`_get_async_index()` (line 418). Every async method goes through it, and
it decides whether the async index connects from the URL the sync index
owns or from a client converted from the caller's sync client; it is the
same code as `SemanticRouter._get_async_index`.

**Where it could fail**
- `message_history.py:432`: a caller-supplied Sentinel or Cluster
client, or one with a sync-only setting such as
`ssl_validate_ocsp=True`, raises `ValueError` from `sync_to_async_redis`
at the first async call; the `aget_recent` docstring names Sentinel and
Cluster but, like the router's, not the sync-only-setting case.
- `message_history.py:154`: `adrop` removes the key with `UNLINK` (via
`drop_keys`) where sync `drop` uses `DEL`; both are `@keyspace @write`,
but an ACL that grants `+del` by name and not `+unlink` would allow
`drop` and deny `adrop`
([UNLINK](https://redis.io/docs/latest/commands/unlink/),
[DEL](https://redis.io/docs/latest/commands/del/)).
- `message_history.py:449`: the async client belongs to the loop that
made the first call; a second `asyncio.run()` on the same history
without `adisconnect` in between fails, the same contract
`SemanticRouter` has shipped since #784.

**Not verified:** Redis Cluster and Sentinel (no cluster tests run
locally or in CI); the TLS and unix-socket conversion paths were not
exercised end to end through `MessageHistory` (they are covered by
#792's own tests); `connection_kwargs` and `lib_name` forwarding on the
URL path is not asserted by a test.

**Beyond CI:** each of these mutations makes
`test_async_message_history_matches_sync` fail: `astore` ignoring
`session_tag`, `adrop` going through the sync client, `adrop(id)`
ignoring `id`, the async index rebuilt per call, and `adisconnect` not
closing the converted client. The two new integration tests pass locally
against `redis:8.4` via testcontainers.

**Since the last brief:** rebased onto `506b961` (#792 now converts TLS
and unix-socket clients, so the caveat narrows to Sentinel and Cluster);
`adrop` deletes through `aindex.drop_keys` instead of the async client;
the sync-equivalence test now blocks the sync client's `execute_command`
and `pipeline`, seeds a second session with `astore`, and pins
`tool_call_id`/metadata, `adrop(id)`, async-index reuse and
`adisconnect` closing the converted pool.

<sub>Reviewed `c1b5d75` · Claude Code (claude-opus-5-5) · lenses:
correctness, security, design, tests, docs, Redis behaviour</sub>

## Test plan

Run on the rebased branch at `c1b5d75`, macOS, `uv`, Docker running
(testcontainers starts `redis:8.4`):

```bash
make format
# All done! ✨ 🍰 ✨
# 272 files left unchanged.

rm -rf .mypy_cache && make check-types
# Success: no issues found in 122 source files

uv run pytest tests/integration/test_message_history.py -k async_message_history -q -p no:cacheprovider
# 2 passed, 33 deselected

uv run pytest -n auto tests/integration/test_message_history.py tests/unit -q -p no:cacheprovider
# 3 failed, 1500 passed, 9 skipped, 53 errors
```

The 3 failures and 53 errors are the identical set on `main` at
`506b961`: every one is `ModuleNotFoundError: sentence_transformers`
(the HuggingFace vectorizer is not installed locally), in
`SemanticMessageHistory` tests this PR does not touch.

`test_async_message_history_matches_sync` takes its expected values from
the sync methods, then patches `execute_command` and `pipeline` on the
sync index's Redis client to raise, so any async method that made a
blocking call would fail it. `test_async_message_history_with_redis_url`
checks that a URL-built history builds its async index from that URL
rather than converting the client.

## Notes

- `SemanticMessageHistory` gets no async methods in this PR. Its
`get_relevant` embeds through the vectorizer, which brings in the
`aembed` caveats #784 documented for the router; it is a separate change
with its own brief.
- No `adelete`: dropping the index is setup/teardown rather than a
per-turn call, and #784 drew the same line for the router. No async form
of the `messages` property either, since a property cannot be awaited;
`aget_recent` with a large `top_k` is the async read.
- Loop binding follows #784's `adisconnect` contract rather than the
rebuild-on-new-loop #785 added for Cohere's async client. The async
index owns a connection pool, and rebuilding it silently on a loop
change would leak the previous pool; an explicit `adisconnect` keeps the
two extensions consistent and the close visible.
- `_get_async_index`, `aindex` and `adisconnect` match
`SemanticRouter`'s line for line; they are kept as two copies rather
than a shared helper, since extracting one would mean changing the
merged router code in this PR.
- `count`/`acount` keep the sync method's unannotated signature rather
than adding `-> int` to one twin only.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> New async Redis connection lifecycle (lazy index, loop binding, client
conversion) could misbehave for Sentinel/Cluster/unix-socket setups;
sync API and data paths are unchanged.
> 
> **Overview**
> Adds **async counterparts** to `MessageHistory` read/write/clear/drop
operations (`aget_recent`, `astore`, `aadd_messages`, `aadd_message`,
`acount`, `adrop`, `aclear`, plus `adisconnect` and an `aindex`
property) so asyncio agents can use history without blocking the event
loop.
> 
> Async work goes through a **lazily created** `AsyncSearchIndex` that
reuses the sync index’s schema and connection settings: URL-owned
clients open a separate async connection from `redis_url`;
caller-supplied sync clients get a derived async client via
`RedisConnectionFactory.sync_to_async_redis`, closed only by
`adisconnect`. Docstrings document loop binding and that
Sentinel/Cluster sync clients need `redis_url` instead.
> 
> Sync behavior is preserved by **extracting shared helpers**
(`_recent_query`, `_recent_result`, `_count_query`, `_exchange`,
`_chat_messages`) so sync and async paths share query and message
construction. Two integration tests assert parity with sync (including a
monkeypatch that fails if async calls hit the sync Redis client) and
cover the `redis_url` async-index path.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
c1b5d75. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto:patch Increment the patch version when merged released This issue/pull request has been released.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sync_to_async_redis fails for TLS clients configured with keywords

3 participants