Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ dev = [
"respx>=0.22.0",
"requests",
"unasync>=0.6.0",
"certifi>=2025.11.12",
]

[tool.uv]
Expand Down
12 changes: 9 additions & 3 deletions src/typesense/async_/api_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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":
Expand Down
4 changes: 2 additions & 2 deletions src/typesense/async_/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
9 changes: 6 additions & 3 deletions src/typesense/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- ConfigError: Custom exception for configuration-related errors.
"""

import ssl
import sys
import time

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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]]
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions src/typesense/http_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"""

import importlib
import ssl
import sys
from types import ModuleType

Expand Down Expand Up @@ -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:
Expand Down
12 changes: 9 additions & 3 deletions src/typesense/sync/api_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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":
Expand Down
4 changes: 2 additions & 2 deletions src/typesense/sync/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
85 changes: 85 additions & 0 deletions tests/api_call_ssl_test.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions tests/api_call_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand All @@ -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,
)


Expand Down
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading