diff --git a/pyproject.toml b/pyproject.toml index bd2bf29..ca5d28a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dev = [ "respx>=0.22.0", "requests", "unasync>=0.6.0", + "certifi>=2025.11.12", ] [tool.uv] diff --git a/src/typesense/async_/api_call.py b/src/typesense/async_/api_call.py index 6ded2ed..bfaf6de 100644 --- a/src/typesense/async_/api_call.py +++ b/src/typesense/async_/api_call.py @@ -51,7 +51,12 @@ ServiceUnavailable, TypesenseClientError, ) -from typesense.http_backend import ASYNC_CLIENT_TYPES, AsyncClientType, backend_errors +from typesense.http_backend import ( + ASYNC_CLIENT_TYPES, + AsyncClientType, + backend_errors, + verify_option, +) from typesense.node_manager import NodeManager from typesense.request_handler import RequestHandler @@ -163,8 +168,8 @@ def __init__( config (Configuration): The configuration object for the Typesense client. http_client (httpx.AsyncClient | httpx2.AsyncClient, optional): A client to send requests with instead of the default httpx client. The - connection pool settings in ``config`` are not applied to it, and it - is not closed by ``aclose``. + connection pool and ``verify`` settings in ``config`` are not + applied to it, and it is not closed by ``aclose``. Raises: TypeError: If ``http_client`` is not an httpx or httpx2 async client. @@ -192,6 +197,7 @@ def __init__( max_connections=config.max_connections, max_keepalive_connections=config.max_keepalive_connections, ), + verify=verify_option(config.verify), ) async def __aenter__(self) -> "AsyncApiCall": diff --git a/src/typesense/async_/client.py b/src/typesense/async_/client.py index a156040..49338b6 100644 --- a/src/typesense/async_/client.py +++ b/src/typesense/async_/client.py @@ -102,8 +102,8 @@ def __init__( http_client (httpx.AsyncClient | httpx2.AsyncClient, optional): A client to send requests with instead of the default httpx client, e.g. an ``httpx2.AsyncClient`` (``pip install typesense[httpx2]``). - The connection pool settings in ``config_dict`` are not applied to - it, and the Typesense client does not close it. + The connection pool and ``verify`` settings in ``config_dict`` are + not applied to it, and the Typesense client does not close it. Example: >>> config = { diff --git a/src/typesense/configuration.py b/src/typesense/configuration.py index 1efe5b8..31ba091 100644 --- a/src/typesense/configuration.py +++ b/src/typesense/configuration.py @@ -14,6 +14,7 @@ - ConfigError: Custom exception for configuration-related errors. """ +import ssl import sys import time @@ -67,7 +68,9 @@ class ConfigDict(typing.TypedDict): healthcheck_interval_seconds (int): The interval in seconds between health checks. - verify (bool): Whether to verify the SSL certificate. + verify (bool | str | ssl.SSLContext): Whether to verify the SSL + certificate. Pass a CA bundle path or an ``ssl.SSLContext`` to verify + against custom certificates. Defaults to ``True``. timeout_seconds (int, deprecated): The connection timeout in seconds. @@ -108,7 +111,7 @@ class ConfigDict(typing.TypedDict): retry_interval_seconds: typing.NotRequired[float] interval_seconds: typing.NotRequired[int] # deprecated alias healthcheck_interval_seconds: typing.NotRequired[int] - verify: typing.NotRequired[typing.Union[bool, str]] + verify: typing.NotRequired[typing.Union[bool, str, ssl.SSLContext]] timeout_seconds: typing.NotRequired[int] # deprecated master_node: typing.NotRequired[typing.Union[str, NodeConfigDict]] # deprecated additional_headers: typing.NotRequired[typing.Dict[str, str]] @@ -208,7 +211,7 @@ class Configuration: num_retries (int): The number of retries to attempt before failing. retry_interval_seconds (float): The interval in seconds between retries. healthcheck_interval_seconds (int): The interval in seconds between health checks. - verify (bool): Whether to verify the SSL certificate. + verify (bool | str | ssl.SSLContext): Whether to verify the SSL certificate. pool_timeout_seconds (float): How long to wait for a free pooled connection. max_connections (int): The maximum number of connections in the pool. max_keepalive_connections (int): The maximum number of idle pooled connections. diff --git a/src/typesense/http_backend.py b/src/typesense/http_backend.py index 69e5a40..d5beae0 100644 --- a/src/typesense/http_backend.py +++ b/src/typesense/http_backend.py @@ -13,6 +13,7 @@ """ import importlib +import ssl import sys from types import ModuleType @@ -61,6 +62,26 @@ def backend_errors(name: str) -> typing.Tuple[typing.Type[Exception], ...]: return tuple(getattr(backend, name) for backend in _BACKENDS) +def verify_option( + verify: typing.Union[bool, str, ssl.SSLContext], +) -> typing.Union[bool, ssl.SSLContext]: + """ + Return the ``verify`` value to build an httpx client with. + + httpx deprecates CA bundle paths as ``verify`` values, so a path is loaded + into an SSL context here instead. + + Args: + verify (bool | str | ssl.SSLContext): The configured ``verify`` setting. + + Returns: + bool | ssl.SSLContext: ``verify`` with a CA bundle path loaded. + """ + if isinstance(verify, str): + return ssl.create_default_context(cafile=verify) + return verify + + # Declared precisely for type checkers so ``isinstance`` narrows to the client # unions above; at runtime they only contain the backends that are installed. if typing.TYPE_CHECKING: diff --git a/src/typesense/sync/api_call.py b/src/typesense/sync/api_call.py index 42c58a0..0ffd4bc 100644 --- a/src/typesense/sync/api_call.py +++ b/src/typesense/sync/api_call.py @@ -51,7 +51,12 @@ ServiceUnavailable, TypesenseClientError, ) -from typesense.http_backend import CLIENT_TYPES, SyncClientType, backend_errors +from typesense.http_backend import ( + CLIENT_TYPES, + SyncClientType, + backend_errors, + verify_option, +) from typesense.node_manager import NodeManager from typesense.request_handler import RequestHandler @@ -163,8 +168,8 @@ def __init__( config (Configuration): The configuration object for the Typesense client. http_client (httpx.Client | httpx2.Client, optional): A client to send requests with instead of the default httpx client. The - connection pool settings in ``config`` are not applied to it, and it - is not closed by ``close``. + connection pool and ``verify`` settings in ``config`` are not + applied to it, and it is not closed by ``close``. Raises: TypeError: If ``http_client`` is not an httpx or httpx2 async client. @@ -192,6 +197,7 @@ def __init__( max_connections=config.max_connections, max_keepalive_connections=config.max_keepalive_connections, ), + verify=verify_option(config.verify), ) def __enter__(self) -> "ApiCall": diff --git a/src/typesense/sync/client.py b/src/typesense/sync/client.py index 3e01d8a..6343904 100644 --- a/src/typesense/sync/client.py +++ b/src/typesense/sync/client.py @@ -102,8 +102,8 @@ def __init__( http_client (httpx.Client | httpx2.Client, optional): A client to send requests with instead of the default httpx client, e.g. an ``httpx2.Client`` (``pip install typesense[httpx2]``). - The connection pool settings in ``config_dict`` are not applied to - it, and the Typesense client does not close it. + The connection pool and ``verify`` settings in ``config_dict`` are + not applied to it, and the Typesense client does not close it. Example: >>> config = { diff --git a/tests/api_call_ssl_test.py b/tests/api_call_ssl_test.py new file mode 100644 index 0000000..6d7f941 --- /dev/null +++ b/tests/api_call_ssl_test.py @@ -0,0 +1,85 @@ +"""Regression tests for TLS configuration in both HTTP clients.""" + +import ssl +from pathlib import Path + +import certifi +import pytest +from pytest_mock import MockerFixture + +from typesense.async_.api_call import AsyncApiCall +from typesense.configuration import Configuration +from typesense.sync.api_call import ApiCall + + +@pytest.fixture(params=[ApiCall, AsyncApiCall]) +def api_call_class(request): + """Exercise both the async source and the generated sync client.""" + return request.param + + +def pool_ssl_context(api_call) -> ssl.SSLContext: + """Return the SSL context of the client's connection pool. + + This reads private httpx 0.28 attributes and may need updating on upgrades. + """ + return api_call._client._transport._pool._ssl_context + + +async def close_api_call(api_call): + if isinstance(api_call, AsyncApiCall): + await api_call.aclose() + else: + api_call.close() + + +@pytest.mark.parametrize("verify", [True, False]) +async def test_verification_mode(fake_config, api_call_class, verify): + """The effective TLS context must honor explicit verification settings.""" + fake_config.verify = verify + api_call = api_call_class(fake_config) + try: + ssl_context = pool_ssl_context(api_call) + assert ssl_context.verify_mode == ( + ssl.CERT_REQUIRED if verify else ssl.CERT_NONE + ) + assert ssl_context.check_hostname is verify + finally: + await close_api_call(api_call) + + +async def test_ssl_context(fake_config, api_call_class): + """A configured SSL context must be used as is.""" + context = ssl.create_default_context() + fake_config.verify = context + api_call = api_call_class(fake_config) + try: + assert pool_ssl_context(api_call) is context + finally: + await close_api_call(api_call) + + +async def test_custom_ca_bundle( + fake_config: Configuration, + api_call_class, + tmp_path: Path, + mocker: MockerFixture, +): + """A configured CA bundle must reach the real SSL context builder.""" + ca_bundle = tmp_path / "custom-ca.pem" + ca_bundle.write_bytes(Path(certifi.where()).read_bytes()) + fake_config.verify = str(ca_bundle) + create_context = mocker.spy(ssl, "create_default_context") + + api_call = api_call_class(fake_config) + try: + create_context.assert_any_call(cafile=str(ca_bundle)) + finally: + await close_api_call(api_call) + + +async def test_missing_ca_bundle(fake_config, api_call_class, tmp_path): + """An invalid CA path must fail instead of silently using default trust roots.""" + fake_config.verify = str(tmp_path / "missing-ca.pem") + with pytest.raises(FileNotFoundError): + api_call_class(fake_config) diff --git a/tests/api_call_test.py b/tests/api_call_test.py index d4b396b..d4684d2 100644 --- a/tests/api_call_test.py +++ b/tests/api_call_test.py @@ -865,6 +865,7 @@ def test_client_uses_connection_pool_settings( client_mock.assert_called_once_with( timeout=httpx.Timeout(3.0, pool=1.5), limits=httpx.Limits(max_connections=200, max_keepalive_connections=50), + verify=True, ) @@ -884,6 +885,7 @@ def test_async_client_uses_connection_pool_settings( client_mock.assert_called_once_with( timeout=httpx.Timeout(3.0, pool=1.5), limits=httpx.Limits(max_connections=200, max_keepalive_connections=50), + verify=True, ) diff --git a/uv.lock b/uv.lock index f017369..813539d 100644 --- a/uv.lock +++ b/uv.lock @@ -1006,6 +1006,7 @@ httpx2 = [ [package.dev-dependencies] dev = [ + { name = "certifi" }, { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "coverage", version = "7.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "faker", version = "37.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -1035,6 +1036,7 @@ provides-extras = ["httpx2"] [package.metadata.requires-dev] dev = [ + { name = "certifi", specifier = ">=2025.11.12" }, { name = "coverage" }, { name = "faker" }, { name = "isort", specifier = ">=6.0.1" },