Skip to content

feat(ssl): support custom SSLContext - #4211

Draft
lh0156 wants to merge 1 commit into
redis:masterfrom
lh0156:agent/add-ssl-context-3599
Draft

feat(ssl): support custom SSLContext#4211
lh0156 wants to merge 1 commit into
redis:masterfrom
lh0156:agent/add-ssl-context-3599

Conversation

@lh0156

@lh0156 lh0156 commented Jul 26, 2026

Copy link
Copy Markdown

Summary

redis-py currently exposes SSL configuration as individual keyword arguments, which prevents applications from supplying a pre-configured ssl.SSLContext. This is required for deployments that need TLS settings not represented by the current keyword arguments, such as custom certificate stores or private CA handling.

This change:

  • accepts a pre-configured SSLContext for synchronous and asynchronous connections
  • propagates it through standalone, URL-based, and cluster clients
  • uses the supplied context without mutating it
  • preserves the existing context-building path when no custom context is provided
  • adds focused regression tests for direct clients, URL connection pools, and cluster configuration

Fixes #3599.

Validation

  • TDD regression tests failed before the implementation and passed afterward
  • custom-context coverage: 8 passed
  • existing SSL URL parsing regression coverage: 9 passed
  • invoke linters: passed
  • python -m compileall -q redis tests: passed
  • git diff --check: passed

The full TLS/integration matrix was not completed because this checkout has no TLS fixture certificates and the local Redis instance does not enable the DEBUG command used by several unrelated connection tests.

Allow sync and async Redis clients to use a pre-configured SSLContext for standalone and cluster connections. Preserve the existing SSL option path when no custom context is supplied.

Refs redis#3599

@petyaslavova petyaslavova left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for taking this on — the underlying gap is real. The ssl_* keywords can only express file-based PEM trust and client certificates, so anything needing a pre-built context (OS trust store via truststore, in-memory or PKCS#11 keys, ALPN, SNI callbacks) currently has no supported route, and accepting an SSLContext will cover that gap.

I'd like to take it in a slightly different shape though, which also shrinks the diff considerably.
Rather than branching inside _wrap_socket_with_ssl, let the existing RedisSSLContext own the
decision, and share one holder between both stacks:

  1. Move RedisSSLContext to redis/connection.py and import it into redis/asyncio/connection.py.
    Async-imports-from-sync is the direction we already use (see redis/asyncio/cluster.py), and the import binding keeps from redis.asyncio.connection import RedisSSLContext working. This gives one class object, so isinstance and the annotation mean the same thing on both sides.
  2. Give RedisSSLContext a context argument. Please keep context readable and writable — it's pre-existing public state — so store it in a _context slot behind a context property (a slot and a property can't share a name, and __slots__ has no __dict__, so _context needs adding there). The setter should validate ssl.SSLContext | None, and the getter should keep today's behaviour of returning None until the context is built or supplied.
  3. When context is supplied, ignore the other fields; otherwise build from them as today. With that in the holder, _wrap_socket_with_ssl collapses to context = self.ssl_context.get() and the duplicated builder disappears. This also means the context is built once and reused on the sync side rather than rebuilt on every reconnect, which is what we want. The sync-only OCSP block stays where it is — it runs after wrap_socket and needs the live socket.
  4. Public ssl_context should accept ssl.SSLContext | RedisSSLContext | None, default None.
    A holder is used as-is, a raw context is wrapped, anything else raises RedisError at
    construction rather than at first connect. The holder's own context argument stays
    ssl.SSLContext | None — it shouldn't accept another holder. Please also annotate the sync
    SSLConnection parameter; it's currently bare ssl_context=None.
  5. Honour it only when ssl=True, consistent with the other SSL options, and document that explicitly.
  6. Add the three missing async properties — ca_path, ciphers, password — so both stacks expose the same set of concepts. Please don't rename anything: sync keeps
    ssl_include_verify_flags, certificate_password, ssl_min_version, and async keeps its existing property names. tests/test_ssl.py and tests/test_asyncio/test_ssl.py read several of these directly.
  7. Docstrings should state that a supplied context is used as-is, that the other ssl_* options are then ignored, and that the caller owns full configuration — mirroring how we word "Argument is ignored when connection_pool is provided".
  8. Please keep the parameter in the same position across Redis, redis.asyncio.Redis,
    RedisCluster and redis.asyncio.RedisCluster. It's currently appended in redis/client.py but inserted mid-signature in redis/asyncio/cluster.py, whose parameters are all positional-or-keyword, so insertion shifts six later ones(this can break existing apps providing the config args as positional).

One detail to resolve while merging the two builders: they guard the same options with inverted conditions — sync uses is not None for the CA fields and truthiness for ciphers, async the opposite. Since an empty value is never meaningful for any of them (ca_certs=""FileNotFoundError, ca_path=""INVALID_DIRECTORY, ca_data=""Empty certificate data, ciphers=""No cipher can be selected), please reject empty strings — and empty bytes, since ca_data accepts DER — up front with a RedisError naming the option. The builder conditions can
then simply use truthiness, since only None or non-empty values can reach them. Note ssl_password="" should stay accepted, and min_version keeps its is not None check.

On tests, the current ones assert object identity or a mocked wrap_socket, which doesn't show the context reaching the handshake. tests/test_connect.py::test_tcp_ssl_connect plus
tests/ssl_utils.get_tls_certificates already give you a local TLS server, so please add real handshake coverage on both stacks for both input forms, plus cases for: the holder ignoring other fields when context is set, ssl=False leaving the context unused, an invalid ssl_context type, empty-value rejection per option, cluster and from_url propagation, and the new async properties.
An example in docs/examples/ssl_connection_examples.ipynb would be a nice addition for discoverability, though we don't consider it blocking.

Finally, could you retarget the description to Refs #3599 rather than Fixes? The reporter's TLSV13_ALERT_CERTIFICATE_REQUIRED is a mutual-TLS failure that ssl_certfile/ssl_keyfile should already cover, and we already load the system trust store via ssl.create_default_context(), so we want to confirm their case separately before closing that issue.

Happy to review a WIP push if you'd like feedback before it's complete.

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

Projects

None yet

2 participants