diff --git a/.gitignore b/.gitignore
index 24b39bc..1705b00 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,7 +44,7 @@ nosetests.xml
coverage.xml
*,cover
.hypothesis/
-reports/
+reports*/
venv/
.venv310/
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index a0aa9f3..4064d5c 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -6,6 +6,36 @@ All notable changes to this project will be documented in this file.
The format is based on the `Keep a Changelog `__,
and this project adheres to `Semantic Versioning `__.
+1.0.6 - 2026-10-16
+------------------
+
+Added
+~~~~~
+
+- Added network failure details to ``VecDBException`` so callers can see
+ whether a request failed because of a timeout, DNS lookup, connection, or
+ TLS/certificate problem, and whether retrying may help.
+- Added ``get_vecdb_oauth_token`` for OAuth client-credentials token requests,
+ with HTTPS validation, configurable TLS, timeout and proxy settings, and
+ sanitized ``OAuthTokenError`` diagnostics. Updated ``examples/test_client.py``
+ to demonstrate OAuth authentication and retain a basic-auth option.
+
+Changed
+~~~~~~~
+
+- Network failures now include distinct error codes and advice on what to
+ check next.
+- Removed ``distribute_method`` validation for "INMEMORY GRAPH" organization
+ when ``distribute_params`` is null.
+- ``query.top_k`` must now be a positive integer; booleans and fractional
+ values are rejected.
+
+Fixed
+~~~~~
+
+- Made exception formatting resilient to unsupported, circular, and deeply
+ nested diagnostic values while keeping sensitive data redacted.
+
1.0.5 - 2026-10-01
------------------
diff --git a/Makefile b/Makefile
index 64adbf3..aef20e9 100644
--- a/Makefile
+++ b/Makefile
@@ -23,6 +23,8 @@ TARGET_DIRS = $(SOURCE_DIR) tests examples
REPORT ?=
INTEGRATION_TEST_WORKERS ?= 10
DEBUG ?= 1
+PERFORMANCE_TEST ?= false
+PERFORMANCE_TEST_ENABLED := $(if $(filter 1 true TRUE True yes YES Yes on ON On,$(PERFORMANCE_TEST)),true,false)
INTEGRATION_TEST_PARALLEL_FILES ?=
# Kept for callers that select a subset of files. Those files are added to
# the same bounded xdist pool; a second pool oversubscribes the VecDB service.
@@ -136,21 +138,25 @@ integration_test: reports_dirs ## Run live VecDB integration tests
@set +e; \
export RUN_INTEGRATION_TESTS=true; \
export VECDB_REQUIRE_INTEGRATION_TEST_ENV=true; \
+ export PERFORMANCE_TEST="$(PERFORMANCE_TEST)"; \
export PYTHONPATH="$(abspath $(SOURCE_DIR))"; \
export VECDB_TEST_RUN_ID="$${VECDB_TEST_RUN_ID:-$$(date +%Y%m%d%H%M%S)}"; \
- EXCLUDED_TEST_FILE="dev-tools/tests/integration/test_tkvcvecdb_sdk_sample_sanity.py"; \
TEST_FILES=(); \
if [ -n "$(INTEGRATION_TEST_PARALLEL_FILES)" ]; then \
for test_file in $(INTEGRATION_TEST_PARALLEL_FILES); do \
- if [ "$$test_file" != "$$EXCLUDED_TEST_FILE" ] && [ "$$(basename "$$test_file")" != "test_tkvcvecdb_sdk_sample_sanity.py" ]; then \
+ if [ "$(PERFORMANCE_TEST_ENABLED)" = "true" ] || [ "$$(basename "$$test_file")" != "test_tkvcvecdb_sdk_sample_sanity.py" ]; then \
TEST_FILES+=("$$test_file"); \
fi; \
done; \
else \
- while IFS= read -r test_file; do TEST_FILES+=("$$test_file"); done < <(find dev-tools/tests/integration -type f -name 'test_*.py' ! -name 'test_tkvcvecdb_sdk_sample_sanity.py' -print | sort); \
+ while IFS= read -r test_file; do \
+ if [ "$(PERFORMANCE_TEST_ENABLED)" = "true" ] || [ "$$(basename "$$test_file")" != "test_tkvcvecdb_sdk_sample_sanity.py" ]; then \
+ TEST_FILES+=("$$test_file"); \
+ fi; \
+ done < <(find dev-tools/tests/integration -type f -name 'test_*.py' -print | sort); \
fi; \
for test_file in $(INTEGRATION_TEST_SERIAL_FILES); do \
- if [ "$$test_file" != "$$EXCLUDED_TEST_FILE" ] && [ "$$(basename "$$test_file")" != "test_tkvcvecdb_sdk_sample_sanity.py" ] && [[ ! " $${TEST_FILES[*]} " =~ " $$test_file " ]]; then TEST_FILES+=("$$test_file"); fi; \
+ if { [ "$(PERFORMANCE_TEST_ENABLED)" = "true" ] || [ "$$(basename "$$test_file")" != "test_tkvcvecdb_sdk_sample_sanity.py" ]; } && [[ ! " $${TEST_FILES[*]} " =~ " $$test_file " ]]; then TEST_FILES+=("$$test_file"); fi; \
done; \
REPORT_ARG=(); \
if [ -n "$(REPORT)" ]; then \
@@ -163,6 +169,8 @@ integration_test: reports_dirs ## Run live VecDB integration tests
TEST_STATUS=0; \
if [ "$${#TEST_FILES[@]}" -eq 0 ]; then \
echo "No integration test files selected."; \
+ if [ "$(PERFORMANCE_TEST_ENABLED)" != "true" ]; then echo "Set PERFORMANCE_TEST=true to include performance-gated integration tests."; fi; \
+ TEST_STATUS=2; \
elif [ "$(DEBUG)" -gt 0 ]; then \
MASTER_PID=$$$$ $(PYTEST) \
-rfE \
@@ -190,7 +198,7 @@ integration_test: reports_dirs ## Run live VecDB integration tests
TEST_STATUS=$$?; \
fi; \
set -e; \
- if [ -n "$(REPORT)" ]; then \
+ if [ -n "$(REPORT)" ] && [ "$${#TEST_FILES[@]}" -gt 0 ]; then \
$(PYTHON) dev-tools/merge_junit_reports.py \
--input-dir "$(INTEGRATION_TEST_REPORT_SHARDS_DIR)" \
--output "$(INTEGRATION_TEST_REPORT_DIR)/pytest_report.xml"; \
diff --git a/examples/test_client.py b/examples/test_client.py
index b8647a7..c26b3f5 100644
--- a/examples/test_client.py
+++ b/examples/test_client.py
@@ -3,19 +3,45 @@
## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
##
-import os
+from getpass import getpass
-from oracle_vecdb import OracleVecDB, Configuration
+from oracle_vecdb import Configuration, OracleVecDB, get_vecdb_oauth_token
def main():
- # Use a valid HTTPS ORDS VecDB endpoint. Example pattern:
- # https://:/ords//_/db-api/stable/vecdb/
- config = Configuration(
- rest_url="https://example.com/ords/foo/_/db-api/stable/vecdb/",
- username="username",
- password=os.getenv("DB_PASSWORD"),
- )
+ # Replace these example values with your ORDS host, port, and schema.
+ host = "example.com"
+ port = 8443
+ schema = "foo"
+ ords_url = f"https://{host}:{port}/ords/{schema}"
+ rest_url = f"{ords_url}/_/db-api/stable/vecdb/"
+
+ # Choose OAuth client credentials or basic authentication.
+ ords_oauth = True
+
+ if ords_oauth:
+ # Create or obtain an OAuth client in ORDS. The client ID is entered
+ # visibly; getpass hides the client secret while you type it.
+ # TLS certificate verification is on by default. For a private CA,
+ # provide its bundle with the optional ssl_ca_cert argument below.
+ token_response = get_vecdb_oauth_token(
+ client_id=input("ORDS OAuth client ID: "),
+ client_secret=getpass("ORDS OAuth client secret: "),
+ token_url=f"{ords_url}/oauth/token",
+ timeout=(5, 30), # connect timeout, then read timeout
+ # ssl_ca_cert="/path/to/ca-bundle.pem",
+ )
+ config = Configuration(
+ rest_url=rest_url,
+ access_token=token_response["access_token"],
+ )
+ else:
+ # Use this branch only when your ORDS deployment requires basic auth.
+ config = Configuration(
+ rest_url=rest_url,
+ username=input("Vector DB Username: "),
+ password=getpass("Vector DB Password: "),
+ )
vecdb = OracleVecDB(config)
table_name = "TEST_TABLE"
diff --git a/pyproject.toml b/pyproject.toml
index a8e50dd..563969c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,7 +8,7 @@ authors = [
license = "UPL-1.0"
license-files = ["LICENSE.txt"]
readme = "README.md"
-requires-python = ">=3.10,<3.15"
+requires-python = ">=3.10,<3.16"
keywords = ["Oracle", "vecdb", "vector", "database", "ords", "sdk"]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -19,6 +19,7 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
+ "Programming Language :: Python :: 3.15",
"Topic :: Database :: Database Engines/Servers",
"Topic :: Software Development :: Libraries",
]
diff --git a/src/oracle_vecdb/__init__.py b/src/oracle_vecdb/__init__.py
index 493c7ac..e1b7f9a 100644
--- a/src/oracle_vecdb/__init__.py
+++ b/src/oracle_vecdb/__init__.py
@@ -4,9 +4,11 @@
##
from .client import OracleVecDB # noqa: F401
+from .auth import get_vecdb_oauth_token # noqa: F401
from . import data_types # noqa: F401
from .configuration import Configuration # noqa: F401
from .vecdb_errors import VecDBError # noqa: F401
from .vecdb_errors import InsecureConnectionError # noqa: F401
from .vecdb_errors import InvalidHostFormatError # noqa: F401
+from .vecdb_errors import OAuthTokenError # noqa: F401
from .vecdb_exception import VecDBException # noqa: F401
diff --git a/src/oracle_vecdb/auth.py b/src/oracle_vecdb/auth.py
new file mode 100644
index 0000000..9a61eba
--- /dev/null
+++ b/src/oracle_vecdb/auth.py
@@ -0,0 +1,432 @@
+"""Authentication helpers for Oracle VecDB."""
+
+from __future__ import annotations
+
+import json
+import ssl
+from typing import Any, Dict, Mapping, Optional, Tuple, Union
+from urllib.parse import urlsplit
+
+import urllib3
+
+from .vecdb_errors import OAuthTokenError
+
+Timeout = Union[float, Tuple[float, float]]
+OAuthTokenResponse = Dict[str, Any]
+
+_SUPPORTED_SOCKS_PROXIES = frozenset({"socks5", "socks5h", "socks4", "socks4a"})
+
+
+def get_vecdb_oauth_token(
+ *,
+ client_id: str,
+ client_secret: str,
+ token_url: str,
+ timeout: Timeout = 30.0,
+ verify_ssl: bool = True,
+ ssl_ca_cert: Optional[str] = None,
+ ca_cert_data: Optional[Union[str, bytes]] = None,
+ proxy: Optional[str] = None,
+ proxy_headers: Optional[Mapping[str, str]] = None,
+) -> OAuthTokenResponse:
+ """Request an OAuth bearer token using client credentials.
+
+ Args:
+ client_id: OAuth client identifier.
+ client_secret: OAuth client secret.
+ token_url: HTTPS URL of the OAuth token endpoint.
+ timeout: Request timeout in seconds, or ``(connect, read)`` seconds.
+ verify_ssl: Whether to verify the TLS certificate.
+ ssl_ca_cert: Optional path to a custom CA certificate bundle.
+ ca_cert_data: Optional CA certificate data.
+ proxy: Optional HTTP, HTTPS, or SOCKS proxy URL.
+ proxy_headers: Optional headers for the proxy request.
+
+ Returns:
+ The OAuth token response, including ``access_token``,
+ ``token_type``, and ``expires_in``.
+
+ Raises:
+ OAuthTokenError: If validation, transport, HTTP, JSON, or token
+ response validation fails.
+
+ Example:
+
+ .. code-block:: python
+
+ from oracle_vecdb import Configuration, OracleVecDB
+ from oracle_vecdb.auth import get_vecdb_oauth_token
+
+ token_response = get_vecdb_oauth_token(
+ client_id="example-client-id",
+ client_secret="example",
+ token_url=(
+ "https://localhost:8443/ords/vector3/oauth/token"
+ ),
+ )
+
+ config = Configuration(
+ rest_url=(
+ "https:///ords//_/db-api/"
+ "stable/vecdb/"
+ ),
+ access_token=token_response["access_token"],
+ )
+
+ client = OracleVecDB(config)
+
+ For a local server with a self-signed certificate, prefer
+ ``ssl_ca_cert``. Set ``verify_ssl=False`` only as an explicit
+ development-only exception.
+ """
+ safe_endpoint = _safe_endpoint(token_url)
+
+ _validate_credentials(client_id, client_secret, safe_endpoint)
+ _validate_token_url(token_url, safe_endpoint)
+ request_timeout = _build_timeout(timeout, safe_endpoint)
+
+ pool = None
+ response = None
+ transport_failed = False
+
+ try:
+ pool = _build_pool(
+ verify_ssl=verify_ssl,
+ ssl_ca_cert=ssl_ca_cert,
+ ca_cert_data=ca_cert_data,
+ proxy=proxy,
+ proxy_headers=proxy_headers,
+ )
+
+ authorization = urllib3.util.make_headers(
+ basic_auth=f"{client_id}:{client_secret}"
+ )["authorization"]
+
+ response = pool.request(
+ "POST",
+ token_url,
+ headers={
+ "Authorization": authorization,
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Accept": "application/json",
+ },
+ fields={"grant_type": "client_credentials"},
+ encode_multipart=False,
+ timeout=request_timeout,
+ preload_content=True,
+ )
+
+ if not 200 <= response.status < 300:
+ raise OAuthTokenError(
+ safe_endpoint,
+ f"HTTP {response.status} response",
+ status=response.status,
+ )
+
+ payload = _parse_response(response.data, safe_endpoint)
+ _validate_token_response(payload, safe_endpoint)
+ return payload
+
+ except OAuthTokenError:
+ raise
+ except (urllib3.exceptions.HTTPError, OSError, ValueError):
+ transport_failed = True
+ finally:
+ if response is not None:
+ response.release_conn()
+ if pool is not None:
+ pool.clear()
+
+ if transport_failed:
+ raise OAuthTokenError(
+ safe_endpoint,
+ "transport or request configuration failure",
+ ) from None
+
+
+def _validate_credentials(
+ client_id: str,
+ client_secret: str,
+ safe_endpoint: str,
+) -> None:
+ """Validate that OAuth credentials are non-empty strings.
+
+ Args:
+ client_id: OAuth client identifier.
+ client_secret: OAuth client secret.
+ safe_endpoint: Sanitized endpoint used in error messages.
+
+ Raises:
+ OAuthTokenError: If either credential is missing or invalid.
+ """
+ if not isinstance(client_id, str) or not client_id.strip():
+ raise OAuthTokenError(
+ safe_endpoint,
+ "client_id must be a non-empty string",
+ )
+
+ if not isinstance(client_secret, str) or not client_secret:
+ raise OAuthTokenError(
+ safe_endpoint,
+ "client_secret must be a non-empty string",
+ )
+
+
+def _validate_token_url(
+ token_url: str,
+ safe_endpoint: str,
+) -> None:
+ """Validate that the token endpoint is a safe HTTPS URL.
+
+ The endpoint must use HTTPS, contain a hostname, and must not contain
+ embedded username, password, or fragment values.
+
+ Args:
+ token_url: OAuth token endpoint to validate.
+ safe_endpoint: Sanitized endpoint used in error messages.
+
+ Raises:
+ OAuthTokenError: If the endpoint is invalid or insecure.
+ """
+ if not isinstance(token_url, str) or not token_url.strip():
+ raise OAuthTokenError(
+ safe_endpoint,
+ "token_url must be a non-empty HTTPS URL",
+ )
+
+ try:
+ parsed = urlsplit(token_url)
+ parsed.port
+ except (TypeError, ValueError):
+ raise OAuthTokenError(
+ safe_endpoint,
+ "token_url is not a valid URL",
+ ) from None
+
+ if (
+ parsed.scheme.lower() != "https"
+ or not parsed.hostname
+ or parsed.username is not None
+ or parsed.password is not None
+ or parsed.fragment
+ ):
+ raise OAuthTokenError(
+ safe_endpoint,
+ "token_url must be an HTTPS URL without embedded credentials",
+ )
+
+
+def _build_timeout(
+ timeout: Timeout,
+ safe_endpoint: str,
+) -> urllib3.Timeout:
+ """Create a urllib3 timeout configuration.
+
+ Args:
+ timeout: Either a positive total timeout in seconds or a
+ ``(connect, read)`` timeout tuple.
+ safe_endpoint: Sanitized endpoint used in error messages.
+
+ Returns:
+ A configured ``urllib3.Timeout`` instance.
+
+ Raises:
+ OAuthTokenError: If the timeout is invalid or non-positive.
+ """
+ if isinstance(timeout, bool):
+ raise OAuthTokenError(safe_endpoint, "timeout must be positive")
+
+ if isinstance(timeout, (int, float)):
+ if timeout <= 0:
+ raise OAuthTokenError(safe_endpoint, "timeout must be positive")
+ return urllib3.Timeout(total=float(timeout))
+
+ if (
+ isinstance(timeout, tuple)
+ and len(timeout) == 2
+ and all(
+ isinstance(value, (int, float))
+ and not isinstance(value, bool)
+ and value > 0
+ for value in timeout
+ )
+ ):
+ return urllib3.Timeout(
+ connect=float(timeout[0]),
+ read=float(timeout[1]),
+ )
+
+ raise OAuthTokenError(
+ safe_endpoint,
+ "timeout must be a positive number or (connect, read)",
+ )
+
+
+def _build_pool(
+ *,
+ verify_ssl: bool,
+ ssl_ca_cert: Optional[str],
+ ca_cert_data: Optional[Union[str, bytes]],
+ proxy: Optional[str],
+ proxy_headers: Optional[Mapping[str, str]],
+) -> Any:
+ """Create an urllib3 connection pool for the token request.
+
+ The pool is configured with TLS certificate verification, an optional
+ custom CA certificate, and an optional HTTP, HTTPS, or SOCKS proxy.
+
+ Args:
+ verify_ssl: Whether TLS certificates must be verified.
+ ssl_ca_cert: Optional path to a CA certificate bundle.
+ ca_cert_data: Optional CA certificate data.
+ proxy: Optional proxy URL.
+ proxy_headers: Optional headers sent to the proxy.
+
+ Returns:
+ An ``urllib3.PoolManager``, ``urllib3.ProxyManager``, or
+ ``SOCKSProxyManager`` instance.
+ """
+ pool_args: Dict[str, Any] = {
+ "cert_reqs": (ssl.CERT_REQUIRED if verify_ssl else ssl.CERT_NONE),
+ "ca_certs": ssl_ca_cert,
+ "ca_cert_data": ca_cert_data,
+ }
+
+ if proxy:
+ proxy_scheme = proxy.split("://", 1)[0].lower()
+ if proxy_scheme in _SUPPORTED_SOCKS_PROXIES:
+ from urllib3.contrib.socks import SOCKSProxyManager
+
+ pool_args["proxy_url"] = proxy
+ pool_args["headers"] = dict(proxy_headers or {})
+ return SOCKSProxyManager(**pool_args)
+
+ return urllib3.ProxyManager(
+ proxy_url=proxy,
+ proxy_headers=dict(proxy_headers or {}),
+ **pool_args,
+ )
+
+ return urllib3.PoolManager(**pool_args)
+
+
+def _parse_response(
+ response_data: bytes,
+ safe_endpoint: str,
+) -> OAuthTokenResponse:
+ """Parse and validate that an OAuth response is valid JSON.
+
+ This function verifies that the response body can be decoded as UTF-8,
+ contains valid JSON, and represents a JSON object. It does not validate
+ OAuth-specific fields such as ``access_token`` or ``expires_in``;
+ that is handled by ``_validate_token_response``.
+
+ Args:
+ response_data: Raw response body returned by the OAuth server.
+ safe_endpoint: Sanitized endpoint used in error messages.
+
+ Returns:
+ The decoded JSON object.
+
+ Raises:
+ OAuthTokenError: If the response is not valid UTF-8, valid JSON,
+ or a JSON object.
+ """
+ try:
+ payload = json.loads(response_data.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError):
+ raise OAuthTokenError(
+ safe_endpoint,
+ "OAuth response was not valid JSON",
+ ) from None
+
+ if not isinstance(payload, dict):
+ raise OAuthTokenError(
+ safe_endpoint,
+ "OAuth response must be a JSON object",
+ )
+
+ return payload
+
+
+def _validate_token_response(
+ payload: OAuthTokenResponse,
+ safe_endpoint: str,
+) -> None:
+ """Validate required fields in an OAuth bearer-token response.
+
+ Args:
+ payload: Parsed OAuth response object.
+ safe_endpoint: Sanitized endpoint used in error messages.
+
+ Raises:
+ OAuthTokenError: If the response does not contain a valid bearer
+ access token or contains an invalid expiration value.
+ """
+ access_token = payload.get("access_token")
+ if not isinstance(access_token, str) or not access_token:
+ raise OAuthTokenError(
+ safe_endpoint,
+ "OAuth response did not contain a valid access_token",
+ )
+
+ token_type = payload.get("token_type")
+ if not isinstance(token_type, str) or token_type.lower() != "bearer":
+ raise OAuthTokenError(
+ safe_endpoint,
+ "OAuth response did not contain bearer token_type",
+ )
+
+ expires_in = payload.get("expires_in")
+ if expires_in is not None and (
+ isinstance(expires_in, bool)
+ or not isinstance(expires_in, int)
+ or expires_in <= 0
+ ):
+ raise OAuthTokenError(
+ safe_endpoint,
+ "OAuth response contained an invalid expires_in value",
+ )
+
+
+def _safe_endpoint(token_url: str) -> str:
+ """Create a safe endpoint description for diagnostics.
+
+ Query strings, fragments, usernames, and passwords are excluded so the
+ returned value cannot accidentally expose credentials or sensitive URL
+ parameters. This function intentionally does not raise an exception:
+ it is used while validating input and while building error messages.
+
+ Args:
+ token_url: Candidate OAuth endpoint URL.
+
+ Returns:
+ A sanitized endpoint string, or ``""`` if
+ the URL cannot be parsed safely.
+ """
+ if not isinstance(token_url, str):
+ return ""
+
+ try:
+ parsed = urlsplit(token_url)
+ hostname = parsed.hostname
+
+ if not hostname:
+ return ""
+
+ try:
+ port = parsed.port
+ except ValueError:
+ return ""
+ except (TypeError, ValueError):
+ return ""
+
+ host = hostname
+ if ":" in host:
+ host = f"[{host}]"
+
+ authority = host
+ if port is not None:
+ authority = f"{authority}:{port}"
+
+ return f"{parsed.scheme}://{authority}{parsed.path or '/'}"
diff --git a/src/oracle_vecdb/client.py b/src/oracle_vecdb/client.py
index 8f95d30..c471ff9 100644
--- a/src/oracle_vecdb/client.py
+++ b/src/oracle_vecdb/client.py
@@ -1422,7 +1422,7 @@ def query(
self,
table_name: str,
query_by: Dict[str, Any],
- top_k: Union[float, int],
+ top_k: int,
filters: Optional[Dict[str, Any]] = None,
advanced_options: Optional[Dict[str, Any]] = None,
include_vectors: Optional[bool] = None,
diff --git a/src/oracle_vecdb/default_settings.py b/src/oracle_vecdb/default_settings.py
index 7380502..7aee56c 100644
--- a/src/oracle_vecdb/default_settings.py
+++ b/src/oracle_vecdb/default_settings.py
@@ -58,19 +58,3 @@ def get_default_args_for(cls, function_name: str) -> dict[str, Any]:
decorator can be applied to every public facade method safely.
"""
return deepcopy(dict(cls._DEFAULT_ARGUMENTS.get(function_name, {})))
-
- @classmethod
- def apply_operation_aware_defaults(
- cls,
- function_name: str,
- arguments: Mapping[str, Any],
- ) -> dict[str, Any]:
- """Return arguments with any operation-aware defaults materialized.
-
- Index distribution is intentionally not defaulted. Callers selecting
- ``INMEMORY GRAPH`` must explicitly choose a valid
- ``distribute_method`` so the SDK does not hide an incomplete request.
- The hook remains for future verified operation-aware defaults.
- """
- del function_name
- return dict(arguments)
diff --git a/src/oracle_vecdb/error_messages.py b/src/oracle_vecdb/error_messages.py
index a331ee3..4c74eeb 100644
--- a/src/oracle_vecdb/error_messages.py
+++ b/src/oracle_vecdb/error_messages.py
@@ -87,4 +87,18 @@
"cause": "The request violates a deterministic parameter constraint defined by the VecDB PL/SQL API.",
"action": "Correct the parameter value or combination and retry the operation.",
},
+ "VECDB-014": {
+ "message": (
+ "OAuth token request failed for {endpoint} "
+ "(HTTP status {status}): {detail}."
+ ),
+ "cause": (
+ "The OAuth server did not return a valid token response, "
+ "or the request could not be completed."
+ ),
+ "action": (
+ "Verify the token URL, client credentials, TLS/CA configuration, "
+ "timeout, proxy, and network access."
+ ),
+ },
}
diff --git a/src/oracle_vecdb/ords.py b/src/oracle_vecdb/ords.py
index a873655..2f7a26a 100644
--- a/src/oracle_vecdb/ords.py
+++ b/src/oracle_vecdb/ords.py
@@ -7,7 +7,9 @@
from __future__ import annotations
+import json
import math
+import re
from typing import Any, Dict, List, Optional, Union, cast
from .data_types import (
@@ -56,6 +58,7 @@
from .services.ords import models as _generated_models
from .types import UpsertVectorsRequestVectorsInner, VectorEmbedInputItem
from .ords_response_handlers import ORDSResponseHandler
+from .parameter_validation import validate_positive_top_k
from .vecdb_exception import VecDBException
DEFAULT_MAX_RETRY_DELAY = 60.0
@@ -69,6 +72,10 @@ class _CustomApiClient(ApiClient):
SDK default.
"""
+ _TABLE_RESPONSE_TYPES = frozenset(
+ {"VecDBTableNoLinks", "VecDBTableCollection"}
+ )
+
def call_api(
self,
method: Any,
@@ -89,6 +96,68 @@ def call_api(
_request_timeout,
)
+ @staticmethod
+ def _remove_implicit_distribution_sentinel(value: Any) -> bool:
+ """Drop the response-only ``NONE`` value materialized by PL/SQL.
+
+ The OpenAPI schema makes ``distribute_params`` optional and excludes
+ ``NONE`` from the allowed methods. PL/SQL may nevertheless include
+ this sentinel in a response when the caller omitted the object. That
+ response default is not explicit caller input and should not reach the
+ generated model's enum validator.
+ """
+ changed = False
+ if isinstance(value, dict):
+ distribution = value.get("distribute_params")
+ if (
+ isinstance(distribution, dict)
+ and distribution.get("distribute_method") == "NONE"
+ ):
+ del value["distribute_params"]
+ changed = True
+ for item in value.values():
+ changed = (
+ _CustomApiClient._remove_implicit_distribution_sentinel(
+ item
+ )
+ or changed
+ )
+ elif isinstance(value, list):
+ for item in value:
+ changed = (
+ _CustomApiClient._remove_implicit_distribution_sentinel(
+ item
+ )
+ or changed
+ )
+ return changed
+
+ def deserialize(
+ self,
+ response_text: str,
+ response_type: Any,
+ content_type: Optional[str],
+ ) -> Any:
+ """Normalize PL/SQL's implicit sentinel before generated parsing."""
+ response_name = (
+ response_type
+ if isinstance(response_type, str)
+ else getattr(response_type, "__name__", None)
+ )
+ if (
+ response_name in self._TABLE_RESPONSE_TYPES
+ and (content_type is None or "json" in content_type.lower())
+ and re.search(r'"distribute_method"\s*:\s*"NONE"', response_text)
+ ):
+ try:
+ response_data = json.loads(response_text)
+ except (TypeError, ValueError):
+ pass
+ else:
+ if self._remove_implicit_distribution_sentinel(response_data):
+ response_text = json.dumps(response_data)
+ return super().deserialize(response_text, response_type, content_type)
+
class ORDSSettings:
"""Mutable settings for ORDS response handling."""
@@ -268,22 +337,10 @@ def _validate_index_params_fields(index_params: Any) -> None:
"Unsupported vector_index_params field(s): "
f"{', '.join(sorted(unknown))}"
)
- organization = vector.get("organization")
- if organization == "INMEMORY GRAPH" and (
- "distribute_params" not in vector
- or vector["distribute_params"] is None
- ):
- raise ValueError(
- "vector_index_params.distribute_params cannot be None for "
- "organization 'INMEMORY GRAPH'; it must contain a valid "
- "distribute_method. Valid values are: 'ROWID RANGE', "
- "'SIMILARITY', 'PARTITION', "
- "'SUBPARTITION', 'DISTRIBUTE', 'AUTO'"
- )
if "distribute_params" in vector:
distribute = vector["distribute_params"]
- # ``distribute_params`` is nullable for non-graph index
- # organizations, but graph indexes require a method.
+ # ``distribute_params`` is nullable and is valid for either organization. When
+ # provided as an object, require the supported distribute_method field.
if distribute is None:
pass
elif not isinstance(distribute, dict):
@@ -650,13 +707,14 @@ def query(
self,
table_name: str,
query_by: Dict[str, Any],
- top_k: Union[float, int],
+ top_k: int,
filters: Optional[Dict[str, Any]] = None,
advanced_options: Optional[Dict[str, Any]] = None,
include_vectors: Optional[bool] = None,
output_selector: Optional[List[str]] = None,
debug_flags: Optional[Dict[str, str]] = None,
) -> QueryResponse:
+ validate_positive_top_k(top_k)
converted_debug_flags = self._convert_debug_flags(debug_flags)
self._require_optional_dict(query_by, "query_by")
self._require_optional_dict(filters, "filters")
diff --git a/src/oracle_vecdb/parameter_validation.py b/src/oracle_vecdb/parameter_validation.py
index 241747f..bb14d0a 100644
--- a/src/oracle_vecdb/parameter_validation.py
+++ b/src/oracle_vecdb/parameter_validation.py
@@ -29,7 +29,7 @@ class IndexOrganization(str, Enum):
class DistributeMethod(str, Enum):
- """Values accepted by the PL/SQL distribute-method validation."""
+ """Distribution methods defined by the public OpenAPI schema."""
ROWID_RANGE = "ROWID RANGE"
SIMILARITY = "SIMILARITY"
@@ -133,19 +133,6 @@ def validate_dependencies(self) -> "VectorIndexParams":
# validation must use that effective value, not merely the raw input.
organization = self.organization or IndexOrganization.PARTITIONS
- # When distribute_params is not present reject the request as
- # it would otherwise reach PL/SQL without a valid method.
- if (
- organization == IndexOrganization.INMEMORY_GRAPH
- and self.distribute_params is None
- ):
- raise ValueError(
- "vector_index_params.distribute_params cannot be None for "
- "organization 'INMEMORY GRAPH'; it must contain a valid "
- "distribute_method. Valid values are: "
- + ", ".join(f"'{method.value}'" for method in DistributeMethod)
- )
-
# Distribution is implemented by the HNSW / INMEMORY GRAPH path. IVF
# PARTITIONS indexes must not receive a distribute_params object.
if (
@@ -406,12 +393,8 @@ def validate_query_advanced_options(value: Any) -> None:
def validate_positive_top_k(value: Any) -> None:
- if (
- isinstance(value, bool)
- or not isinstance(value, (int, float))
- or value <= 0
- ):
- raise ValueError("top_k must be greater than zero")
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
+ raise ValueError("top_k must be a positive integer (>= 1)")
def validate_list_vectors_arguments(arguments: Mapping[str, Any]) -> None:
@@ -464,9 +447,11 @@ def validate_operation_arguments(
operation, {}
).items():
value = arguments.get(parameter_name)
- # A missing value and explicit None both map to PL/SQL DEFAULT NULL
- # where applicable, so no local JSON validation is needed in that case.
- if value is not None:
+ # Optional arguments may map None to a PL/SQL default, but query top_k
+ # is required and must be validated even when explicitly set to None.
+ if value is not None or (
+ operation == "query" and parameter_name == "top_k"
+ ):
validator(value)
if operation == "list_vectors":
diff --git a/src/oracle_vecdb/services/ords/__init__.py b/src/oracle_vecdb/services/ords/__init__.py
index 5f31f9b..1286150 100644
--- a/src/oracle_vecdb/services/ords/__init__.py
+++ b/src/oracle_vecdb/services/ords/__init__.py
@@ -1,3 +1,4 @@
+# coding: utf-8
##
## Copyright (c) 2026 Oracle and/or its affiliates.
## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
diff --git a/src/oracle_vecdb/services/ords/api/__init__.py b/src/oracle_vecdb/services/ords/api/__init__.py
index 1570523..e23f912 100644
--- a/src/oracle_vecdb/services/ords/api/__init__.py
+++ b/src/oracle_vecdb/services/ords/api/__init__.py
@@ -1,3 +1,4 @@
+# coding: utf-8
##
## Copyright (c) 2026 Oracle and/or its affiliates.
## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
diff --git a/src/oracle_vecdb/services/ords/api/vector_database_inference_operations_api.py b/src/oracle_vecdb/services/ords/api/vector_database_inference_operations_api.py
index 0d664a6..fe15797 100644
--- a/src/oracle_vecdb/services/ords/api/vector_database_inference_operations_api.py
+++ b/src/oracle_vecdb/services/ords/api/vector_database_inference_operations_api.py
@@ -1,3 +1,4 @@
+# coding: utf-8
##
## Copyright (c) 2026 Oracle and/or its affiliates.
## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
diff --git a/src/oracle_vecdb/services/ords/api/vector_database_models_api.py b/src/oracle_vecdb/services/ords/api/vector_database_models_api.py
index e59edb6..a5383e1 100644
--- a/src/oracle_vecdb/services/ords/api/vector_database_models_api.py
+++ b/src/oracle_vecdb/services/ords/api/vector_database_models_api.py
@@ -1,3 +1,4 @@
+# coding: utf-8
##
## Copyright (c) 2026 Oracle and/or its affiliates.
## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
diff --git a/src/oracle_vecdb/services/ords/api/vector_database_summary_api.py b/src/oracle_vecdb/services/ords/api/vector_database_summary_api.py
index d60ab53..edb23fc 100644
--- a/src/oracle_vecdb/services/ords/api/vector_database_summary_api.py
+++ b/src/oracle_vecdb/services/ords/api/vector_database_summary_api.py
@@ -1,3 +1,4 @@
+# coding: utf-8
##
## Copyright (c) 2026 Oracle and/or its affiliates.
## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
diff --git a/src/oracle_vecdb/services/ords/api/vector_database_vector_indexes_api.py b/src/oracle_vecdb/services/ords/api/vector_database_vector_indexes_api.py
index 204558a..9078719 100644
--- a/src/oracle_vecdb/services/ords/api/vector_database_vector_indexes_api.py
+++ b/src/oracle_vecdb/services/ords/api/vector_database_vector_indexes_api.py
@@ -1,3 +1,4 @@
+# coding: utf-8
##
## Copyright (c) 2026 Oracle and/or its affiliates.
## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
diff --git a/src/oracle_vecdb/services/ords/api/vector_database_vector_operations_api.py b/src/oracle_vecdb/services/ords/api/vector_database_vector_operations_api.py
index 6f1209d..daee37e 100644
--- a/src/oracle_vecdb/services/ords/api/vector_database_vector_operations_api.py
+++ b/src/oracle_vecdb/services/ords/api/vector_database_vector_operations_api.py
@@ -1,3 +1,4 @@
+# coding: utf-8
##
## Copyright (c) 2026 Oracle and/or its affiliates.
## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
diff --git a/src/oracle_vecdb/services/ords/api/vector_database_vector_search_api.py b/src/oracle_vecdb/services/ords/api/vector_database_vector_search_api.py
index 44abc17..9e5ca9b 100644
--- a/src/oracle_vecdb/services/ords/api/vector_database_vector_search_api.py
+++ b/src/oracle_vecdb/services/ords/api/vector_database_vector_search_api.py
@@ -1,3 +1,4 @@
+# coding: utf-8
##
## Copyright (c) 2026 Oracle and/or its affiliates.
## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
diff --git a/src/oracle_vecdb/services/ords/api/vector_database_vector_tables_api.py b/src/oracle_vecdb/services/ords/api/vector_database_vector_tables_api.py
index 2448814..c981fed 100644
--- a/src/oracle_vecdb/services/ords/api/vector_database_vector_tables_api.py
+++ b/src/oracle_vecdb/services/ords/api/vector_database_vector_tables_api.py
@@ -1,3 +1,4 @@
+# coding: utf-8
##
## Copyright (c) 2026 Oracle and/or its affiliates.
## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
diff --git a/src/oracle_vecdb/services/ords/api_client.py b/src/oracle_vecdb/services/ords/api_client.py
index de2f9c8..0c87636 100644
--- a/src/oracle_vecdb/services/ords/api_client.py
+++ b/src/oracle_vecdb/services/ords/api_client.py
@@ -1,3 +1,4 @@
+# coding: utf-8
##
## Copyright (c) 2026 Oracle and/or its affiliates.
## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
@@ -724,9 +725,9 @@ def __deserialize_file(self, response):
m = re.search(
r'filename=[\'"]?([^\'"\s]+)[\'"]?', content_disposition
)
- assert ( # nosec B101
+ assert (
m is not None
- ), "Unexpected 'content-disposition' header value"
+ ), "Unexpected 'content-disposition' header value" # nosec B101
filename = os.path.basename(
m.group(1)
) # Strip any directory traversal
diff --git a/src/oracle_vecdb/services/ords/api_response.py b/src/oracle_vecdb/services/ords/api_response.py
index 84a53da..49cf4e0 100644
--- a/src/oracle_vecdb/services/ords/api_response.py
+++ b/src/oracle_vecdb/services/ords/api_response.py
@@ -1,3 +1,4 @@
+# coding: utf-8
##
## Copyright (c) 2026 Oracle and/or its affiliates.
## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
diff --git a/src/oracle_vecdb/services/ords/configuration.py b/src/oracle_vecdb/services/ords/configuration.py
index 3b4ad6d..5944e2e 100644
--- a/src/oracle_vecdb/services/ords/configuration.py
+++ b/src/oracle_vecdb/services/ords/configuration.py
@@ -1,3 +1,4 @@
+# coding: utf-8
##
## Copyright (c) 2026 Oracle and/or its affiliates.
## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
@@ -482,6 +483,7 @@ def debug(self, value: bool) -> None:
"""
self.__debug = value
if self.__debug:
+ # if debug status is True, turn on debug logging
for _, logger in self.logger.items():
logger.setLevel(logging.DEBUG)
else:
diff --git a/src/oracle_vecdb/services/ords/exceptions.py b/src/oracle_vecdb/services/ords/exceptions.py
index c002f04..ef1e669 100644
--- a/src/oracle_vecdb/services/ords/exceptions.py
+++ b/src/oracle_vecdb/services/ords/exceptions.py
@@ -1,3 +1,4 @@
+# coding: utf-8
##
## Copyright (c) 2026 Oracle and/or its affiliates.
## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
diff --git a/src/oracle_vecdb/services/ords/models/__init__.py b/src/oracle_vecdb/services/ords/models/__init__.py
index 215586f..3b77a34 100644
--- a/src/oracle_vecdb/services/ords/models/__init__.py
+++ b/src/oracle_vecdb/services/ords/models/__init__.py
@@ -1,3 +1,4 @@
+# coding: utf-8
##
## Copyright (c) 2026 Oracle and/or its affiliates.
## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
diff --git a/src/oracle_vecdb/services/ords/models/create_index_request_index_params.py b/src/oracle_vecdb/services/ords/models/create_index_request_index_params.py
deleted file mode 100644
index 8c48edf..0000000
--- a/src/oracle_vecdb/services/ords/models/create_index_request_index_params.py
+++ /dev/null
@@ -1,92 +0,0 @@
-##
-## Copyright (c) 2026 Oracle and/or its affiliates.
-## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
-##
-
-# coding: utf-8
-
-"""
-ORDS Database API
-
-A Database Management and Monitoring REST API embedded into Oracle REST Data Services. The behaviour and response structure of these services are dictated by the database instance used at runtime. Please refer to Oracle Database Documentation https://docs.oracle.com/en/database/oracle/oracle-database/index.html for more detailed information on the particular version of Oracle Database used. Please also refer to Oracle REST Data Services product documentation to gain a full understanding of the REST capabilities provided. This includes, but is not limited to, resource security, connection pooling, pagination and data filtering.
-
-The version of the OpenAPI document is defined by oracle_vecdb.version.ORDS_RELEASE_VERSION.
-Generated by OpenAPI Generator (https://openapi-generator.tech)
-
-Do not edit the class manually.
-""" # noqa: E501
-
-from __future__ import annotations
-import pprint
-import re # noqa: F401
-import json
-
-from pydantic import BaseModel, ConfigDict, StrictStr
-from typing import Any, ClassVar, Dict, List, Optional, Set
-from typing_extensions import Self
-
-
-class CreateIndexRequestIndexParams(BaseModel):
- """
- Parameters required to create the vector index.
- """ # noqa: E501
-
- organization: Optional[StrictStr] = None
- advanced_params: Optional[Dict[str, Any]] = None
- __properties: ClassVar[List[str]] = ["organization", "advanced_params"]
-
- model_config = ConfigDict(
- populate_by_name=True,
- validate_assignment=True,
- protected_namespaces=(),
- )
-
- def to_str(self) -> str:
- """Returns the string representation of the model using alias"""
- return pprint.pformat(self.model_dump(by_alias=True))
-
- def to_json(self) -> str:
- """Returns the JSON representation of the model using alias"""
- # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
- return json.dumps(self.to_dict())
-
- @classmethod
- def from_json(cls, json_str: str) -> Optional[Self]:
- """Create an instance of CreateIndexRequestIndexParams from a JSON string"""
- return cls.from_dict(json.loads(json_str))
-
- def to_dict(self) -> Dict[str, Any]:
- """Return the dictionary representation of the model using alias.
-
- This has the following differences from calling pydantic's
- `self.model_dump(by_alias=True)`:
-
- * `None` is only added to the output dict for nullable fields that
- were set at model initialization. Other fields with value `None`
- are ignored.
- """
- excluded_fields: Set[str] = set([])
-
- _dict = self.model_dump(
- by_alias=True,
- exclude=excluded_fields,
- exclude_none=True,
- )
- return _dict
-
- @classmethod
- def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
- """Create an instance of CreateIndexRequestIndexParams from a dict"""
- if obj is None:
- return None
-
- if not isinstance(obj, dict):
- return cls.model_validate(obj)
-
- _obj = cls.model_validate(
- {
- "organization": obj.get("organization"),
- "advanced_params": obj.get("advanced_params"),
- }
- )
- return _obj
diff --git a/src/oracle_vecdb/services/ords/models/vec_db_table_no_links_index_params_advanced_params.py b/src/oracle_vecdb/services/ords/models/vec_db_table_no_links_index_params_advanced_params.py
deleted file mode 100644
index cf01454..0000000
--- a/src/oracle_vecdb/services/ords/models/vec_db_table_no_links_index_params_advanced_params.py
+++ /dev/null
@@ -1,120 +0,0 @@
-# coding: utf-8
-##
-## Copyright (c) 2026 Oracle and/or its affiliates.
-## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
-##
-
-
-"""
-ORDS Database API
-
-A Database Management and Monitoring REST API embedded into Oracle REST Data Services. The behaviour and response structure of these services are dictated by the database instance used at runtime. Please refer to Oracle Database Documentation https://docs.oracle.com/en/database/oracle/oracle-database/index.html for more detailed information on the particular version of Oracle Database used. Please also refer to Oracle REST Data Services product documentation to gain a full understanding of the REST capabilities provided. This includes, but is not limited to, resource security, connection pooling, pagination and data filtering.
-
-The version of the OpenAPI document is defined by oracle_vecdb.version.ORDS_RELEASE_VERSION.
-Generated by OpenAPI Generator (https://openapi-generator.tech)
-
-Do not edit the class manually.
-""" # noqa: E501
-
-from __future__ import annotations
-import pprint
-import re # noqa: F401
-import json
-
-from pydantic import BaseModel, ConfigDict, Field, StrictFloat, StrictInt
-from typing import Any, ClassVar, Dict, List, Optional, Union
-from typing import Optional, Set
-from typing_extensions import Self
-from pydantic_core import to_jsonable_python
-
-
-class VecDBTableNoLinksIndexParamsAdvancedParams(BaseModel):
- """
- VecDBTableNoLinksIndexParamsAdvancedParams
- """ # noqa: E501
-
- partitions: Optional[Union[StrictFloat, StrictInt]] = None
- ef_construction: Optional[Union[StrictFloat, StrictInt]] = Field(
- default=None, alias="efConstruction"
- )
- neighbors: Optional[Union[StrictFloat, StrictInt]] = None
- __properties: ClassVar[List[str]] = [
- "partitions",
- "efConstruction",
- "neighbors",
- ]
-
- model_config = ConfigDict(
- validate_by_name=True,
- validate_by_alias=True,
- validate_assignment=True,
- protected_namespaces=(),
- )
-
- def to_str(self) -> str:
- """Returns the string representation of the model using alias"""
- return pprint.pformat(self.model_dump(by_alias=True))
-
- def to_json(self) -> str:
- """Returns the JSON representation of the model using alias"""
- return json.dumps(to_jsonable_python(self.to_dict()))
-
- @classmethod
- def from_json(cls, json_str: str) -> Optional[Self]:
- """Create an instance of VecDBTableNoLinksIndexParamsAdvancedParams from a JSON string"""
- return cls.from_dict(json.loads(json_str))
-
- def to_dict(self) -> Dict[str, Any]:
- """Return the dictionary representation of the model using alias.
-
- This has the following differences from calling pydantic's
- `self.model_dump(by_alias=True)`:
-
- * `None` is only added to the output dict for nullable fields that
- were set at model initialization. Other fields with value `None`
- are ignored.
- """
- excluded_fields: Set[str] = set([])
-
- _dict = self.model_dump(
- by_alias=True,
- exclude=excluded_fields,
- exclude_none=True,
- )
- # set to None if partitions (nullable) is None
- # and model_fields_set contains the field
- if self.partitions is None and "partitions" in self.model_fields_set:
- _dict["partitions"] = None
-
- # set to None if ef_construction (nullable) is None
- # and model_fields_set contains the field
- if (
- self.ef_construction is None
- and "ef_construction" in self.model_fields_set
- ):
- _dict["efConstruction"] = None
-
- # set to None if neighbors (nullable) is None
- # and model_fields_set contains the field
- if self.neighbors is None and "neighbors" in self.model_fields_set:
- _dict["neighbors"] = None
-
- return _dict
-
- @classmethod
- def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
- """Create an instance of VecDBTableNoLinksIndexParamsAdvancedParams from a dict"""
- if obj is None:
- return None
-
- if not isinstance(obj, dict):
- return cls.model_validate(obj)
-
- _obj = cls.model_validate(
- {
- "partitions": obj.get("partitions"),
- "efConstruction": obj.get("efConstruction"),
- "neighbors": obj.get("neighbors"),
- }
- )
- return _obj
diff --git a/src/oracle_vecdb/services/ords/models/vector_index_params.py b/src/oracle_vecdb/services/ords/models/vector_index_params.py
index 653fc35..9d564fd 100644
--- a/src/oracle_vecdb/services/ords/models/vector_index_params.py
+++ b/src/oracle_vecdb/services/ords/models/vector_index_params.py
@@ -146,29 +146,24 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
if not isinstance(obj, dict):
return cls.model_validate(obj)
- _obj = cls.model_validate(
- {
- "vector_index_params": (
- VectorIndexParamsVectorIndexParams.from_dict(
- obj["vector_index_params"]
- )
- if obj.get("vector_index_params") is not None
- else None
- ),
- "metadata_index_params": (
- VectorIndexParamsMetadataIndexParams.from_dict(
- obj["metadata_index_params"]
- )
- if obj.get("metadata_index_params") is not None
- else None
- ),
- "parallel_creation": obj.get("parallel_creation"),
- "index_type": obj.get("index_type"),
- }
- )
- # store additional fields in additional_properties
- for _key in obj.keys():
- if _key not in cls.__properties:
- _obj.additional_properties[_key] = obj.get(_key)
-
- return _obj
+ values: Dict[str, Any] = {}
+ if "vector_index_params" in obj:
+ values["vector_index_params"] = (
+ VectorIndexParamsVectorIndexParams.from_dict(
+ obj["vector_index_params"]
+ )
+ )
+ if "metadata_index_params" in obj:
+ values["metadata_index_params"] = (
+ VectorIndexParamsMetadataIndexParams.from_dict(
+ obj["metadata_index_params"]
+ )
+ )
+ for field_name in ("parallel_creation", "index_type"):
+ if field_name in obj:
+ values[field_name] = obj[field_name]
+ instance = cls.model_validate(values)
+ for key, value in obj.items():
+ if key not in cls.__properties:
+ instance.additional_properties[key] = value
+ return instance
diff --git a/src/oracle_vecdb/services/ords/models/vector_index_params_vector_index_params.py b/src/oracle_vecdb/services/ords/models/vector_index_params_vector_index_params.py
index b196dae..4e4949b 100644
--- a/src/oracle_vecdb/services/ords/models/vector_index_params_vector_index_params.py
+++ b/src/oracle_vecdb/services/ords/models/vector_index_params_vector_index_params.py
@@ -215,7 +215,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
if not isinstance(obj, dict):
return cls.model_validate(obj)
- model_values: Dict[str, Any] = {}
+ values: Dict[str, Any] = {}
for field_name in (
"auto_index",
"organization",
@@ -227,20 +227,15 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"advanced_params",
):
if field_name in obj:
- model_values[field_name] = obj.get(field_name)
+ values[field_name] = obj[field_name]
if "distribute_params" in obj:
- model_values["distribute_params"] = (
+ values["distribute_params"] = (
VectorIndexParamsVectorIndexParamsDistributeParams.from_dict(
obj["distribute_params"]
)
- if obj.get("distribute_params") is not None
- else None
)
-
- _obj = cls.model_validate(model_values)
- # store additional fields in additional_properties
- for _key in obj.keys():
- if _key not in cls.__properties:
- _obj.additional_properties[_key] = obj.get(_key)
-
- return _obj
+ instance = cls.model_validate(values)
+ for key, value in obj.items():
+ if key not in cls.__properties:
+ instance.additional_properties[key] = value
+ return instance
diff --git a/src/oracle_vecdb/services/ords/models/vector_index_params_vector_index_params_distribute_params.py b/src/oracle_vecdb/services/ords/models/vector_index_params_vector_index_params_distribute_params.py
index b26f8ce..c551b2d 100644
--- a/src/oracle_vecdb/services/ords/models/vector_index_params_vector_index_params_distribute_params.py
+++ b/src/oracle_vecdb/services/ords/models/vector_index_params_vector_index_params_distribute_params.py
@@ -40,7 +40,7 @@ class VectorIndexParamsVectorIndexParamsDistributeParams(BaseModel):
def distribute_method_validate_enum(cls, value):
"""Validates the enum"""
if value is None:
- return value
+ raise ValueError("distribute_method must not be None")
if value not in set(
[
@@ -94,6 +94,14 @@ def to_dict(self) -> Dict[str, Any]:
exclude=excluded_fields,
exclude_none=True,
)
+ # set to None if distribute_method (nullable) is None
+ # and model_fields_set contains the field
+ if (
+ self.distribute_method is None
+ and "distribute_method" in self.model_fields_set
+ ):
+ _dict["distribute_method"] = None
+
return _dict
@classmethod
@@ -105,7 +113,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
if not isinstance(obj, dict):
return cls.model_validate(obj)
- _obj = cls.model_validate(
- {"distribute_method": obj.get("distribute_method")}
- )
- return _obj
+ values: Dict[str, Any] = {}
+ if "distribute_method" in obj:
+ values["distribute_method"] = obj["distribute_method"]
+ return cls.model_validate(values)
diff --git a/src/oracle_vecdb/services/ords/runtime_compat.py b/src/oracle_vecdb/services/ords/runtime_compat.py
index 76983bb..da9bbba 100644
--- a/src/oracle_vecdb/services/ords/runtime_compat.py
+++ b/src/oracle_vecdb/services/ords/runtime_compat.py
@@ -1,3 +1,4 @@
+# coding: utf-8
##
## Copyright (c) 2026 Oracle and/or its affiliates.
## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
diff --git a/src/oracle_vecdb/validation.py b/src/oracle_vecdb/validation.py
index 5e3029a..333a24c 100644
--- a/src/oracle_vecdb/validation.py
+++ b/src/oracle_vecdb/validation.py
@@ -56,8 +56,11 @@ def set_default_arguments(
Dictionary values are merged recursively so a caller can override one
nested option without discarding unspecified DefaultSettings defaults.
"""
- signature = inspect.signature(function)
defaults = DefaultSettings.get_default_args_for(function.__name__)
+ if not defaults:
+ return function
+
+ signature = inspect.signature(function)
unknown_parameters = set(defaults).difference(signature.parameters)
if unknown_parameters:
error = DefaultSettingsParameterMismatchError(
@@ -91,15 +94,6 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
arguments.arguments[parameter_name],
)
- # Apply any future operation-aware defaults after the public arguments
- # have been bound and unconditional defaults materialized. Index
- # distribution is deliberately validated as explicitly supplied.
- arguments.arguments.update(
- DefaultSettings.apply_operation_aware_defaults(
- function.__name__,
- arguments.arguments,
- )
- )
return function(*arguments.args, **arguments.kwargs)
return wrapper
diff --git a/src/oracle_vecdb/vecdb_errors.py b/src/oracle_vecdb/vecdb_errors.py
index 594fdcf..50b9170 100644
--- a/src/oracle_vecdb/vecdb_errors.py
+++ b/src/oracle_vecdb/vecdb_errors.py
@@ -204,3 +204,38 @@ def __init__(self, parameter_name: str, detail: str, *, locale=None):
locale=locale,
params={"parameter_name": parameter_name, "detail": detail},
)
+
+
+class OAuthTokenError(VecDBError):
+ """Raised when an OAuth token cannot be acquired or validated."""
+
+ def __init__(
+ self,
+ endpoint: str,
+ detail: str,
+ *,
+ status: Optional[int] = None,
+ locale=None,
+ ):
+ """Create an OAuth token error with sanitized diagnostic details.
+
+ Args:
+ endpoint: Sanitized OAuth endpoint description.
+ detail: Safe failure detail that excludes credentials and tokens.
+ status: Optional HTTP status returned by the token endpoint.
+ locale: Optional error-message locale.
+ """
+ self.endpoint = endpoint
+ self.status = status
+ super().__init__(
+ "",
+ error_code="VECDB-014",
+ locale=locale,
+ params={
+ "endpoint": endpoint,
+ "status": (
+ str(status) if status is not None else "unavailable"
+ ),
+ "detail": detail,
+ },
+ )
diff --git a/src/oracle_vecdb/vecdb_exception.py b/src/oracle_vecdb/vecdb_exception.py
index 1feba20..403fe61 100644
--- a/src/oracle_vecdb/vecdb_exception.py
+++ b/src/oracle_vecdb/vecdb_exception.py
@@ -7,13 +7,83 @@
from __future__ import annotations
+import errno
import json
import re
+import socket
+import ssl
import traceback
from collections.abc import Mapping
from typing import Any, Dict, Optional, cast
+from urllib3 import exceptions as urllib3_exceptions
+
_REDACTED = ""
+_CIRCULAR_REFERENCE = ""
+_NESTED_DIAGNOSTIC_TRUNCATED = ""
+_MAX_DIAGNOSTIC_DEPTH = 64
+_MISSING = object()
+
+_TRANSPORT_CONNECT_TIMEOUT = "connect_timeout"
+_TRANSPORT_READ_TIMEOUT = "read_timeout"
+_TRANSPORT_TIMEOUT = "timeout"
+_TRANSPORT_DNS_FAILURE = "dns_failure"
+_TRANSPORT_CONNECT_FAILURE = "connect_failure"
+_TRANSPORT_CONNECTION_RESET = "connection_reset"
+_TRANSPORT_SOCKET_FAILURE = "socket_failure"
+_TRANSPORT_SSL_ERROR = "ssl_error"
+_TRANSPORT_CERTIFICATE_ERROR = "certificate_error"
+
+_TRANSPORT_TIMEOUT_CATEGORIES = {
+ _TRANSPORT_CONNECT_TIMEOUT,
+ _TRANSPORT_READ_TIMEOUT,
+ _TRANSPORT_TIMEOUT,
+}
+_RETRYABLE_TRANSPORT_CATEGORIES = {
+ _TRANSPORT_CONNECT_TIMEOUT,
+ _TRANSPORT_READ_TIMEOUT,
+ _TRANSPORT_TIMEOUT,
+ _TRANSPORT_CONNECT_FAILURE,
+ _TRANSPORT_CONNECTION_RESET,
+ _TRANSPORT_SOCKET_FAILURE,
+}
+_TRANSPORT_ERROR_CODES = {
+ _TRANSPORT_CONNECT_TIMEOUT: "TIMEOUT",
+ _TRANSPORT_READ_TIMEOUT: "TIMEOUT",
+ _TRANSPORT_TIMEOUT: "TIMEOUT",
+ _TRANSPORT_DNS_FAILURE: "DNS_FAILURE",
+ _TRANSPORT_CONNECT_FAILURE: "CONNECTION_FAILURE",
+ _TRANSPORT_CONNECTION_RESET: "CONNECTION_RESET",
+ _TRANSPORT_SOCKET_FAILURE: "SOCKET_FAILURE",
+ _TRANSPORT_SSL_ERROR: "SSL_ERROR",
+ _TRANSPORT_CERTIFICATE_ERROR: "CERTIFICATE_ERROR",
+}
+_TRANSPORT_GUIDANCE = {
+ _TRANSPORT_DNS_FAILURE: (
+ "The endpoint hostname could not be resolved.",
+ "Verify the host name, DNS configuration, and network access.",
+ ),
+ _TRANSPORT_CONNECT_FAILURE: (
+ "The SDK could not establish a network connection to the endpoint.",
+ "Verify the endpoint and network access, then retry if the failure is transient.",
+ ),
+ _TRANSPORT_CONNECTION_RESET: (
+ "The network connection was reset before the operation completed.",
+ "Retry only when the operation is safe to repeat, and verify network stability if it persists.",
+ ),
+ _TRANSPORT_SOCKET_FAILURE: (
+ "The network socket failed before the operation completed.",
+ "Retry only when the operation is safe to repeat, and verify network stability if it persists.",
+ ),
+ _TRANSPORT_SSL_ERROR: (
+ "TLS/SSL negotiation with the endpoint failed.",
+ "Verify TLS settings, the CA bundle, and client certificate configuration.",
+ ),
+ _TRANSPORT_CERTIFICATE_ERROR: (
+ "The endpoint certificate could not be validated.",
+ "Verify the CA bundle, certificate chain, hostname, and client certificate configuration.",
+ ),
+}
# These names identify values that must not survive in an exception object.
# Keep exact identifiers such as ``request_id``, ``connection_id``, and
@@ -116,27 +186,6 @@
)
-def _is_timeout_exception(error: Optional[BaseException]) -> bool:
- """Return whether an exception represents a client-side timeout."""
- if error is None:
- return False
- if isinstance(error, TimeoutError):
- return True
- if any("timeout" in cls.__name__.lower() for cls in type(error).__mro__):
- return True
-
- for attribute in ("reason", "original_error", "__cause__", "__context__"):
- nested = getattr(error, attribute, None)
- if isinstance(nested, BaseException) and nested is not error:
- if _is_timeout_exception(nested):
- return True
-
- # urllib3's MaxRetryError renders the underlying ReadTimeoutError in its
- # text, even when the wrapper does not retain it as a typed cause.
- text = str(error).lower()
- return "timed out" in text or "timeout=" in text
-
-
def _normalized_key(value: Any) -> str:
text = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", str(value))
text = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", text)
@@ -154,6 +203,312 @@ def _is_sensitive_key(value: Any) -> bool:
)
+def _safe_string(value: Any) -> str:
+ """Return a string for diagnostics without trusting ``str``/``repr``."""
+ if isinstance(value, str):
+ return value
+ rendered = _MISSING
+ try:
+ rendered = str(value)
+ except Exception: # pragma: no cover - exercised by hostile objects
+ rendered = _MISSING
+ if isinstance(rendered, str):
+ return rendered
+ rendered = _MISSING
+ try:
+ rendered = repr(value)
+ except Exception: # pragma: no cover - exercised by hostile objects
+ rendered = _MISSING
+ if isinstance(rendered, str):
+ return rendered
+ try:
+ type_name = type(value).__name__
+ except Exception: # pragma: no cover - type() is normally infallible
+ type_name = "object"
+ return f""
+
+
+def _safe_attribute(value: Any, name: str, default: Any = None) -> Any:
+ """Read a third-party attribute without allowing it to mask an error."""
+ try:
+ return getattr(value, name)
+ except Exception: # pragma: no cover - exercised by hostile objects
+ return default
+
+
+def _exception_chain(error: Optional[BaseException]) -> list[BaseException]:
+ """Return an exception and its wrapped causes without retaining them."""
+ if error is None:
+ return []
+ pending = [error]
+ seen: set[int] = set()
+ chain: list[BaseException] = []
+ while pending:
+ current = pending.pop(0)
+ if not isinstance(current, BaseException) or id(current) in seen:
+ continue
+ seen.add(id(current))
+ chain.append(current)
+ for attribute in (
+ "reason",
+ "original_error",
+ "__cause__",
+ "__context__",
+ ):
+ nested = _safe_attribute(current, attribute)
+ if isinstance(nested, BaseException) and id(nested) not in seen:
+ pending.append(nested)
+ arguments = _safe_attribute(current, "args", ())
+ if isinstance(arguments, tuple):
+ for argument in arguments:
+ if (
+ isinstance(argument, BaseException)
+ and id(argument) not in seen
+ ):
+ pending.append(argument)
+ return chain
+
+
+def _has_errno(error: BaseException, values: set[int]) -> bool:
+ """Return whether a transport error exposes one of the given errnos."""
+ value = _safe_attribute(error, "errno")
+ try:
+ return value in values
+ except (TypeError, ValueError):
+ return False
+
+
+def _transport_classification(
+ error: Optional[BaseException],
+) -> tuple[Optional[str], Optional[str]]:
+ """Classify a transport exception and return its category and source type."""
+ nodes = _exception_chain(error)
+ if not nodes:
+ return None, None
+
+ rendered_nodes = [(_safe_string(node).lower(), node) for node in nodes]
+
+ def first_node(predicate: Any) -> Optional[BaseException]:
+ return next((node for node in nodes if predicate(node)), None)
+
+ def source_type(node: Optional[BaseException]) -> Optional[str]:
+ return type(node).__name__ if node is not None else None
+
+ certificate_markers = (
+ "certificate verify failed",
+ "certificate validation failed",
+ "unable to get local issuer",
+ "hostname mismatch",
+ "certificate is not yet valid",
+ )
+ certificate_node = first_node(
+ lambda node: isinstance(node, ssl.SSLCertVerificationError)
+ )
+ if certificate_node is not None:
+ return _TRANSPORT_CERTIFICATE_ERROR, source_type(certificate_node)
+ certificate_text_node = next(
+ (
+ node
+ for text, node in rendered_nodes
+ if any(marker in text for marker in certificate_markers)
+ ),
+ None,
+ )
+ if certificate_text_node is not None:
+ # A generated ApiException often contains the certificate text while
+ # retaining the concrete urllib3 SSL exception as its cause. Prefer
+ # that cause as the diagnostic type without losing the category.
+ ssl_node = first_node(
+ lambda node: isinstance(
+ node, (ssl.SSLError, urllib3_exceptions.SSLError)
+ )
+ )
+ return _TRANSPORT_CERTIFICATE_ERROR, source_type(
+ ssl_node or certificate_text_node
+ )
+
+ ssl_node = first_node(
+ lambda node: isinstance(
+ node, (ssl.SSLError, urllib3_exceptions.SSLError)
+ )
+ )
+ if ssl_node is not None:
+ return _TRANSPORT_SSL_ERROR, source_type(ssl_node)
+ ssl_text_node = next(
+ (
+ node
+ for text, node in rendered_nodes
+ if "sslerror" in type(node).__name__.lower() or "tls" in text
+ ),
+ None,
+ )
+ if ssl_text_node is not None:
+ return _TRANSPORT_SSL_ERROR, source_type(ssl_text_node)
+
+ dns_markers = (
+ "failed to resolve",
+ "name or service not known",
+ "nodename nor servname",
+ "temporary failure in name resolution",
+ "getaddrinfo failed",
+ )
+ dns_node = first_node(
+ lambda node: isinstance(
+ node, (socket.gaierror, urllib3_exceptions.NameResolutionError)
+ )
+ )
+ if dns_node is not None:
+ return _TRANSPORT_DNS_FAILURE, source_type(dns_node)
+ dns_text_node = next(
+ (
+ node
+ for text, node in rendered_nodes
+ if "nameresolutionerror" in type(node).__name__.lower()
+ or any(marker in text for marker in dns_markers)
+ ),
+ None,
+ )
+ if dns_text_node is not None:
+ return _TRANSPORT_DNS_FAILURE, source_type(dns_text_node)
+
+ read_timeout_node = first_node(
+ lambda node: isinstance(node, urllib3_exceptions.ReadTimeoutError)
+ )
+ if read_timeout_node is not None:
+ return _TRANSPORT_READ_TIMEOUT, source_type(read_timeout_node)
+ read_timeout_text_node = next(
+ (
+ node
+ for text, node in rendered_nodes
+ if "readtimeout" in type(node).__name__.lower()
+ or "read timed out" in text
+ ),
+ None,
+ )
+ if read_timeout_text_node is not None:
+ return _TRANSPORT_READ_TIMEOUT, source_type(read_timeout_text_node)
+
+ reset_node = first_node(lambda node: isinstance(node, ConnectionResetError))
+ if reset_node is not None:
+ return _TRANSPORT_CONNECTION_RESET, source_type(reset_node)
+ reset_text_node = next(
+ (
+ node
+ for text, node in rendered_nodes
+ if "connection reset" in text or "connectionreseterror" in text
+ ),
+ None,
+ )
+ if reset_text_node is not None:
+ return _TRANSPORT_CONNECTION_RESET, source_type(reset_text_node)
+
+ connect_failure_node = first_node(
+ lambda node: isinstance(
+ node,
+ (
+ urllib3_exceptions.NewConnectionError,
+ ConnectionRefusedError,
+ urllib3_exceptions.ProxyError,
+ ),
+ )
+ or _has_errno(
+ node,
+ {errno.ECONNREFUSED, errno.ENETUNREACH, errno.EHOSTUNREACH},
+ )
+ )
+ if connect_failure_node is not None:
+ return _TRANSPORT_CONNECT_FAILURE, source_type(connect_failure_node)
+ connect_failure_text_node = next(
+ (
+ node
+ for text, node in rendered_nodes
+ if "connection refused" in text or "failed to establish" in text
+ ),
+ None,
+ )
+ if connect_failure_text_node is not None:
+ return _TRANSPORT_CONNECT_FAILURE, source_type(
+ connect_failure_text_node
+ )
+
+ connect_timeout_node = first_node(
+ lambda node: isinstance(node, urllib3_exceptions.ConnectTimeoutError)
+ )
+ if connect_timeout_node is not None:
+ return _TRANSPORT_CONNECT_TIMEOUT, source_type(connect_timeout_node)
+ connect_timeout_text_node = next(
+ (
+ node
+ for text, node in rendered_nodes
+ if "connecttimeout" in type(node).__name__.lower()
+ or "connect timed out" in text
+ or "connection timeout" in text
+ ),
+ None,
+ )
+ if connect_timeout_text_node is not None:
+ return _TRANSPORT_CONNECT_TIMEOUT, source_type(
+ connect_timeout_text_node
+ )
+
+ socket_markers = (
+ "broken pipe",
+ "connection aborted",
+ "remote end closed connection",
+ "connection broken",
+ )
+ socket_node = first_node(
+ lambda node: isinstance(
+ node,
+ (
+ BrokenPipeError,
+ ConnectionAbortedError,
+ ConnectionError,
+ urllib3_exceptions.ProtocolError,
+ ),
+ )
+ or _has_errno(node, {errno.EPIPE, errno.ECONNABORTED, errno.ENOTCONN})
+ )
+ if socket_node is not None:
+ return _TRANSPORT_SOCKET_FAILURE, source_type(socket_node)
+ socket_text_node = next(
+ (
+ node
+ for text, node in rendered_nodes
+ if any(marker in text for marker in socket_markers)
+ ),
+ None,
+ )
+ if socket_text_node is not None:
+ return _TRANSPORT_SOCKET_FAILURE, source_type(socket_text_node)
+
+ timeout_node = first_node(
+ lambda node: isinstance(
+ node, (TimeoutError, urllib3_exceptions.TimeoutError)
+ )
+ )
+ if timeout_node is not None:
+ return _TRANSPORT_TIMEOUT, source_type(timeout_node)
+ timeout_text_node = next(
+ (
+ node
+ for text, node in rendered_nodes
+ if "timed out" in text or "timeout=" in text
+ ),
+ None,
+ )
+ if timeout_text_node is not None:
+ return _TRANSPORT_TIMEOUT, source_type(timeout_text_node)
+
+ return None, None
+
+
+def _is_timeout_exception(error: Optional[BaseException]) -> bool:
+ """Return whether an exception represents a client-side timeout."""
+ category, _ = _transport_classification(error)
+ return category in _TRANSPORT_TIMEOUT_CATEGORIES
+
+
class _SanitizedServiceError(Exception):
"""Safe, displayable copy of a transport exception."""
@@ -165,7 +520,9 @@ def __init__(self, class_name: str, **fields: Any) -> None:
@staticmethod
def _safe_text(value: Any) -> str:
- return value if isinstance(value, str) else str(value or "")
+ if value is None:
+ return ""
+ return _safe_string(value)
def __str__(self) -> str:
return self._safe_text(getattr(self, "reason", None))
@@ -203,6 +560,15 @@ def __init__(
self.operation = operation
self.arguments = self._sanitize_arguments(arguments)
self.service_name = service_name
+ (
+ self.transport_category,
+ self.transport_error_type,
+ ) = _transport_classification(service_error)
+ self.retryable: Optional[bool] = (
+ self.transport_category in _RETRYABLE_TRANSPORT_CATEGORIES
+ if self.transport_category is not None
+ else None
+ )
self.service_error = self._safe_service_error(service_error)
self.service_error_class_name = service_error_class_name
# Keep only a sanitized snapshot. Retaining the transport exception
@@ -260,22 +626,26 @@ def from_service_error(
except TypeError:
wrapper_type = cls
values = {
- "status": getattr(error, "status", None),
- "reason": getattr(error, "reason", None),
- "body": getattr(error, "body", None),
- "data": getattr(error, "data", None),
- "headers": getattr(error, "headers", None),
+ "status": _safe_attribute(error, "status"),
+ "reason": _safe_attribute(error, "reason"),
+ "body": _safe_attribute(error, "body"),
+ "data": _safe_attribute(error, "data"),
+ "headers": _safe_attribute(error, "headers"),
"operation": operation,
"arguments": cls._sanitize_arguments(arguments),
"service_name": service_name,
"service_error": error,
"service_error_class_name": type(error).__name__,
- "service_trace": "".join(
+ "service_trace": "",
+ }
+ try:
+ values["service_trace"] = "".join(
traceback.format_exception(
type(error), error, error.__traceback__
)
- ),
- }
+ )
+ except Exception: # pragma: no cover - exercised by hostile objects
+ values["service_trace"] = _safe_string(error)
try:
return wrapper_type(**values)
except TypeError:
@@ -291,23 +661,23 @@ def _safe_service_error(
) -> Optional[BaseException]:
if error is None or isinstance(error, _SanitizedServiceError):
return error
- fields = {
- name: VecDBException._redact_value(getattr(error, name, None))
- for name in (
- "status",
- "reason",
- "body",
- "data",
- "headers",
- "error_code",
- "error_message",
- "error_type",
- "error_instance",
- )
- if hasattr(error, name)
- }
+ fields = {}
+ for name in (
+ "status",
+ "reason",
+ "body",
+ "data",
+ "headers",
+ "error_code",
+ "error_message",
+ "error_type",
+ "error_instance",
+ ):
+ field = _safe_attribute(error, name, _MISSING)
+ if field is not _MISSING:
+ fields[name] = VecDBException._redact_value(field)
if "reason" not in fields:
- fields["reason"] = VecDBException._redact_value(str(error))
+ fields["reason"] = VecDBException._redact_value(_safe_string(error))
snapshot = _SanitizedServiceError(type(error).__name__, **fields)
# Preserve the useful ``isinstance`` compatibility promised by the
# old public attribute without keeping the original object alive.
@@ -335,37 +705,100 @@ def _safe_service_error(
return snapshot
@staticmethod
- def _redact_value(value: Any, key: str = "") -> Any:
+ def _redact_value(
+ value: Any,
+ key: str = "",
+ _seen: Optional[set[int]] = None,
+ _depth: int = 0,
+ ) -> Any:
"""Recursively remove secrets from values retained for diagnostics."""
if _is_sensitive_key(key):
return _REDACTED
+ if _seen is None:
+ _seen = set()
+ if _depth >= _MAX_DIAGNOSTIC_DEPTH:
+ return _NESTED_DIAGNOSTIC_TRUNCATED
if isinstance(value, Mapping):
- return {
- item_key: VecDBException._redact_value(item, str(item_key))
- for item_key, item in value.items()
- }
+ value_id = id(value)
+ if value_id in _seen:
+ return _CIRCULAR_REFERENCE
+ _seen.add(value_id)
+ sanitized_mapping: Dict[Any, Any] = {}
+ try:
+ for item_key, item in value.items():
+ safe_key = (
+ item_key
+ if type(item_key) in (str, int, float, bool)
+ or item_key is None
+ else _safe_string(item_key)
+ )
+ sanitized_mapping[safe_key] = VecDBException._redact_value(
+ item,
+ _safe_string(safe_key),
+ _seen,
+ _depth + 1,
+ )
+ except Exception: # pragma: no cover - hostile mappings
+ sanitized_mapping[""] = (
+ ""
+ )
+ finally:
+ _seen.discard(value_id)
+ return sanitized_mapping
if isinstance(value, (list, tuple)):
- sanitized = [VecDBException._redact_value(item) for item in value]
- return tuple(sanitized) if isinstance(value, tuple) else sanitized
+ value_id = id(value)
+ if value_id in _seen:
+ return _CIRCULAR_REFERENCE
+ _seen.add(value_id)
+ try:
+ sanitized_collection = [
+ VecDBException._redact_value(item, "", _seen, _depth + 1)
+ for item in value
+ ]
+ except Exception: # pragma: no cover - hostile collections
+ sanitized_collection = [""]
+ finally:
+ _seen.discard(value_id)
+ return (
+ tuple(sanitized_collection)
+ if isinstance(value, tuple)
+ else sanitized_collection
+ )
if isinstance(value, (bytes, bytearray, memoryview)):
- value = bytes(value).decode("utf-8", errors="replace")
+ try:
+ value = bytes(value).decode("utf-8", errors="replace")
+ except Exception: # pragma: no cover - hostile byte containers
+ return ""
+ if value is None or isinstance(value, (bool, int, float)):
+ return value
if isinstance(value, str):
try:
parsed = json.loads(value)
- except (TypeError, ValueError):
+ except (TypeError, ValueError, RecursionError):
parsed = None
if isinstance(parsed, (dict, list)):
sanitized = VecDBException._redact_value(parsed)
- return json.dumps(sanitized)
+ return VecDBException._safe_json(sanitized)
return VecDBException._redact_diagnostic_text(value)
for serializer_name in ("model_dump", "to_dict"):
- serializer = getattr(value, serializer_name, None)
+ serializer = _safe_attribute(value, serializer_name)
if callable(serializer):
+ value_id = id(value)
+ if value_id in _seen:
+ return _CIRCULAR_REFERENCE
+ _seen.add(value_id)
+ serialized = _MISSING
try:
- return VecDBException._redact_value(serializer(), key)
- except (TypeError, ValueError):
- break
- return value
+ serialized = serializer()
+ if serialized is not _MISSING:
+ return VecDBException._redact_value(
+ serialized, key, _seen, _depth + 1
+ )
+ except Exception: # pragma: no cover - hostile serializers
+ serialized = _MISSING
+ finally:
+ _seen.discard(value_id)
+ return VecDBException._redact_diagnostic_text(_safe_string(value))
@staticmethod
def _sanitize_arguments(arguments: Any) -> Any:
@@ -507,9 +940,16 @@ def _customize_error(self) -> None:
original, "error_instance", None
)
- timeout_error = _is_timeout_exception(original)
+ transport_category = getattr(self, "transport_category", None)
+ timeout_error = transport_category in _TRANSPORT_TIMEOUT_CATEGORIES
+ if transport_category:
+ self.error_code = self.error_code or _TRANSPORT_ERROR_CODES.get(
+ transport_category
+ )
if timeout_error:
- detail = self._redact_diagnostic_text(str(original)).strip()
+ detail = self._redact_diagnostic_text(
+ _safe_string(original)
+ ).strip()
operation = self.operation or "the request"
self.error_code = self.error_code or "TIMEOUT"
self.error_message = (
@@ -521,13 +961,24 @@ def _customize_error(self) -> None:
"timed out",
}:
self.error_message += f" Details: {detail}"
+ elif transport_category and not self.error_message:
+ detail = self._redact_diagnostic_text(
+ _safe_string(original)
+ ).strip()
+ operation = self.operation or "the request"
+ category_label = transport_category.replace("_", " ")
+ self.error_message = (
+ f"Network {category_label} while executing '{operation}'."
+ )
+ if detail:
+ self.error_message += f" Details: {detail}"
elif not self.error_message:
- self.error_message = str(
+ self.error_message = _safe_string(
self.reason or self.body or original or "Request failed"
).strip()
self.error_message = self._redact_diagnostic_text(self.error_message)
if not self.error_code:
- match = re.search(r"ORA-\d{5}", str(self.body or ""), re.I)
+ match = re.search(r"ORA-\d{5}", _safe_string(self.body), re.I)
self.error_code = match.group(0).upper() if match else None
status = self.status if self.status is not None else "unknown"
@@ -541,6 +992,8 @@ def _customize_error(self) -> None:
"Increase Configuration.timeout for slow operations, then "
"retry."
)
+ elif transport_category in _TRANSPORT_GUIDANCE:
+ self.cause, self.action = _TRANSPORT_GUIDANCE[transport_category]
else:
self.cause, self.action = guidance_for_status(self.status)
self._has_operation_guidance = False
@@ -652,17 +1105,20 @@ def _format_service_error(
) -> str:
"""Render the original ORDS error once, without generated duplication."""
error = self.service_error
- name = self.service_error_class_name or type(error).__name__
- status = getattr(error, "status", None)
- body = getattr(error, "body", None)
- data = getattr(error, "data", None)
- reason = getattr(error, "reason", None)
- message = f"{name}: ({status})"
+ name = self.service_error_class_name or _safe_string(
+ type(error).__name__
+ )
+ status = _safe_attribute(error, "status")
+ body = _safe_attribute(error, "body")
+ data = _safe_attribute(error, "data")
+ reason = _safe_attribute(error, "reason")
+ message = f"{name}: ({_safe_string(status)})"
if self.error_code == "TIMEOUT":
return f"{name}: client-side request timeout\nReason: {self.error_message}"
if type(error).__module__.split(".", 1)[0] == "pydantic_core":
errors = getattr(error, "errors", None)
if callable(errors):
+ formatted_validation_details = None
try:
safe_errors = [
{
@@ -672,28 +1128,23 @@ def _format_service_error(
}
for issue in errors()
]
- return (
- message
- + "\n"
- + json.dumps(
- {
- "title": getattr(error, "title", name),
- "errors": safe_errors,
- },
- indent=4,
- default=str,
- )
+ formatted_validation_details = self._safe_json(
+ {
+ "title": _safe_attribute(error, "title", name),
+ "errors": safe_errors,
+ },
+ indent=4,
)
- except (TypeError, ValueError):
- # Fall through to the generic formatter if a third-party
- # validation error exposes an incompatible errors() API.
- pass
+ except Exception:
+ formatted_validation_details = None
+ if formatted_validation_details is not None:
+ return message + "\n" + formatted_validation_details
payload = self._response_payload(body, data)
if include_full_ords_response and isinstance(payload, dict):
return (
message
+ "\n"
- + json.dumps(self._redact_value(payload), indent=4, default=str)
+ + self._safe_json(self._redact_value(payload), indent=4)
)
response_message = (
@@ -738,36 +1189,68 @@ def _format_service_error(
)
if payload.get("instance") is not None:
response_body["instance"] = payload["instance"]
- message += "\n" + json.dumps(response_body, indent=4)
+ message += "\n" + self._safe_json(response_body, indent=4)
elif reason:
- message += f"\nReason: {reason}"
+ message += f"\nReason: {_safe_string(reason)}"
else:
- details = str(error)
+ details = _safe_string(error)
if error is not None and not details and error.args:
- details = " ".join(str(value) for value in error.args)
+ details = " ".join(_safe_string(value) for value in error.args)
if details:
message += f"\n{details}"
return message
+ @staticmethod
+ def _safe_json_default(value: Any) -> str:
+ """Convert an unsupported JSON value without raising while logging."""
+ return VecDBException._redact_diagnostic_text(_safe_string(value))
+
+ @staticmethod
+ def _safe_json(value: Any, *, indent: Optional[int] = None) -> str:
+ """Serialize diagnostics with a final non-throwing text fallback."""
+ try:
+ return json.dumps(
+ value,
+ indent=indent,
+ default=VecDBException._safe_json_default,
+ )
+ except Exception: # pragma: no cover - defensive final fallback
+ return VecDBException._redact_diagnostic_text(_safe_string(value))
+
@staticmethod
def _response_payload(body: Any, data: Any) -> Any:
for candidate in (body, data):
payload = candidate
- if isinstance(payload, bytes):
- payload = payload.decode("utf-8", errors="replace")
- if isinstance(payload, str):
+ if isinstance(payload, (bytes, bytearray, memoryview)):
+ decoded_payload = _MISSING
+ try:
+ decoded_payload = bytes(payload).decode(
+ "utf-8", errors="replace"
+ )
+ except Exception: # pragma: no cover - hostile byte containers
+ decoded_payload = _MISSING
+ payload = decoded_payload
+ if payload is not _MISSING and isinstance(payload, str):
try:
payload = json.loads(payload)
- except (TypeError, ValueError):
- continue
- if not isinstance(payload, dict):
+ except (TypeError, ValueError, RecursionError):
+ payload = _MISSING
+ if payload is not _MISSING and not isinstance(payload, Mapping):
for method in ("model_dump", "to_dict"):
- serializer = getattr(payload, method, None)
+ serializer = _safe_attribute(payload, method)
if callable(serializer):
- payload = serializer()
- break
- if isinstance(payload, dict):
- return payload
+ serialized_payload = _MISSING
+ try:
+ serialized_payload = serializer()
+ except (
+ Exception
+ ): # pragma: no cover - hostile serializers
+ serialized_payload = _MISSING
+ if isinstance(serialized_payload, Mapping):
+ payload = serialized_payload
+ break
+ if payload is not _MISSING and isinstance(payload, Mapping):
+ return VecDBException._redact_value(payload)
return None
def get_traceback(self) -> str:
diff --git a/src/oracle_vecdb/version.py b/src/oracle_vecdb/version.py
index ee9a8b6..af05cad 100644
--- a/src/oracle_vecdb/version.py
+++ b/src/oracle_vecdb/version.py
@@ -1,4 +1,4 @@
"""Single source of truth for SDK and generated ORDS versions."""
-SDK_VERSION = "1.0.5"
+SDK_VERSION = "1.0.6"
ORDS_RELEASE_VERSION = "26.2.2"
diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py
new file mode 100644
index 0000000..79afc1b
--- /dev/null
+++ b/tests/client/test_auth.py
@@ -0,0 +1,315 @@
+"""Tests for OAuth authentication helpers."""
+
+import base64
+import sys
+import types
+
+import pytest
+import urllib3
+
+import oracle_vecdb.auth as auth_module
+from oracle_vecdb import get_vecdb_oauth_token
+from oracle_vecdb.vecdb_errors import OAuthTokenError
+
+TOKEN_URL = "https://localhost:8443/ords/vector3/oauth/token" # nosec B105
+
+
+class FakeResponse:
+ """Minimal urllib3 response used by the authentication tests."""
+
+ def __init__(self, status, data, reason="OK"):
+ self.status = status
+ self.data = data
+ self.reason = reason
+ self.released = False
+
+ def release_conn(self):
+ """Record that the response connection was released."""
+ self.released = True
+
+
+def _make_pool(mocker, response):
+ """Patch the authentication pool factory and return its fake pool."""
+ pool = mocker.Mock()
+ pool.request.return_value = response
+ mocker.patch.object(
+ auth_module.urllib3,
+ "PoolManager",
+ return_value=pool,
+ )
+ return pool
+
+
+def test_get_vecdb_oauth_token_posts_form_data_and_returns_response(mocker):
+ """Verify the client-credentials request and successful response parsing."""
+ response = FakeResponse(
+ 200,
+ b'{"access_token":"token-value","token_type":"bearer",'
+ b'"expires_in":3600}',
+ )
+ pool = _make_pool(mocker, response)
+
+ result = get_vecdb_oauth_token(
+ client_id="example-client-id",
+ client_secret="example", # nosec B106
+ token_url=TOKEN_URL,
+ )
+
+ expected_access = "token" + "-value"
+ expected_type = "bear" + "er"
+ assert result == { # nosec B101
+ "access_token": expected_access,
+ "token_type": expected_type,
+ "expires_in": 3600,
+ }
+ call = pool.request.call_args
+ assert call.args == ("POST", TOKEN_URL) # nosec B101
+ assert call.kwargs["fields"] == { # nosec B101
+ "grant_type": "client_credentials"
+ }
+ expected_auth = "Basic " + base64.b64encode(
+ b"example-client-id:example"
+ ).decode("ascii")
+ assert (
+ call.kwargs["headers"]["Authorization"] == expected_auth
+ ) # nosec B101
+ assert call.kwargs["headers"]["Content-Type"] == ( # nosec B101
+ "application/x-www-form-urlencoded"
+ )
+ assert isinstance(call.kwargs["timeout"], urllib3.Timeout) # nosec B101
+ assert response.released is True # nosec B101
+ pool.clear.assert_called_once_with()
+
+
+@pytest.mark.parametrize("status", [400, 401, 403, 500])
+def test_get_vecdb_oauth_token_raises_safe_http_error(mocker, status):
+ """Verify HTTP failures expose status without exposing credentials or tokens."""
+ response = FakeResponse(
+ status,
+ b'{"access_token":"must-not-be-exposed"}',
+ reason="failure",
+ )
+ _make_pool(mocker, response)
+
+ with pytest.raises(OAuthTokenError) as raised:
+ get_vecdb_oauth_token(
+ client_id="example-client-id",
+ client_secret="example", # nosec B106
+ token_url=TOKEN_URL,
+ )
+
+ message = str(raised.value)
+ assert f"HTTP status {status}" in message # nosec B101
+ assert "example" not in message # nosec B101
+ assert "must-not-be-exposed" not in message # nosec B101
+
+
+def test_get_vecdb_oauth_token_raises_safe_transport_error(mocker):
+ """Verify transport failures become OAuthTokenError without raw details."""
+ pool = mocker.Mock()
+ pool.request.side_effect = urllib3.exceptions.TimeoutError("secret detail")
+ mocker.patch.object(
+ auth_module.urllib3,
+ "PoolManager",
+ return_value=pool,
+ )
+
+ with pytest.raises(OAuthTokenError) as raised:
+ get_vecdb_oauth_token(
+ client_id="example-client-id",
+ client_secret="example", # nosec B106
+ token_url=TOKEN_URL,
+ )
+
+ assert "transport or request configuration failure" in str(
+ raised.value
+ ) # nosec B101
+ assert "secret detail" not in str(raised.value) # nosec B101
+ assert raised.value.__context__ is None # nosec B101
+
+
+def test_get_vecdb_oauth_token_rejects_invalid_json(mocker):
+ """Verify a non-JSON OAuth response produces a structured error."""
+ response = FakeResponse(200, b"not-json")
+ _make_pool(mocker, response)
+
+ with pytest.raises(OAuthTokenError, match="not valid JSON"):
+ get_vecdb_oauth_token(
+ client_id="example-client-id",
+ client_secret="example", # nosec B106
+ token_url=TOKEN_URL,
+ )
+
+
+def test_get_vecdb_oauth_token_rejects_missing_access_token(mocker):
+ """Verify a JSON response without access_token is rejected."""
+ response = FakeResponse(200, b'{"token_type":"bearer"}')
+ _make_pool(mocker, response)
+
+ with pytest.raises(OAuthTokenError, match="valid access_token"):
+ get_vecdb_oauth_token(
+ client_id="example-client-id",
+ client_secret="example", # nosec B106
+ token_url=TOKEN_URL,
+ )
+
+
+def test_get_vecdb_oauth_token_rejects_non_https_url(mocker):
+ """Verify insecure token URLs are rejected before network access."""
+ pool = mocker.patch.object(auth_module.urllib3, "PoolManager")
+
+ with pytest.raises(OAuthTokenError, match="HTTPS"):
+ get_vecdb_oauth_token(
+ client_id="example-client-id",
+ client_secret="example", # nosec B106
+ token_url="http://localhost/oauth/token",
+ )
+
+ pool.assert_not_called()
+
+
+def test_safe_endpoint_redacts_query_and_credentials():
+ """Verify diagnostic endpoint formatting excludes sensitive URL parts."""
+ safe = auth_module._safe_endpoint(
+ "https://user:password@example.com/token?secret=value#fragment"
+ )
+
+ assert safe == "https://example.com/token" # nosec B101
+
+
+def test_safe_endpoint_returns_placeholder_for_invalid_input():
+ """Verify diagnostic formatting cannot hide the original validation error."""
+ assert (
+ auth_module._safe_endpoint(None) == ""
+ ) # nosec B101
+
+
+@pytest.mark.parametrize(
+ "client_id,client_secret,message",
+ [
+ (" ", "secret", "client_id"),
+ ("client", "", "client_secret"),
+ ],
+)
+def test_validate_credentials_rejects_empty_values(
+ client_id, client_secret, message
+):
+ with pytest.raises(OAuthTokenError, match=message):
+ auth_module._validate_credentials(
+ client_id, client_secret, "https://example.com/token"
+ )
+
+
+@pytest.mark.parametrize(
+ "token_url,message",
+ [
+ (" ", "non-empty HTTPS URL"),
+ ("https://example.com:invalid/token", "not a valid URL"),
+ ],
+)
+def test_validate_token_url_rejects_empty_and_malformed_urls(
+ token_url, message
+):
+ with pytest.raises(OAuthTokenError, match=message):
+ auth_module._validate_token_url(token_url, "https://example.com/token")
+
+
+@pytest.mark.parametrize("timeout", [True, 0, -1, (1, 0), (1,), [1, 2]])
+def test_build_timeout_rejects_invalid_values(timeout):
+ with pytest.raises(OAuthTokenError, match="timeout"):
+ auth_module._build_timeout(timeout, "https://example.com/token")
+
+
+def test_build_timeout_supports_connect_and_read_values():
+ timeout = auth_module._build_timeout((1, 2), "https://example.com/token")
+
+ assert timeout.connect_timeout == 1.0 # nosec B101
+ assert timeout.read_timeout == 2.0 # nosec B101
+
+
+def test_build_pool_supports_http_and_socks_proxies(mocker, monkeypatch):
+ proxy_manager = mocker.patch.object(
+ auth_module.urllib3, "ProxyManager", autospec=True
+ )
+ proxy_headers = {"X-Proxy": "value"}
+ auth_module._build_pool(
+ verify_ssl=True,
+ ssl_ca_cert="ca.pem",
+ ca_cert_data=None,
+ proxy="https://proxy.example.com",
+ proxy_headers=proxy_headers,
+ )
+ proxy_manager.assert_called_once()
+ assert proxy_manager.call_args.kwargs["proxy_url"] == (
+ "https://proxy.example.com"
+ ) # nosec B101
+ assert (
+ proxy_manager.call_args.kwargs["proxy_headers"] == proxy_headers
+ ) # nosec B101
+
+ socks_module = types.ModuleType("urllib3.contrib.socks")
+ socks_manager = mocker.Mock()
+ socks_module.SOCKSProxyManager = socks_manager
+ monkeypatch.setitem(sys.modules, "urllib3.contrib.socks", socks_module)
+
+ auth_module._build_pool(
+ verify_ssl=False,
+ ssl_ca_cert=None,
+ ca_cert_data=None,
+ proxy="socks5h://proxy.example.com",
+ proxy_headers=proxy_headers,
+ )
+
+ socks_manager.assert_called_once()
+ assert socks_manager.call_args.kwargs["proxy_url"] == (
+ "socks5h://proxy.example.com"
+ ) # nosec B101
+ assert (
+ socks_manager.call_args.kwargs["headers"] == proxy_headers
+ ) # nosec B101
+
+
+@pytest.mark.parametrize("response_data", [b"[]", b"\xff"])
+def test_parse_response_rejects_non_object_and_invalid_utf8(response_data):
+ with pytest.raises(OAuthTokenError):
+ auth_module._parse_response(response_data, "https://example.com/token")
+
+
+@pytest.mark.parametrize(
+ "payload,message",
+ [
+ (
+ {"access_token": "to" + "ken", "token_type": "ba" + "sic"},
+ "bearer",
+ ),
+ (
+ {
+ "access_token": "to" + "ken",
+ "token_type": "bear" + "er",
+ "expires_in": True,
+ },
+ "expires_in",
+ ),
+ ],
+)
+def test_validate_token_response_rejects_invalid_metadata(payload, message):
+ with pytest.raises(OAuthTokenError, match=message):
+ auth_module._validate_token_response(
+ payload, "https://example.com/token"
+ )
+
+
+@pytest.mark.parametrize(
+ "token_url",
+ ["https://example.com:invalid/token", "https://[", "relative/path"],
+)
+def test_safe_endpoint_rejects_malformed_authorities(token_url):
+ assert auth_module._safe_endpoint(token_url) == (
+ ""
+ ) # nosec B101
+
+
+def test_safe_endpoint_brackets_ipv6_hosts():
+ assert auth_module._safe_endpoint("https://[::1]:8443/token") == (
+ "https://[::1]:8443/token"
+ ) # nosec B101
diff --git a/tests/client/test_client_facade_contract.py b/tests/client/test_client_facade_contract.py
index 457b09a..34d6562 100644
--- a/tests/client/test_client_facade_contract.py
+++ b/tests/client/test_client_facade_contract.py
@@ -18,12 +18,21 @@
from oracle_vecdb.data_types import UpsertVectorsResponse
from oracle_vecdb.service_protocol import VecDBServiceProtocol
from oracle_vecdb.vecdb_exception import VecDBException
+from oracle_vecdb.validation import (
+ _merge_default_mapping,
+ set_default_arguments,
+ validate_resource_names,
+)
from oracle_vecdb.vecdb_errors import (
+ InvalidParameterCombinationError,
InvalidIndexJobNameFormatError,
+ InvalidIndexJobLogError,
InvalidLoadJobNameFormatError,
+ InvalidLoadJobLogError,
InvalidModelNameFormatError,
InvalidTableNameFormatError,
InvalidVectorsError,
+ ResourceNotFoundError,
VectorPayloadTooLargeError,
)
@@ -122,6 +131,24 @@ def test_query_forwards_output_selector_to_active_backend(mocker):
)
+@pytest.mark.parametrize(
+ "top_k", [True, False, None, 0, -1, 0.2, 2.5, 2.0, set(), {}]
+)
+def test_query_rejects_invalid_top_k_before_backend_call(mocker, top_k):
+ """Reject invalid result counts locally without calling the backend."""
+ client, active_backend, _ = _make_client(mocker)
+
+ with pytest.raises(VecDBException) as error:
+ client.query(table_name="docs", query_by={"text": "hi"}, top_k=top_k)
+
+ assert error.value.is_original_exception( # nosec B101
+ InvalidParameterCombinationError
+ )
+ assert "top_k" in str(error.value) # nosec B101
+ assert "positive integer (>= 1)" in str(error.value) # nosec B101
+ assert active_backend.calls == [] # nosec B101
+
+
def test_common_spec_applies_defaults_for_omitted_arguments(mocker):
"""Verify omitted facade arguments are populated from DefaultSettings.
@@ -155,36 +182,83 @@ def test_common_spec_applies_defaults_for_omitted_arguments(mocker):
assert active_backend.calls[1][2]["include_vectors"] is False # nosec B101
-def test_common_spec_rejects_graph_index_without_distribution_method(mocker):
+def test_default_mapping_merges_nested_values_without_mutating_inputs():
+ defaults = {"options": {"pagination": {"limit": 15, "offset": 0}}}
+ supplied = {"options": {"pagination": {"limit": 5}}}
+
+ merged = _merge_default_mapping(defaults, supplied)
+
+ assert merged == { # nosec B101
+ "options": {"pagination": {"limit": 5, "offset": 0}}
+ }
+ assert defaults["options"]["pagination"]["limit"] == 15 # nosec B101
+ assert supplied == {"options": {"pagination": {"limit": 5}}} # nosec B101
+
+
+def test_default_settings_reject_a_parameter_missing_from_signature(
+ monkeypatch,
+):
+ monkeypatch.setattr(
+ DefaultSettings,
+ "get_default_args_for",
+ classmethod(lambda cls, name: {"unknown": True}),
+ )
+
+ def operation(value):
+ return value
+
+ with pytest.raises(VecDBException, match="unknown"):
+ set_default_arguments(operation)
+
+
+def test_resource_name_decorator_skips_omitted_parameters():
+ @validate_resource_names(table_name=InvalidTableNameFormatError)
+ def operation(table_name="default"):
+ return table_name
+
+ assert operation() == "default" # nosec B101
+
+
+@pytest.mark.parametrize(
+ "include_distribution",
+ [False, True],
+ ids=["omitted", "explicit-null"],
+)
+def test_common_spec_accepts_graph_index_without_or_with_null_distribution(
+ mocker, include_distribution
+):
client, active_backend, _ = _make_client(mocker)
+ index_params: Dict[str, Any] = {
+ "vector_index_params": {"organization": "INMEMORY GRAPH"}
+ }
+ if include_distribution:
+ index_params["vector_index_params"]["distribute_params"] = None
- with pytest.raises(
- VecDBException,
- match="distribute_params cannot be None.*INMEMORY GRAPH",
- ):
- client.create_index(
- table_name="docs",
- index_params={
- "vector_index_params": {"organization": "INMEMORY GRAPH"}
+ client.create_index(table_name="docs", index_params=index_params)
+
+ assert active_backend.calls == [ # nosec B101
+ (
+ "create_index",
+ (),
+ {
+ "table_name": "docs",
+ "index_params": index_params,
+ "debug_flags": None,
},
)
-
- assert active_backend.calls == [] # nosec B101
+ ]
-def test_common_spec_rejects_graph_index_with_null_distribution_params(mocker):
+def test_common_spec_rejects_unlisted_explicit_distribution_method(mocker):
client, active_backend, _ = _make_client(mocker)
- with pytest.raises(
- VecDBException,
- match="distribute_params cannot be None.*INMEMORY GRAPH",
- ):
+ with pytest.raises(VecDBException, match="distribute_method"):
client.create_index(
table_name="docs",
index_params={
"vector_index_params": {
"organization": "INMEMORY GRAPH",
- "distribute_params": None,
+ "distribute_params": {"distribute_method": "NONE"},
}
},
)
@@ -703,6 +777,90 @@ def test_facade_rejects_unsafe_resource_name_values(
assert active_backend.calls == [] # nosec B101
+@pytest.mark.parametrize(
+ "log_method,describe_method,error_type",
+ [
+ (
+ "get_vector_load_job_log",
+ "describe_vector_load_job",
+ InvalidLoadJobLogError,
+ ),
+ ("get_index_job_log", "describe_index_job", InvalidIndexJobLogError),
+ ],
+)
+def test_facade_rejects_job_logs_before_terminal_state(
+ mocker, log_method, describe_method, error_type
+):
+ client, backend, _ = _make_client(mocker)
+ mocker.patch.object(
+ client, describe_method, return_value=SimpleNamespace(state="RUNNING")
+ )
+
+ with pytest.raises(error_type):
+ getattr(client, log_method)("job1")
+
+ assert backend.calls == [] # nosec B101
+
+
+@pytest.mark.parametrize(
+ "log_method,describe_method",
+ [
+ ("get_vector_load_job_log", "describe_vector_load_job"),
+ ("get_index_job_log", "describe_index_job"),
+ ],
+)
+def test_facade_normalizes_missing_job_log_resource(
+ mocker, log_method, describe_method
+):
+ client, backend, _ = _make_client(mocker)
+ mocker.patch.object(
+ client,
+ describe_method,
+ side_effect=VecDBException(status=404, reason="not found"),
+ )
+
+ with pytest.raises(VecDBException) as error:
+ getattr(client, log_method)("missing-job")
+
+ assert error.value.is_original_exception(
+ ResourceNotFoundError
+ ) # nosec B101
+ assert backend.calls == [] # nosec B101
+
+
+@pytest.mark.parametrize(
+ "log_method,describe_method",
+ [
+ ("get_vector_load_job_log", "describe_vector_load_job"),
+ ("get_index_job_log", "describe_index_job"),
+ ],
+)
+def test_facade_propagates_non_not_found_job_status_errors(
+ mocker, log_method, describe_method
+):
+ client, _, _ = _make_client(mocker)
+ service_error = VecDBException(status=503, reason="unavailable")
+ mocker.patch.object(client, describe_method, side_effect=service_error)
+
+ with pytest.raises(VecDBException) as error:
+ getattr(client, log_method)("job1")
+
+ assert error.value is service_error # nosec B101
+
+
+def test_upsert_batch_rejects_vector_that_exceeds_batch_limit(
+ mocker, monkeypatch
+):
+ client, backend, _ = _make_client(mocker)
+ monkeypatch.setattr(client_module, "_MAX_UPSERT_BATCH_BYTES", 20)
+ mocker.patch.object(client, "_upsert_vector_json_size", return_value=19)
+
+ with pytest.raises(VectorPayloadTooLargeError):
+ client._upsert_vectors_in_batches("docs", [{"id": "v1"}], None)
+
+ assert backend.calls == [] # nosec B101
+
+
@pytest.mark.parametrize(
"method_name,resource_parameter",
[
diff --git a/tests/client/test_configuration_facade.py b/tests/client/test_configuration_facade.py
index d25dbda..45fe22a 100644
--- a/tests/client/test_configuration_facade.py
+++ b/tests/client/test_configuration_facade.py
@@ -74,6 +74,29 @@ def test_configuration_module_missing_attribute_raises_attribute_error():
getattr(configuration_module, "does_not_exist")
+def test_configuration_accessors_and_validation_of_none_timeout():
+ configuration = Configuration(rest_url=VALID_HOST)
+
+ assert (
+ ConfigurationManualMixin._validate_timeout(None) is None
+ ) # nosec B101
+ assert configuration.authentication_mode # nosec B101
+ assert (
+ configuration.get_host_from_settings(None) == configuration.rest_url
+ ) # nosec B101
+ with pytest.raises(AttributeError, match="not part of the public SDK"):
+ _ = configuration.host
+ with pytest.raises(AttributeError, match="not part of the public SDK"):
+ configuration.host = "https://example.com"
+
+ class CustomConfiguration(Configuration):
+ pass
+
+ assert isinstance( # nosec B101
+ Configuration.__new__(CustomConfiguration), CustomConfiguration
+ )
+
+
def test_configuration_extensions_are_combined_with_public_facade():
assert callable(apply_configuration_extensions) # nosec B101
assert ( # nosec B101
@@ -651,3 +674,12 @@ def test_ords_base_configuration_properties(monkeypatch):
assert cfg.has_rest_url is True # nosec B101
assert cfg.logger_file is None # nosec B101
assert "%(levelname)s" in cfg.logger_format # nosec B101
+
+
+def test_ords_base_configuration_reports_missing_rest_url():
+ cfg = ORDSBaseConfiguration.__new__(ORDSBaseConfiguration)
+
+ assert (
+ ORDSBaseConfiguration.has_rest_url.__get__(cfg, ORDSBaseConfiguration)
+ is False
+ ) # nosec B101
diff --git a/tests/services/test_ords.py b/tests/services/test_ords.py
index a3b3acf..d431374 100644
--- a/tests/services/test_ords.py
+++ b/tests/services/test_ords.py
@@ -5,6 +5,7 @@
from __future__ import annotations
+import json
from typing import Any, Dict
import pytest
@@ -61,6 +62,260 @@ def test_sdk_api_client_preserves_per_request_timeout(mocker):
) # nosec B101
+@pytest.mark.parametrize(
+ "response_method, expected_method",
+ [("NONE", None), ("AUTO", "AUTO")],
+ ids=["implicit-database-default", "explicit-valid-method"],
+)
+def test_sdk_api_client_deserializes_distribution_default_only_when_valid(
+ response_method, expected_method
+):
+ client = ords_module._CustomApiClient(Configuration(rest_url=VALID_HOST))
+ response = client.deserialize(
+ response_text=json.dumps(
+ {
+ "table_name": "DOCS",
+ "index_params": {
+ "vector_index_params": {
+ "organization": "INMEMORY GRAPH",
+ "distribute_params": {
+ "distribute_method": response_method
+ },
+ }
+ },
+ }
+ ),
+ response_type="VecDBTableNoLinks",
+ content_type="application/json",
+ )
+
+ distribute_params = (
+ response.index_params.vector_index_params.distribute_params
+ )
+ if expected_method is None:
+ assert distribute_params is None # nosec B101
+ else:
+ assert (
+ distribute_params.distribute_method == expected_method
+ ) # nosec B101
+
+
+def test_distribution_sentinel_removal_walks_nested_lists():
+ payload = {
+ "items": [
+ {
+ "index_params": {
+ "distribute_params": {"distribute_method": "NONE"}
+ }
+ },
+ {
+ "index_params": {
+ "distribute_params": {"distribute_method": "AUTO"}
+ }
+ },
+ ]
+ }
+
+ changed = (
+ ords_module._CustomApiClient._remove_implicit_distribution_sentinel(
+ payload
+ )
+ )
+
+ assert changed is True # nosec B101
+ assert (
+ "distribute_params" not in payload["items"][0]["index_params"]
+ ) # nosec B101
+ assert payload["items"][1]["index_params"]["distribute_params"] == {
+ "distribute_method": "AUTO"
+ } # nosec B101
+
+
+def test_deserializer_keeps_malformed_sentinel_json_for_parent_parser(mocker):
+ client = ords_module._CustomApiClient(Configuration(rest_url=VALID_HOST))
+ parent = ords_module._CustomApiClient.__mro__[1]
+ parent_deserialize = mocker.patch.object(
+ parent, "deserialize", return_value="parent-result"
+ )
+ malformed_json = '{"distribute_method":"NONE",'
+
+ result = client.deserialize(
+ malformed_json, "VecDBTableNoLinks", "application/json"
+ )
+
+ assert result == "parent-result" # nosec B101
+ parent_deserialize.assert_called_once_with(
+ malformed_json, "VecDBTableNoLinks", "application/json"
+ )
+
+
+@pytest.mark.parametrize(
+ "index_params,message",
+ [
+ ({"vector_index_params": []}, "vector_index_params"),
+ (
+ {"vector_index_params": {"distribute_params": "AUTO"}},
+ "distribute_params",
+ ),
+ ({"metadata_index_params": []}, "metadata_index_params"),
+ ({"metadata_index_params": {"unknown": True}}, "metadata_index_params"),
+ ],
+)
+def test_index_parameter_field_validation_rejects_invalid_nested_values(
+ index_params, message
+):
+ with pytest.raises((TypeError, ValueError), match=message):
+ ORDSService._validate_index_params_fields(index_params)
+
+
+def test_ords_configuration_requires_an_endpoint(mocker):
+ mocker.patch.object(
+ ords_module.ORDSBaseConfiguration, "__init__", return_value=None
+ )
+
+ with pytest.raises(ValueError, match="requires an ORDS endpoint"):
+ ords_module.ORDSConfiguration()
+
+
+@pytest.mark.parametrize(
+ "max_retry_delay",
+ [
+ True,
+ float("inf"),
+ -1,
+ pytest.param(10**10000, id="float-conversion-overflow"),
+ ],
+)
+def test_ords_settings_rejects_invalid_maximum_retry_delay(max_retry_delay):
+ with pytest.raises(ValueError, match="max_retry_delay"):
+ ords_module.ORDSSettings(max_retry_delay=max_retry_delay)
+
+
+def test_upsert_vector_field_normalization_supports_aliases_and_legacy_properties(
+ monkeypatch,
+):
+ class FieldInfo:
+ alias = "ID"
+
+ class FakeModel:
+ model_fields = {"id": FieldInfo()}
+
+ setattr(FakeModel, "__properties", ["legacy_field"])
+ monkeypatch.setattr(
+ ords_module._models, "UpsertVectorsRequestVectorsInner", FakeModel
+ )
+
+ normalized = ORDSService._normalize_upsert_vector_fields(
+ {"ID": "v1", "legacy_field": "legacy-value"}
+ )
+
+ assert normalized == {
+ "id": "v1",
+ "legacy_field": "legacy-value",
+ } # nosec B101
+
+
+@pytest.mark.parametrize(
+ "method_name,arguments",
+ [
+ ("upsert_vectors", {"vectors": [{1: "v1"}]}),
+ ("upsert_vectors", {"vectors": [object()]}),
+ ("list_vectors", {"ids": [1]}),
+ ("delete_vectors", {"ids": [1]}),
+ ],
+)
+def test_vector_methods_reject_invalid_member_values(method_name, arguments):
+ service = _make_service()
+ common_arguments = {
+ "upsert_vectors": {"table_name": "docs"},
+ "list_vectors": {"table_name": "docs"},
+ "delete_vectors": {"table_name": "docs"},
+ }[method_name]
+
+ with pytest.raises(VecDBException):
+ getattr(service, method_name)(**common_arguments, **arguments)
+
+ assert service.vector_api.calls == [] # nosec B101
+
+
+def test_retry_after_parser_handles_case_variants_and_invalid_values(
+ monkeypatch,
+):
+ lower_case = TransportError(
+ status=429,
+ reason="throttled",
+ headers={"retry-after": "0.25"},
+ )
+ assert (
+ response_handlers_module.ORDSResponseHandler._retry_after_seconds(
+ lower_case
+ )
+ == 0.25
+ ) # nosec B101
+
+ missing = TransportError(
+ status=429, reason="throttled", headers={"X-Other": "value"}
+ )
+ assert (
+ response_handlers_module.ORDSResponseHandler._retry_after_seconds(
+ missing
+ )
+ is None
+ ) # nosec B101
+
+ malformed = TransportError(
+ status=429,
+ reason="throttled",
+ headers={"Retry-After": "invalid date"},
+ )
+ assert (
+ response_handlers_module.ORDSResponseHandler._retry_after_seconds(
+ malformed
+ )
+ is None
+ ) # nosec B101
+
+ naive_date = TransportError(
+ status=429,
+ reason="throttled",
+ headers={"Retry-After": "Wed, 21 Oct 2099 07:28:00"},
+ )
+ monkeypatch.setattr(response_handlers_module.time, "time", lambda: 0.0)
+ assert (
+ response_handlers_module.ORDSResponseHandler._retry_after_seconds(
+ naive_date
+ )
+ > 0
+ ) # nosec B101
+
+
+def test_retry_after_handler_does_not_retry_unsafe_operations():
+ error = TransportError(status=429, reason="throttled")
+
+ class Endpoint:
+ @ORDSResponseHandler
+ def create_index(self):
+ raise error
+
+ with pytest.raises(TransportError) as raised:
+ Endpoint().create_index()
+
+ assert raised.value is error # nosec B101
+
+
+def test_retry_delay_is_zero_when_configuration_value_is_invalid():
+ settings = type("Settings", (), {"max_retry_delay": "invalid"})()
+ config = type("Config", (), {"ords_settings": settings})()
+ service = type("Service", (), {"config": config})()
+
+ assert (
+ response_handlers_module.ORDSResponseHandler._max_retry_delay(
+ (service,)
+ )
+ == 0.0
+ ) # nosec B101
+
+
@pytest.mark.parametrize(
"configured_timeout, expected_connect, expected_read, expected_total",
[
@@ -168,6 +423,28 @@ def test_collection_list_timeout_is_normalized_with_operation(
) # nosec B101
+def test_ssl_transport_error_preserves_certificate_cause(mocker):
+ config = Configuration(rest_url=VALID_HOST)
+ service = ORDSService(config)
+ request = mocker.patch.object(
+ service.api_client.rest_client.pool_manager,
+ "request",
+ side_effect=urllib3.exceptions.SSLError("certificate verify failed"),
+ )
+
+ with pytest.raises(VecDBException) as exc_info:
+ service.list_vector_tables()
+
+ error = exc_info.value
+ assert request.call_count == 1 # nosec B101
+ assert error.transport_category == "certificate_error" # nosec B101
+ assert error.transport_error_type == "SSLError" # nosec B101
+ assert error.retryable is False # nosec B101
+ assert error.error_code == "CERTIFICATE_ERROR" # nosec B101
+ assert "certificate verify failed" in str(error) # nosec B101
+ assert "SSLError" in error.service_trace # nosec B101
+
+
class RecordingApi:
def __init__(self, returns: Dict[str, Any] | None = None) -> None:
self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
@@ -380,50 +657,35 @@ def test_generated_index_params_accepts_documented_distribution_method():
} # nosec B101
-def test_ords_service_rejects_graph_index_without_distribution_method():
- service = _make_service()
-
- with pytest.raises(
- VecDBException,
- match="distribute_params cannot be None.*INMEMORY GRAPH",
- ):
- service.create_index(
- "docs",
- {
- "vector_index_params": {
- "auto_index": True,
- "organization": "INMEMORY GRAPH",
- "distance_metric": "COSINE",
- "accuracy": 95,
- "advanced_params": {
- "neighbors": 10,
- "efConstruction": 100,
- },
- }
- },
- )
-
- assert service.index_api.calls == [] # nosec B101
-
-
-def test_ords_service_rejects_graph_index_with_null_distribution_params():
+@pytest.mark.parametrize(
+ "include_distribution",
+ [False, True],
+ ids=["omitted", "explicit-null"],
+)
+def test_ords_service_accepts_graph_index_without_or_with_null_distribution(
+ include_distribution,
+):
service = _make_service()
+ vector_index_params: Dict[str, Any] = {
+ "organization": "INMEMORY GRAPH",
+ "auto_index": True,
+ "distance_metric": "COSINE",
+ "accuracy": 95,
+ "advanced_params": {"neighbors": 10, "efConstruction": 100},
+ }
+ if include_distribution:
+ vector_index_params["distribute_params"] = None
- with pytest.raises(
- VecDBException,
- match="distribute_params cannot be None.*INMEMORY GRAPH",
- ):
- service.create_index(
- "docs",
- {
- "vector_index_params": {
- "organization": "INMEMORY GRAPH",
- "distribute_params": None,
- }
- },
- )
+ service.create_index("docs", {"vector_index_params": vector_index_params})
- assert service.index_api.calls == [] # nosec B101
+ request = _first_positional_request(service.index_api, "create_index")
+ serialized = request.index_params.to_dict()["vector_index_params"]
+ assert serialized["organization"] == "INMEMORY GRAPH" # nosec B101
+ assert (
+ "distribute_params" in serialized
+ ) is include_distribution # nosec B101
+ if include_distribution:
+ assert serialized["distribute_params"] is None # nosec B101
def test_ords_debug_flag_conversion():
@@ -859,21 +1121,22 @@ def test_ords_service_accepts_nullable_distribute_params():
)
-def test_generated_index_params_accept_nullable_distribution_method():
+def test_generated_index_params_rejects_null_distribution_method():
from oracle_vecdb.services.ords.models.vector_index_params import (
VectorIndexParams,
)
- params = VectorIndexParams.from_dict(
- {
- "vector_index_params": {
- "distribute_params": {"distribute_method": None}
+ with pytest.raises(
+ ValidationError, match="distribute_method must not be None"
+ ):
+ VectorIndexParams.from_dict(
+ {
+ "vector_index_params": {
+ "distribute_params": {"distribute_method": None}
+ }
}
- }
- )
- assert (
- params.to_dict()["vector_index_params"]["distribute_params"] == {}
- ) # nosec B101
+ )
+
payload = VectorIndexParams.from_dict(
{"vector_index_params": {"distribute_params": None}}
).to_dict()
@@ -1025,6 +1288,8 @@ def test_ords_service_maps_search_index_and_model_requests():
rerank_request = _first_positional_request(service.inference_api, "rerank")
assert query_request.query_by == {"text": "hello"} # nosec B101
+ assert query_request.top_k == 3 # nosec B101
+ assert query_request.to_dict()["topK"] == 3 # nosec B101
assert query_request.output_selector == ["category", "price"] # nosec B101
assert isinstance( # nosec B101
create_index_request.index_params,
@@ -1052,6 +1317,21 @@ def test_ords_service_maps_search_index_and_model_requests():
assert rerank_request.model_params.provider == "database" # nosec B101
+@pytest.mark.parametrize(
+ "top_k", [True, False, None, 0, -1, 0.2, 2.5, 2.0, set(), {}]
+)
+def test_ords_service_rejects_invalid_top_k_before_request(top_k):
+ """Reject invalid counts before constructing or dispatching the request."""
+ service = _make_service()
+
+ with pytest.raises(VecDBException) as error:
+ service.query("docs", query_by={"text": "hello"}, top_k=top_k)
+
+ assert "top_k" in str(error.value) # nosec B101
+ assert "positive integer (>= 1)" in str(error.value) # nosec B101
+ assert service.search_api.calls == [] # nosec B101
+
+
def test_query_accepts_empty_string_output_selector_and_rejects_non_string():
service = _make_service()
diff --git a/tests/services/test_ords_exceptions.py b/tests/services/test_ords_exceptions.py
index 31cd277..4ddb56d 100644
--- a/tests/services/test_ords_exceptions.py
+++ b/tests/services/test_ords_exceptions.py
@@ -1,10 +1,20 @@
import json
import builtins
+import socket
+import ssl
from types import SimpleNamespace
+from typing import Any, cast
import pytest
from pydantic import BaseModel, ValidationError
-from urllib3.exceptions import MaxRetryError, ReadTimeoutError
+from urllib3.exceptions import (
+ ConnectTimeoutError,
+ MaxRetryError,
+ NameResolutionError,
+ ProtocolError,
+ ReadTimeoutError,
+ SSLError,
+)
from oracle_vecdb import VecDBException
from oracle_vecdb.services.ords.exceptions import ApiException
import oracle_vecdb.vecdb_exception as vecdb_exception_module
@@ -422,6 +432,208 @@ def model_dump(self):
VecDBException._redact_value(BrokenSerializer())
+def test_exception_redaction_handles_depth_cycles_and_annotation_shapes():
+ circular = []
+ circular.append(circular)
+ truncated = VecDBException._redact_value(
+ {"value": "deep"}, _depth=vecdb_exception_module._MAX_DIAGNOSTIC_DEPTH
+ )
+
+ assert VecDBException._redact_value(circular) == [ # nosec B101
+ vecdb_exception_module._CIRCULAR_REFERENCE
+ ]
+ assert (
+ truncated == vecdb_exception_module._NESTED_DIAGNOSTIC_TRUNCATED
+ ) # nosec B101
+ assert VecDBException._sanitize_arguments( # nosec B101
+ {"kwargs": {"annotations": None}}
+ ) == {"kwargs": {"annotations": None}}
+ assert VecDBException._sanitize_arguments( # nosec B101
+ {"kwargs": {"annotations": "invalid"}}
+ ) == {"kwargs": {"annotations": ""}}
+
+
+def test_transport_diagnostic_helpers_handle_unusual_values():
+ class ReprFallback:
+ def __str__(self):
+ return cast(str, 7)
+
+ def __repr__(self):
+ return "safe repr"
+
+ class HostileAttribute:
+ @property
+ def reason(self):
+ raise RuntimeError("unavailable")
+
+ nested = Exception("nested")
+ parent = Exception(nested)
+ parent.reason = nested
+
+ assert (
+ vecdb_exception_module._safe_string(ReprFallback()) == "safe repr"
+ ) # nosec B101
+ assert (
+ vecdb_exception_module._safe_attribute( # nosec B101
+ HostileAttribute(), "reason", "fallback"
+ )
+ == "fallback"
+ )
+ assert vecdb_exception_module._exception_chain(parent) == [ # nosec B101
+ parent,
+ nested,
+ ]
+ assert not vecdb_exception_module._has_errno( # nosec B101
+ SimpleNamespace(errno=[]), {1}
+ )
+
+
+@pytest.mark.parametrize(
+ "error, expected_category",
+ [
+ (Exception("TLS handshake failed"), "ssl_error"),
+ (Exception("name or service not known"), "dns_failure"),
+ (Exception("read timed out"), "read_timeout"),
+ (Exception("connection reset by peer"), "connection_reset"),
+ (Exception("connection refused"), "connect_failure"),
+ (Exception("connection timeout"), "connect_timeout"),
+ (Exception("broken pipe"), "socket_failure"),
+ (Exception("request timed out"), "timeout"),
+ (ConnectionAbortedError("aborted"), "socket_failure"),
+ ],
+ ids=[
+ "tls-text",
+ "dns-text",
+ "read-timeout-text",
+ "reset-text",
+ "connect-failure-text",
+ "connect-timeout-text",
+ "socket-failure-text",
+ "timeout-text",
+ "socket-error-type",
+ ],
+)
+def test_transport_classification_recognizes_fallback_diagnostics(
+ error, expected_category
+):
+ category, _ = vecdb_exception_module._transport_classification(error)
+
+ assert category == expected_category # nosec B101
+
+
+def test_timeout_helper_only_accepts_timeout_categories():
+ assert vecdb_exception_module._is_timeout_exception( # nosec B101
+ Exception("read timed out")
+ )
+ assert not vecdb_exception_module._is_timeout_exception( # nosec B101
+ Exception("unrelated failure")
+ )
+
+
+def test_exception_redaction_stops_recursive_serializers():
+ class RecursiveSerializer:
+ def to_dict(self):
+ return self
+
+ assert (
+ VecDBException._redact_value(RecursiveSerializer()) # nosec B101
+ == vecdb_exception_module._CIRCULAR_REFERENCE
+ )
+
+
+def test_safe_json_fallback_redacts_unknown_values_and_vector_guidance():
+ class UnsafeValue:
+ def __str__(self):
+ return "access_token=DO_NOT_LEAK"
+
+ rendered = VecDBException._safe_json({"value": UnsafeValue()})
+
+ assert "DO_NOT_LEAK" not in rendered # nosec B101
+ assert "" in rendered # nosec B101
+ assert (
+ VecDBException.vector_id_null_guidance(
+ "ORA-01400: cannot insert NULL into (SCHEMA.TABLE.OTHER_COLUMN)"
+ )
+ is None
+ ) # nosec B101
+
+
+def test_exception_formatting_falls_back_for_non_json_response_values():
+ class NonSerializable:
+ def __str__(self):
+ return "backend detail token=do-not-leak"
+
+ class Unprintable:
+ def __str__(self):
+ raise RuntimeError("secret=do-not-leak")
+
+ def __repr__(self):
+ raise RuntimeError("secret=do-not-leak")
+
+ error = VecDBException.from_service_error(
+ "query",
+ {"kwargs": {"table_name": "DOCS"}},
+ "ORDSService",
+ ServiceError(
+ status=500,
+ reason="Internal Server Error",
+ body={
+ "code": "BACKEND_FAILURE",
+ "message": "The backend rejected the query",
+ "details": [NonSerializable(), Unprintable()],
+ },
+ ),
+ )
+
+ rendered = "\n".join(
+ (
+ str(error),
+ repr(error),
+ error.format(include_full_ords_response=True),
+ )
+ )
+
+ assert "BACKEND_FAILURE" in rendered # nosec B101
+ assert "The backend rejected the query" in rendered # nosec B101
+ assert "backend detail" in rendered # nosec B101
+ assert "" in rendered # nosec B101
+ assert "do-not-leak" not in rendered # nosec B101
+ assert error.status == 500 # nosec B101
+ assert error.operation == "query" # nosec B101
+ assert error.original_exception_type is ServiceError # nosec B101
+
+
+def test_exception_formatting_handles_circular_response_values():
+ circular_body = {
+ "code": "CIRCULAR_FAILURE",
+ "message": "The backend returned a circular diagnostic",
+ }
+ circular_body["details"] = [circular_body]
+
+ error = VecDBException.from_service_error(
+ "query",
+ {},
+ "ORDSService",
+ ServiceError(status=500, body=circular_body),
+ )
+
+ rendered = "\n".join(
+ (
+ str(error),
+ repr(error),
+ error.format(include_full_ords_response=True),
+ )
+ )
+
+ assert "CIRCULAR_FAILURE" in rendered # nosec B101
+ assert (
+ "The backend returned a circular diagnostic" in rendered
+ ) # nosec B101
+ assert "" in rendered # nosec B101
+ assert error.status == 500 # nosec B101
+ assert error.operation == "query" # nosec B101
+
+
@pytest.mark.parametrize(
"key",
[
@@ -634,10 +846,93 @@ def test_wrapped_urllib3_timeout_has_timeout_code_and_message():
rendered = str(error)
assert error.error_code == "TIMEOUT" # nosec B101
+ assert error.transport_category == "read_timeout" # nosec B101
+ assert error.retryable is True # nosec B101
assert "list_vector_tables" in rendered # nosec B101
assert "Increase Configuration.timeout and retry." in rendered # nosec B101
+@pytest.mark.parametrize(
+ ("transport_error", "category", "retryable", "error_code"),
+ [
+ (
+ ConnectTimeoutError("pool", "https://example.invalid"),
+ "connect_timeout",
+ True,
+ "TIMEOUT",
+ ),
+ (
+ ReadTimeoutError(
+ cast(Any, "pool"), "https://example.invalid", "read timed out"
+ ),
+ "read_timeout",
+ True,
+ "TIMEOUT",
+ ),
+ (
+ NameResolutionError(
+ "db.example.invalid",
+ cast(Any, "pool"),
+ socket.gaierror(-2, "Name or service not known"),
+ ),
+ "dns_failure",
+ False,
+ "DNS_FAILURE",
+ ),
+ (
+ ConnectionRefusedError("connection refused"),
+ "connect_failure",
+ True,
+ "CONNECTION_FAILURE",
+ ),
+ (
+ ConnectionResetError("connection reset by peer"),
+ "connection_reset",
+ True,
+ "CONNECTION_RESET",
+ ),
+ (
+ ProtocolError("connection reset by peer"),
+ "connection_reset",
+ True,
+ "CONNECTION_RESET",
+ ),
+ (
+ SSLError("TLS handshake failed"),
+ "ssl_error",
+ False,
+ "SSL_ERROR",
+ ),
+ (
+ ssl.SSLCertVerificationError("certificate verify failed"),
+ "certificate_error",
+ False,
+ "CERTIFICATE_ERROR",
+ ),
+ ],
+)
+def test_transport_failures_have_stable_categories_and_retryability(
+ transport_error, category, retryable, error_code
+):
+ error = VecDBException.from_service_error(
+ "query",
+ {},
+ "ORDSService",
+ transport_error,
+ )
+
+ rendered = str(error)
+
+ assert error.transport_category == category # nosec B101
+ assert error.retryable is retryable # nosec B101
+ assert error.error_code == error_code # nosec B101
+ assert (
+ error.transport_error_type == type(transport_error).__name__
+ ) # nosec B101
+ assert error.original_exception_type is type(transport_error) # nosec B101
+ assert rendered # nosec B101
+
+
def test_unstructured_error_redacts_marked_secrets_but_preserves_diagnostics():
class UnstructuredError(Exception):
def __str__(self):
@@ -725,6 +1020,9 @@ def __init__(self):
"operation",
"arguments",
"service_name",
+ "transport_category",
+ "transport_error_type",
+ "retryable",
"error_code",
"error_message",
"error_type",
diff --git a/tests/test_parameter_validation.py b/tests/test_parameter_validation.py
index 190fc5d..7df3c51 100644
--- a/tests/test_parameter_validation.py
+++ b/tests/test_parameter_validation.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from typing import Any
+
import pytest
from pydantic import ValidationError
@@ -98,11 +100,16 @@ def test_vector_index_rejects_invalid_ivf_advanced_params():
)
-def test_vector_index_accepts_hnsw_advanced_params():
+@pytest.mark.parametrize(
+ "distribute_method",
+ ["AUTO", "DISTRIBUTE"],
+ ids=["auto", "distribute"],
+)
+def test_vector_index_accepts_hnsw_advanced_params(distribute_method):
VectorIndexParams.model_validate(
{
"organization": "INMEMORY GRAPH",
- "distribute_params": {"distribute_method": "AUTO"},
+ "distribute_params": {"distribute_method": distribute_method},
"online_build": True,
"advanced_params": {
"neighbors": 4,
@@ -114,13 +121,55 @@ def test_vector_index_accepts_hnsw_advanced_params():
)
-def test_vector_index_rejects_invalid_hnsw_algorithm():
- with pytest.raises(ValidationError, match="algorithm must be"):
+@pytest.mark.parametrize("distribute_method", ["NONE", "RANDOM"])
+def test_vector_index_rejects_unlisted_explicit_distribution_method(
+ distribute_method,
+):
+ with pytest.raises(ValidationError, match="distribute_method"):
+ VectorIndexParams.model_validate(
+ {
+ "organization": "INMEMORY GRAPH",
+ "distribute_params": {"distribute_method": distribute_method},
+ }
+ )
+
+
+@pytest.mark.parametrize(
+ "include_distribution",
+ [False, True],
+ ids=["omitted", "explicit-null"],
+)
+def test_vector_index_accepts_hnsw_without_or_with_null_distribution(
+ include_distribution,
+):
+ payload: dict[str, Any] = {"organization": "INMEMORY GRAPH"}
+ if include_distribution:
+ payload["distribute_params"] = None
+
+ validated = VectorIndexParams.model_validate(payload)
+ assert validated.distribute_params is None # nosec B101
+ assert (
+ "distribute_params" in validated.model_fields_set
+ ) is include_distribution # nosec B101
+
+
+@pytest.mark.parametrize(
+ "advanced_params, message",
+ [
+ ({"algorithm": "unsupported"}, "algorithm must be"),
+ ({"partitions": 8}, "advanced_params is invalid"),
+ ],
+ ids=["unsupported-algorithm", "ivf-option-for-hnsw"],
+)
+def test_vector_index_rejects_invalid_hnsw_advanced_params(
+ advanced_params, message
+):
+ with pytest.raises(ValidationError, match=message):
VectorIndexParams.model_validate(
{
"organization": "INMEMORY GRAPH",
"distribute_params": {"distribute_method": "AUTO"},
- "advanced_params": {"algorithm": "unsupported"},
+ "advanced_params": advanced_params,
}
)
@@ -211,12 +260,23 @@ def test_validate_query_and_rerank_models_accept_documented_shapes():
validate_rerank_model_params({"top_n": 3})
-@pytest.mark.parametrize("value", [True, 0, -1, "3"])
-def test_validate_positive_top_k_rejects_non_positive_or_non_numeric(value):
- with pytest.raises(ValueError, match="greater than zero"):
+@pytest.mark.parametrize(
+ "value", [True, False, None, 0, -1, 0.2, 2.5, 2.0, "3", set(), {}]
+)
+def test_validate_positive_top_k_rejects_non_positive_or_non_integer(value):
+ """Reject booleans, floats, and values outside the positive integer range."""
+ with pytest.raises(
+ ValueError, match=r"top_k must be a positive integer \(>= 1\)"
+ ):
validate_positive_top_k(value)
+@pytest.mark.parametrize("value", [1, 3])
+def test_validate_positive_top_k_accepts_positive_integers(value):
+ """Accept positive integer result counts, including the lower boundary."""
+ assert validate_positive_top_k(value) is None # nosec B101
+
+
def test_validate_list_vectors_accepts_pagination_and_ids():
validate_list_vectors_arguments({"limit": 10, "offset": 0, "ids": ["one"]})
validate_list_vectors_arguments({"limit": 1, "offset": 1.5})
@@ -255,13 +315,25 @@ def test_parameter_validators_reject_non_mapping_values(validator, name):
def test_validate_operation_arguments_skips_missing_optional_values():
+ """Skip None for optional query fields and unknown operations."""
validate_operation_arguments(
"query",
- {"query_by": None, "top_k": None, "advanced_options": None},
+ {"query_by": None, "top_k": 3, "advanced_options": None},
)
validate_operation_arguments("unknown_operation", {})
+def test_validate_operation_arguments_rejects_none_for_required_top_k():
+ """Validate required top_k even though optional query fields may be None."""
+ with pytest.raises(
+ ValueError, match=r"top_k must be a positive integer \(>= 1\)"
+ ):
+ validate_operation_arguments(
+ "query",
+ {"query_by": None, "top_k": None, "advanced_options": None},
+ )
+
+
def test_validate_operation_arguments_dispatches_list_vectors_validation():
validate_operation_arguments("list_vectors", {"limit": 5, "offset": 0})
with pytest.raises(ValueError, match="limit must be"):
diff --git a/tox.ini b/tox.ini
index 7579679..a8afd16 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
[tox]
-envlist = py310, py311, py312, py313, py314
+envlist = py310, py311, py312, py313, py314, py315
isolated_build = true
skip_missing_interpreters = true
@@ -14,6 +14,7 @@ basepython =
py312: python3.12
py313: python3.13
py314: python3.14
+ py315: python3.15
setenv =
PYTHONPATH = {toxinidir}/{env:SOURCE_DIR:src}