diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7288dc2..a0aa9f3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,25 @@ 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.5 - 2026-10-01 +------------------ + +Added +~~~~~ + +- Added configurable HTTP request timeouts through ``Configuration.timeout``, + supporting both total timeouts and separate connect/read timeouts. +- Added direct iteration support for query, paged, and vector collection + responses. +- Added configurable retry-delay limits for transient HTTP 429 responses, + including support for server-provided ``Retry-After`` values. + +Fixed +~~~~~ + +- Improved upsert error messages for missing or oversized vector IDs, including guidance on enabling automatic ID generation. +- Removed REST endpoint values from being echoed in URL validation errors. + 1.0.4 - 2026-09-21 ------------------ diff --git a/Makefile b/Makefile index 1d96caf..64adbf3 100644 --- a/Makefile +++ b/Makefile @@ -13,15 +13,12 @@ SHELL := /bin/bash # ============================================================================== # 1. CONFIGURATION # ============================================================================== -# Prefer the platform's explicit Python 3 executable. Some environments do -# not provide an unversioned `python` command. -PYTHON = python +# Prefer the repository virtual environment when available, then fall back to +# the platform's Python 3 executable. The variable remains overridable with +# `make PYTHON=/path/to/python ...`. +PYTHON ?= $(shell if test -x .venv310/bin/python; then printf '%s' .venv310/bin/python; elif test -x .venv/bin/python; then printf '%s' .venv/bin/python; elif command -v python3 >/dev/null 2>&1; then command -v python3; else printf '%s' python; fi) SOURCE_DIR = src TARGET_DIRS = $(SOURCE_DIR) tests examples -DOCS_DIR = docs -DOCS_BUILD_DIR = $(DOCS_DIR)/build -DOC_ZIP_PREFIX = $(SDK_NAME)-api-ref - # Optional flag to enable report generation. Use 'gmake REPORT=1' REPORT ?= INTEGRATION_TEST_WORKERS ?= 10 @@ -45,11 +42,11 @@ SECURITY_REPORT_DIR := $(REPORT_DIR)/security # Tools PYTEST = $(PYTHON) -m pytest -BLACK = black -FLAKE8 = flake8 -MYPY = mypy -TOX = tox -BANDIT = bandit +BLACK = $(PYTHON) -m black +FLAKE8 = $(PYTHON) -m flake8 +MYPY = $(PYTHON) -m mypy +TOX = $(PYTHON) -m tox +BANDIT = $(PYTHON) -m bandit # Build command BUILD = $(PYTHON) -m build @@ -64,12 +61,7 @@ COVERAGE_XML_FLAG := $(if $(REPORT), --cov-report=xml:$(COVERAGE_REPORT_DIR)/cov COVERAGE_HTML_FLAG := $(if $(REPORT), --cov-report=html:$(COVERAGE_REPORT_DIR)/html,) BANDIT_REPORT_FLAG := $(if $(REPORT), -f json -o $(SECURITY_REPORT_DIR)/bandit_report.json,) -SDK_NAME := $(shell $(PYTHON) -c "import pathlib, re, sys; text = pathlib.Path('pyproject.toml').read_text(); match = re.search(r'^name\\s*=\\s*\"([^\"\\n]+)\"', text, re.MULTILINE); print(match.group(1)) if match else sys.exit('name not found in pyproject.toml')") -# Read the version source directly so `make install_dev` works in a freshly -# created environment, before runtime dependencies such as pydantic exist. -SDK_VERSION := $(shell $(PYTHON) -c "import ast, pathlib, sys; version = next((ast.literal_eval(line.split('=', 1)[1].strip()) for line in pathlib.Path('src/oracle_vecdb/version.py').read_text().splitlines() if line.lstrip().startswith('SDK_VERSION')), None); print(version) if version is not None else sys.exit('SDK_VERSION not found in src/oracle_vecdb/version.py')") - -.PHONY: all check distribute format format_check lint type_check test integration_test build install_dev clean help reports_dirs security_check generate_docs +.PHONY: all check distribute format format_check lint type_check test integration_test build install_dev clean help reports_dirs security_check # ============================================================================= # 2. CORE TARGETS @@ -131,7 +123,7 @@ test: reports_dirs ## Run unit tests with coverage @echo " -> All tests passed." integration_test: reports_dirs ## Run live VecDB integration tests - @echo "Running VecDB integration tests (pytest-xdist)..." + @echo "Running VecDB integration tests..." @if ! [[ "$(INTEGRATION_TEST_WORKERS)" =~ ^[0-9]+$$ ]] || [ "$(INTEGRATION_TEST_WORKERS)" -lt 1 ]; then \ echo "Error: INTEGRATION_TEST_WORKERS must be a positive integer."; \ exit 2; \ @@ -146,23 +138,32 @@ integration_test: reports_dirs ## Run live VecDB integration tests export VECDB_REQUIRE_INTEGRATION_TEST_ENV=true; \ 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 \ - TEST_FILES+=("$$test_file"); \ + if [ "$$test_file" != "$$EXCLUDED_TEST_FILE" ] && [ "$$(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' -print | sort); \ + 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); \ fi; \ for test_file in $(INTEGRATION_TEST_SERIAL_FILES); do \ - if [[ ! " $${TEST_FILES[*]} " =~ " $$test_file " ]]; then TEST_FILES+=("$$test_file"); fi; \ + 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; \ done; \ REPORT_ARG=(); \ if [ -n "$(REPORT)" ]; then \ REPORT_ARG=(--junitxml "$(INTEGRATION_TEST_REPORT_SHARDS_DIR)/integration.xml"); \ fi; \ + PYTEST_XDIST_ARGS=(); \ + if [ "$(INTEGRATION_TEST_WORKERS)" -gt 1 ]; then \ + PYTEST_XDIST_ARGS=(-n "$(INTEGRATION_TEST_WORKERS)" --dist=$(WORKER_SCHEDULE)); \ + fi; \ TEST_STATUS=0; \ - if [ "$${#TEST_FILES[@]}" -gt 0 ] && [ "$(DEBUG)" -gt 0 ]; then \ + if [ "$${#TEST_FILES[@]}" -eq 0 ]; then \ + echo "No integration test files selected."; \ + elif [ "$(DEBUG)" -gt 0 ]; then \ MASTER_PID=$$$$ $(PYTEST) \ -rfE \ --tb=short \ @@ -172,8 +173,7 @@ integration_test: reports_dirs ## Run live VecDB integration tests --capture=tee-sys \ --color=yes \ --durations-min=1 \ - -n "$(INTEGRATION_TEST_WORKERS)" \ - --dist=$(WORKER_SCHEDULE) \ + "$${PYTEST_XDIST_ARGS[@]}" \ "$${REPORT_ARG[@]}" \ "$${TEST_FILES[@]}"; \ TEST_STATUS=$$?; \ @@ -184,8 +184,7 @@ integration_test: reports_dirs ## Run live VecDB integration tests --durations=5 \ --color=yes \ --durations-min=1 \ - -n "$(INTEGRATION_TEST_WORKERS)" \ - --dist=$(WORKER_SCHEDULE) \ + "$${PYTEST_XDIST_ARGS[@]}" \ "$${REPORT_ARG[@]}" \ "$${TEST_FILES[@]}"; \ TEST_STATUS=$$?; \ @@ -205,22 +204,6 @@ integration_test: reports_dirs ## Run live VecDB integration tests exit $$TEST_STATUS @echo " -> Integration tests passed." -generate_docs: ## Build SDK docs and package archive - @echo "Building SDK documentation (Sphinx HTML)..." - @if [ ! -f "$(DOCS_DIR)/source/conf.py" ]; then \ - echo "Error: Sphinx configuration not found at $(DOCS_DIR)/source/conf.py"; \ - exit 2; \ - fi - @rm -rf $(DOCS_BUILD_DIR) - @$(MAKE) -C $(DOCS_DIR) html - @echo "Packaging HTML documentation archive..." - @if [ ! -d "$(DOCS_BUILD_DIR)/html" ]; then \ - echo "Error: Expected HTML build directory not found: $(DOCS_BUILD_DIR)/html"; \ - exit 1; \ - fi - @cd "$(DOCS_BUILD_DIR)/html" && zip -qr "../$(DOC_ZIP_PREFIX)-$(SDK_VERSION).zip" . - @echo " -> Documentation archived at $(DOCS_BUILD_DIR)/$(DOC_ZIP_PREFIX)-$(SDK_VERSION).zip" - # ============================================================================== # 4. UTILITY TARGETS # ============================================================================== @@ -243,7 +226,7 @@ install_dev: ## Install development dependencies clean: ## Remove build/test caches and reports @echo "Cleaning up artifacts..." - @rm -rf .mypy_cache .pytest_cache .coverage htmlcov/ build dist __parfait__ $(REPORT_DIR) $(DOCS_BUILD_DIR) + @rm -rf .mypy_cache .pytest_cache .coverage htmlcov/ build dist __parfait__ $(REPORT_DIR) @find . -name "__pycache__" -exec rm -rf {} + @echo " -> Cleanup complete." diff --git a/src/oracle_vecdb/configuration.py b/src/oracle_vecdb/configuration.py index 4d98687..838b13d 100644 --- a/src/oracle_vecdb/configuration.py +++ b/src/oracle_vecdb/configuration.py @@ -17,6 +17,7 @@ import copy import logging +import math import os import re from logging import FileHandler @@ -66,6 +67,15 @@ ServerVariablesT = Dict[str, str] + +def _is_finite_timeout(value: Union[int, float]) -> bool: + """Return whether a numeric timeout is finite without leaking overflow.""" + try: + return math.isfinite(value) + except OverflowError: + return False + + GenericAuthSetting = TypedDict( "GenericAuthSetting", { @@ -222,6 +232,12 @@ class ConfigurationManualMixin: :param retries: Number of retries for API requests. :param ca_cert_data: verify the peer using concatenated CA certificate data in PEM (str) or DER (bytes) format. + :param timeout: Optional request timeout in seconds. A number sets the + total timeout; a ``(connect, read)`` tuple sets separate connection + and read timeouts. If omitted, no SDK timeout is configured and the + underlying urllib3 behavior is preserved. + The setting applies to SDK HTTP requests, not asynchronous job + completion or polling loops. It can be changed after construction. :Example: @@ -279,6 +295,38 @@ def _validate_base_path(base_path: str) -> None: def _raise_missing_service_configuration() -> None: raise ValueError("Please provide an ORDS endpoint using 'rest_url'.") + @staticmethod + def _validate_timeout( + value: Optional[Union[float, tuple[float, float]]], + ) -> Optional[Union[float, tuple[float, float]]]: + """Validate and return a public HTTP request timeout value.""" + if value is None: + return None + + if isinstance(value, tuple): + if len(value) != 2 or any( + isinstance(item, bool) + or not isinstance(item, (int, float)) + or not _is_finite_timeout(item) + or item <= 0 + for item in value + ): + raise ValueError( + "timeout tuple values must be positive numbers" + ) + return value + + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not _is_finite_timeout(value) + or value <= 0 + ): + raise ValueError( + "timeout must be a positive number or (connect, read) tuple" + ) + return value + @staticmethod def _build_auth_setting( auth_type: str, value: Optional[str] @@ -393,25 +441,7 @@ def __init__( """Default Base url """ if timeout is not None: - if isinstance(timeout, tuple): - if len(timeout) != 2 or any( - isinstance(value, bool) - or not isinstance(value, (int, float)) - or value <= 0 - for value in timeout - ): - raise ValueError( - "timeout tuple values must be positive numbers" - ) - elif ( - isinstance(timeout, bool) - or not isinstance(timeout, (int, float)) - or timeout <= 0 - ): - raise ValueError( - "timeout must be a positive number or (connect, read) tuple" - ) - self.timeout = timeout + self.timeout = timeout self.server_index = ( 0 if server_index is None and resolved_base_path is None @@ -552,6 +582,17 @@ def __deepcopy__(self, memo: Dict[int, Any]) -> Self: result.debug = self.debug return result + @property + def timeout(self) -> Optional[Union[float, tuple[float, float]]]: + """Optional user-configured timeout for SDK HTTP requests.""" + return getattr(self, "_timeout", None) + + @timeout.setter + def timeout( + self, value: Optional[Union[float, tuple[float, float]]] + ) -> None: + self._timeout = self._validate_timeout(value) + def __setattr__(self, name: str, value: Any) -> None: object.__setattr__(self, name, value) diff --git a/src/oracle_vecdb/data_types/responses.py b/src/oracle_vecdb/data_types/responses.py index e97f09e..3fdc81c 100644 --- a/src/oracle_vecdb/data_types/responses.py +++ b/src/oracle_vecdb/data_types/responses.py @@ -14,6 +14,7 @@ Any, Dict, Iterable, + Iterator, List, Optional, Sequence, @@ -195,6 +196,10 @@ class QueryResponse(_VecDBModel): def __len__(self) -> int: return len(self.items) + def __iter__(self) -> Iterator[QueryResultItem]: # type: ignore[override] + """Iterate over the query result items.""" + return iter(self.items) + def __getitem__(self, index: int) -> QueryResultItem: return self.items[index] @@ -341,6 +346,10 @@ class _PagedResponse(_VecDBModel): count: Optional[int] = None links: Optional[List[Any]] = None + def __iter__(self) -> Iterator[Any]: # type: ignore[override] + """Iterate over the response items.""" + return iter(self.items) + @classmethod def from_internal(cls, response: Any) -> Self: values = {name: _value(response, name) for name in cls.model_fields} @@ -366,6 +375,10 @@ class VectorCollectionResponse(_VecDBModel): offset: Optional[int] = None count: Optional[int] = None + def __iter__(self) -> Iterator[Any]: # type: ignore[override] + """Iterate over the response items.""" + return iter(self.items) + @classmethod def from_internal(cls, response: Any) -> Self: values = {name: _value(response, name) for name in cls.model_fields} diff --git a/src/oracle_vecdb/error_messages.py b/src/oracle_vecdb/error_messages.py index 10043b8..a331ee3 100644 --- a/src/oracle_vecdb/error_messages.py +++ b/src/oracle_vecdb/error_messages.py @@ -4,12 +4,12 @@ ERROR_MESSAGES: Mapping[str, Mapping[str, str]] = { "VECDB-001": { - "message": "Insecure REST URL: {rest_url}. HTTPS is required.", + "message": "Insecure REST URL. HTTPS is required.", "cause": "Plain-text HTTP can expose authentication details in transit.", "action": "Set rest_url to an endpoint that starts with 'https://'.", }, "VECDB-002": { - "message": "Invalid REST URL format: {rest_url}.", + "message": "Invalid REST URL format.", "cause": "The REST URL does not match the required VecDB URL structure.", "action": "Use https://:/ords//_/db-api/(stable|)/vecdb/.", }, diff --git a/src/oracle_vecdb/ords.py b/src/oracle_vecdb/ords.py index 6a26634..a873655 100644 --- a/src/oracle_vecdb/ords.py +++ b/src/oracle_vecdb/ords.py @@ -7,6 +7,7 @@ from __future__ import annotations +import math from typing import Any, Dict, List, Optional, Union, cast from .data_types import ( @@ -57,13 +58,15 @@ from .ords_response_handlers import ORDSResponseHandler from .vecdb_exception import VecDBException +DEFAULT_MAX_RETRY_DELAY = 60.0 + class _CustomApiClient(ApiClient): """Handwritten adapter for generator-owned request plumbing. Generated API methods pass ``None`` when no per-request timeout is set. - Apply the SDK configuration default here so regeneration of ``ApiClient`` - does not discard the public timeout contract. + Forward the user-configured timeout, when present, without imposing an + SDK default. """ def call_api( @@ -94,9 +97,27 @@ def __init__( self, max_retry_count_error_555: int = 3, max_retry_count_error_429: int = 3, + max_retry_delay: float = DEFAULT_MAX_RETRY_DELAY, ) -> None: self.max_retry_count_error_555 = max(0, int(max_retry_count_error_555)) self.max_retry_count_error_429 = max(0, int(max_retry_count_error_429)) + if isinstance(max_retry_delay, bool) or not isinstance( + max_retry_delay, (int, float) + ): + raise ValueError( + "max_retry_delay must be a finite non-negative number" + ) + try: + normalized_delay = float(max_retry_delay) + except OverflowError as error: + raise ValueError( + "max_retry_delay must be a finite non-negative number" + ) from error + if not math.isfinite(normalized_delay) or normalized_delay < 0: + raise ValueError( + "max_retry_delay must be a finite non-negative number" + ) + self.max_retry_delay = normalized_delay _models = cast(Any, _generated_models) @@ -114,9 +135,14 @@ def __init__( class ORDSConfiguration(ORDSBaseConfiguration): """Configuration selected for ORDS/REST based VecDB access.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: + def __init__( + self, + *args: Any, + max_retry_delay: float = DEFAULT_MAX_RETRY_DELAY, + **kwargs: Any, + ) -> None: super().__init__(*args, **kwargs) - self.ords_settings = ORDSSettings() + self.ords_settings = ORDSSettings(max_retry_delay=max_retry_delay) if not self.has_rest_url: raise ValueError( diff --git a/src/oracle_vecdb/ords_response_handlers.py b/src/oracle_vecdb/ords_response_handlers.py index ebe0a6b..7abe5bf 100644 --- a/src/oracle_vecdb/ords_response_handlers.py +++ b/src/oracle_vecdb/ords_response_handlers.py @@ -1,6 +1,11 @@ """Small response handlers shared by the ORDS service facade.""" from functools import wraps +from email.utils import parsedate_to_datetime +from datetime import timezone +import math +import secrets +import time from types import MethodType from typing import Any, Callable @@ -10,6 +15,29 @@ class ORDSResponseHandler: """Callable wrapper for extensible ORDS response handling.""" + # Some read-only operations use POST because their request filters are + # JSON bodies. Keep retry policy semantic and fail closed for new methods. + _RETRY_SAFE_OPERATIONS = frozenset( + { + "describe_vector_database", + "list_vector_tables", + "describe_vector_table", + "generate_embedding", + "list_vectors", + "list_vector_load_jobs", + "describe_vector_load_job", + "get_vector_load_job_log", + "query", + "rerank", + "list_index_jobs", + "describe_index_job", + "get_index_job_log", + "describe_index", + "list_models", + "describe_model", + } + ) + def __init__(self, function: Callable[..., Any]) -> None: self.function = function wraps(function)(self) @@ -37,11 +65,18 @@ def _handle_exception( self, error: Exception, *args: Any, **kwargs: Any ) -> Any: if self._is_555(error): + if not self._is_retry_safe_operation(): + raise error return self.handle_555(error, *args, **kwargs) if self._is_429(error): + if not self._is_retry_safe_operation(): + raise error return self.handle_429(error, *args, **kwargs) raise error + def _is_retry_safe_operation(self) -> bool: + return self.function.__name__ in self._RETRY_SAFE_OPERATIONS + def handle_555(self, error: Exception, *args: Any, **kwargs: Any) -> Any: """Retry transient ORDS 555/ORDS-25001 responses.""" return self._retry( @@ -72,7 +107,8 @@ def _retry( ) -> Any: """Retry one error type and redispatch a different subsequent error.""" latest_error = error - for _ in range(max_retries): + for retry_number in range(max_retries): + self._sleep_before_retry(latest_error, retry_number, args) try: return self.function(*args, **kwargs) except VecDBException: @@ -86,6 +122,58 @@ def _retry( return self._handle_exception(next_error, *args, **kwargs) raise latest_error + @classmethod + def _sleep_before_retry( + cls, error: Exception, retry_number: int, args: tuple[Any, ...] + ) -> None: + """Honor Retry-After and otherwise use bounded jittered backoff.""" + if not cls._is_429(error): + return + + max_delay = cls._max_retry_delay(args) + retry_after = cls._retry_after_seconds(error) + if retry_after is None: + upper_bound = min(max_delay, float(2**retry_number)) + retry_after = secrets.SystemRandom().uniform(0.0, upper_bound) + else: + # Retry-After is server-controlled input; cap it before reaching + # the blocking sleep so a malformed or hostile response cannot + # stall the caller indefinitely. + retry_after = min(retry_after, max_delay) + + time.sleep(retry_after) + + @staticmethod + def _retry_after_seconds(error: Exception) -> float | None: + headers = getattr(error, "headers", None) + if not headers: + return None + + retry_after = None + if hasattr(headers, "get"): + retry_after = headers.get("Retry-After") + if retry_after is None: + retry_after = headers.get("retry-after") + if retry_after is None: + return None + + value = str(retry_after).strip() + try: + delay = float(value) + except (TypeError, ValueError): + delay = None + if delay is not None and math.isfinite(delay) and delay >= 0: + return delay + + try: + retry_at = parsedate_to_datetime(value) + except (TypeError, ValueError, OverflowError): + return None + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=timezone.utc) + delay = retry_at.timestamp() - time.time() + return max(0.0, delay) if math.isfinite(delay) else None + @staticmethod def _is_555(error: Exception) -> bool: return ( @@ -104,3 +192,16 @@ def _max_retries(args: tuple[Any, ...], setting_name: str) -> int: getattr(service, "config", None), "ords_settings", None ) return max(0, int(getattr(settings, setting_name, 3))) + + @staticmethod + def _max_retry_delay(args: tuple[Any, ...]) -> float: + service = args[0] if args else None + settings = getattr( + getattr(service, "config", None), "ords_settings", None + ) + max_delay = getattr(settings, "max_retry_delay", 1.0) + try: + max_delay = float(max_delay) + except (OverflowError, TypeError, ValueError): + return 0.0 + return max(0.0, max_delay) if math.isfinite(max_delay) else 0.0 diff --git a/src/oracle_vecdb/validation.py b/src/oracle_vecdb/validation.py index 38b8485..5e3029a 100644 --- a/src/oracle_vecdb/validation.py +++ b/src/oracle_vecdb/validation.py @@ -120,6 +120,7 @@ def validate_common_spec_arguments( @wraps(function) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: arguments = signature.bind(*args, **kwargs) + public_error: VecDBException try: validate_operation_arguments(function.__name__, arguments.arguments) except (ValidationError, ValueError, TypeError) as validation_error: @@ -129,13 +130,18 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: ) # Do not attach the full nested request: it may contain credentials, # signed URLs, query text, or other sensitive application data. - raise VecDBException.from_service_error( + public_error = VecDBException.from_service_error( operation=function.__name__, arguments={"kwargs": {"request": ""}}, service_name="validation", error=error, - ) from validation_error - return function(*arguments.args, **arguments.kwargs) + ) + else: + return function(*arguments.args, **arguments.kwargs) + + # Raise outside the except block so Python does not retain the raw + # validation exception as the public exception context. + raise public_error from None return wrapper diff --git a/src/oracle_vecdb/vecdb_exception.py b/src/oracle_vecdb/vecdb_exception.py index afc9e34..1feba20 100644 --- a/src/oracle_vecdb/vecdb_exception.py +++ b/src/oracle_vecdb/vecdb_exception.py @@ -106,6 +106,35 @@ r"(?:(?P[\"'])(?P[^\"']*)" r"(?P=value_quote)|(?P[^,\s}\]]+))" ) +_VECTOR_ID_NULL_GUIDANCE = ( + "The vector ID is missing or exceeds the vector table's ID-column limit." +) +_VECTOR_ID_NULL_ACTION = ( + "Verify that each vector ID complies with the vector table's documented " + "ID-column limit. Alternatively, create the table with " + 'table_params={"auto_generate_id": True}.' +) + + +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: @@ -478,7 +507,21 @@ def _customize_error(self) -> None: original, "error_instance", None ) - if not self.error_message: + timeout_error = _is_timeout_exception(original) + if timeout_error: + detail = self._redact_diagnostic_text(str(original)).strip() + operation = self.operation or "the request" + self.error_code = self.error_code or "TIMEOUT" + self.error_message = ( + f"Request timed out while executing '{operation}'. " + "Increase Configuration.timeout and retry." + ) + if detail and detail.lower() not in { + "timeout", + "timed out", + }: + self.error_message += f" Details: {detail}" + elif not self.error_message: self.error_message = str( self.reason or self.body or original or "Request failed" ).strip() @@ -489,9 +532,44 @@ def _customize_error(self) -> None: status = self.status if self.status is not None else "unknown" self.error_code = str(self.error_code or f"HTTP-{status}") - self.cause, self.action = guidance_for_status(self.status) + if timeout_error: + self.cause = ( + "The configured HTTP request timeout elapsed before the " + "operation completed." + ) + self.action = ( + "Increase Configuration.timeout for slow operations, then " + "retry." + ) + else: + self.cause, self.action = guidance_for_status(self.status) + self._has_operation_guidance = False + if self.operation == "upsert_vectors": + guidance = self.vector_id_null_guidance( + self.body, + self.data, + self.error_message, + self.reason, + ) + if guidance: + self.cause, self.action = guidance + self._has_operation_guidance = True self._sync_exception_fields() + @staticmethod + def vector_id_null_guidance(*details: Any) -> Optional[tuple[str, str]]: + """Return guidance for an ID-column ORA-01400 failure.""" + text = " ".join(str(value or "") for value in details) + if not re.search(r"ORA-01400", text, re.IGNORECASE): + return None + if not re.search( + r"cannot\s+insert\s+NULL\s+into\s*\([^)]*\bID\b", + text, + re.IGNORECASE, + ): + return None + return _VECTOR_ID_NULL_GUIDANCE, _VECTOR_ID_NULL_ACTION + def _sync_exception_fields(self) -> None: """Expose concrete ORDSErrorResponse field names for diagnostics.""" self.code = self.error_code @@ -558,6 +636,8 @@ def format( "\nService stack trace:\n" f"{self._redact_diagnostic_text(self.stack_trace)}" ) + if getattr(self, "_has_operation_guidance", False): + message += f"\nCause: {self.cause}\nAction: {self.action}" return self._redact_diagnostic_text(message) message = f"({getattr(self, 'status', None)}) {self.vecdb_message}" @@ -578,6 +658,8 @@ def _format_service_error( data = getattr(error, "data", None) reason = getattr(error, "reason", None) message = f"{name}: ({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): diff --git a/src/oracle_vecdb/version.py b/src/oracle_vecdb/version.py index aacfcea..ee9a8b6 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.4" +SDK_VERSION = "1.0.5" ORDS_RELEASE_VERSION = "26.2.2" diff --git a/tests/client/test_client_facade_contract.py b/tests/client/test_client_facade_contract.py index 24ffb01..457b09a 100644 --- a/tests/client/test_client_facade_contract.py +++ b/tests/client/test_client_facade_contract.py @@ -72,6 +72,16 @@ def _make_client(mocker): ) +def test_timeout_remains_configuration_only(): + public_methods = inspect.getmembers(OracleVecDB, inspect.isfunction) + + assert all( + "timeout" not in inspect.signature(method).parameters + for name, method in public_methods + if not name.startswith("_") + ) # nosec B101 + + def test_convert_debug_flags_delegates_to_active_backend(mocker): client, active_backend, _ = _make_client(mocker) result = client._convert_debug_flags({"vector_index": "low"}) diff --git a/tests/client/test_configuration_facade.py b/tests/client/test_configuration_facade.py index 4ac3c42..d25dbda 100644 --- a/tests/client/test_configuration_facade.py +++ b/tests/client/test_configuration_facade.py @@ -5,6 +5,7 @@ import copy import http.client +import inspect import os from pathlib import Path @@ -14,6 +15,7 @@ from oracle_vecdb.configuration import ( Configuration, + ConfigurationManualMixin, ORDSBaseConfiguration, ORDSConfiguration, apply_configuration_extensions, @@ -115,12 +117,81 @@ def test_configuration_rejects_positional_endpoint(monkeypatch): Configuration(VALID_HOST) -@pytest.mark.parametrize("timeout", [True, 0, (1, False), (1, -1)]) +@pytest.mark.parametrize( + "timeout", + [ + True, + 0, + float("inf"), + float("nan"), + (1, False), + (1, -1), + (1, float("inf")), + 10**400, + (10**400, 1), + (1, 10**400), + ], +) def test_configuration_rejects_invalid_timeout(timeout): with pytest.raises(ValueError, match="timeout"): Configuration(rest_url=VALID_HOST, timeout=timeout) +def test_configuration_timeout_can_be_overwritten_with_validation(): + cfg = Configuration(rest_url=VALID_HOST, timeout=12.5) + + cfg.timeout = (5.0, 60.0) + + assert cfg.timeout == (5.0, 60.0) # nosec B101 + + with pytest.raises(ValueError, match="timeout"): + cfg.timeout = (5.0, 0) + + assert cfg.timeout == (5.0, 60.0) # nosec B101 + + +def test_configuration_timeout_is_not_set_by_default(): + cfg = Configuration(rest_url=VALID_HOST) + + assert cfg.timeout is None # nosec B101 + assert not hasattr(cfg, "_timeout") # nosec B101 + + cfg.timeout = 12.5 + + assert cfg.timeout == 12.5 # nosec B101 + + +def test_configuration_timeout_preserves_positional_slot(): + parameter = inspect.signature(ConfigurationManualMixin.__init__).parameters[ + "timeout" + ] + + assert ( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + ) # nosec B101 + + positional_args = [None] * 13 + [12.5] + cfg = Configuration(*positional_args, rest_url=VALID_HOST) + + assert cfg.timeout == 12.5 # nosec B101 + assert cfg.verify_ssl is True # nosec B101 + + +def test_configuration_exposes_max_retry_delay(): + cfg = Configuration(rest_url=VALID_HOST, max_retry_delay=5.0) + + assert cfg.ords_settings.max_retry_delay == 5.0 # nosec B101 + + +@pytest.mark.parametrize( + "max_retry_delay", + [True, -1, float("inf"), float("nan")], +) +def test_configuration_rejects_invalid_max_retry_delay(max_retry_delay): + with pytest.raises(ValueError, match="max_retry_delay"): + Configuration(rest_url=VALID_HOST, max_retry_delay=max_retry_delay) + + def test_generated_configuration_without_rest_url_reports_false(): cfg = ORDSBaseConfiguration.__new__(ORDSBaseConfiguration) @@ -472,7 +543,7 @@ def test_generated_configuration_debug_does_not_toggle_global_http_debug_or_log_ ): original = http.client.HTTPConnection.debuglevel username = "test-user" - password = "test-password" # nosec B105 + password = "test-password" # nosec try: http.client.HTTPConnection.debuglevel = 7 cfg = generated_configuration.Configuration( @@ -494,7 +565,7 @@ def test_generated_configuration_debug_does_not_toggle_global_http_debug_or_log_ def test_debug_true_does_not_log_configured_credentials(caplog, monkeypatch): _reset_env_vars(monkeypatch) - access_token = "Bearer test-token-that-must-not-be-logged" # nosec B105 + access_token = "Bearer test-token-that-must-not-be-logged" # nosec cfg = Configuration( rest_url=VALID_HOST, access_token=access_token, @@ -515,9 +586,9 @@ def test_debug_true_does_not_log_configured_credentials(caplog, monkeypatch): "username": "user", "password": "pass", "access_token": "token", - }, # nosec B105 - {"username": "user", "password": None}, # nosec B105 - {"username": None, "password": "pass"}, # nosec B105 + }, # nosec + {"username": "user", "password": None}, # nosec + {"username": None, "password": "pass"}, # nosec ], ) def test_configuration_rejects_conflicting_or_partial_authentication(kwargs): diff --git a/tests/data_types/test_responses.py b/tests/data_types/test_responses.py index c5f3cb2..96ad2ed 100644 --- a/tests/data_types/test_responses.py +++ b/tests/data_types/test_responses.py @@ -12,11 +12,15 @@ DropVectorTableResponse, IndexDetailsResponse, IndexDescriptionResponse, + JobCollectionResponse, JobLogResponse, + ModelCollectionResponse, QueryResponse, QueryResultItem, RerankResponse, RerankResultItem, + VectorCollectionResponse, + VectorTableCollectionResponse, VectorTableResponse, ) from oracle_vecdb.services.ords.models.query_vectors200_response import ( @@ -236,7 +240,9 @@ def test_query_result_item_normalizes_existing_and_object_values(): def test_query_response_normalizes_items_attribute_sequence_and_existing(): - existing = QueryResponse(items=[QueryResultItem(id="existing")]) + existing = QueryResponse( + items=[QueryResultItem(id="first"), QueryResultItem(id="second")] + ) object_response = AttributeItem( items=[AttributeItem(id="from-items", metadata={}, distance=0.2)] ) @@ -245,7 +251,12 @@ def test_query_response_normalizes_items_attribute_sequence_and_existing(): ] assert QueryResponse.from_internal(existing) is existing # nosec B101 - assert len(existing) == 1 # nosec B101 + assert len(existing) == 2 # nosec B101 + assert list(existing) == existing.items # nosec B101 + assert [item.id for item in existing] == ["first", "second"] # nosec B101 + assert all( # nosec B101 + isinstance(item, QueryResultItem) for item in existing + ) assert ( QueryResponse.from_internal(object_response)[0].id == "from-items" ) # nosec B101 @@ -255,6 +266,25 @@ def test_query_response_normalizes_items_attribute_sequence_and_existing(): ) +@pytest.mark.parametrize( + "response_type", + [ + VectorTableCollectionResponse, + VectorCollectionResponse, + ModelCollectionResponse, + JobCollectionResponse, + ], +) +def test_collection_responses_iterate_over_items(response_type): + response = response_type(items=[{"id": "first"}, {"id": "second"}]) + + assert list(response) == response.items # nosec B101 + assert [item["id"] for item in response] == [ + "first", + "second", + ] # nosec B101 + + def test_query_response_from_generated_query_vectors_response(): internal = QueryVectors200Response( results=[VecDBSearchItem(id="vec-generated", distance=0.5)] diff --git a/tests/internal/test_vecdb_errors.py b/tests/internal/test_vecdb_errors.py index abac0fe..2b0e2ca 100644 --- a/tests/internal/test_vecdb_errors.py +++ b/tests/internal/test_vecdb_errors.py @@ -104,16 +104,20 @@ def test_vecdb_error_print_oerr_without_cause_or_action_avoids_none(capsys): def test_invalid_host_format_error_derives_messages(): - err = InvalidHostFormatError("http://bad") + url = "http://bad" + err = InvalidHostFormatError(url) assert "VECDB-002" in err.get_error() # nosec B101 + assert url not in err.get_error() # nosec B101 assert "Action:" in err.action # nosec B101 def test_insecure_connection_error_advises_https(): - err = InsecureConnectionError("http://bad") + url = "http://bad" + err = InsecureConnectionError(url) assert "VECDB-001" in err.get_error() # nosec B101 + assert url not in err.get_error() # nosec B101 assert "HTTPS is required" in err.get_error() # nosec B101 @@ -158,7 +162,11 @@ def test_resource_name_errors_include_codes_causes_and_actions( def test_job_and_default_setting_errors_construct_stable_messages(): - ResourceNotFoundError("missing-resource") - InvalidLoadJobLogError("load-job", "RUNNING") - InvalidIndexJobLogError("index-job", "RUNNING") + resource_error = ResourceNotFoundError("caller-resource-value") + load_log_error = InvalidLoadJobLogError("caller-load-job", "RUNNING") + index_log_error = InvalidIndexJobLogError("caller-index-job", "RUNNING") DefaultSettingsParameterMismatchError("query", ["missing_parameter"]) + + assert "caller-resource-value" in resource_error.get_error() # nosec B101 + assert "caller-load-job" in load_log_error.get_error() # nosec B101 + assert "caller-index-job" in index_log_error.get_error() # nosec B101 diff --git a/tests/services/test_ords.py b/tests/services/test_ords.py index 5f5d5c8..a3b3acf 100644 --- a/tests/services/test_ords.py +++ b/tests/services/test_ords.py @@ -8,7 +8,9 @@ from typing import Any, Dict import pytest +import urllib3 import oracle_vecdb.ords as ords_module +import oracle_vecdb.ords_response_handlers as response_handlers_module from pydantic import ValidationError from oracle_vecdb.configuration import Configuration from oracle_vecdb.ords import ORDSService, create_ords_service @@ -21,9 +23,16 @@ class TransportError(Exception): """Transport-shaped error used to test the generic retry contract.""" - def __init__(self, *, status: int, reason: str) -> None: + def __init__( + self, + *, + status: int, + reason: str, + headers: Dict[str, str] | None = None, + ) -> None: self.status = status self.reason = reason + self.headers = headers or {} super().__init__(reason) @@ -52,6 +61,113 @@ def test_sdk_api_client_preserves_per_request_timeout(mocker): ) # nosec B101 +@pytest.mark.parametrize( + "configured_timeout, expected_connect, expected_read, expected_total", + [ + (None, None, None, None), + (12.5, None, None, 12.5), + ((1.0, 2.0), 1.0, 2.0, None), + ], +) +def test_sdk_client_applies_configured_timeout_to_transport( + mocker, + configured_timeout, + expected_connect, + expected_read, + expected_total, +): + config = Configuration(rest_url=VALID_HOST, timeout=configured_timeout) + client = ords_module._CustomApiClient(config) + response = mocker.Mock(status=200, reason="OK", data=b"{}") + request = mocker.patch.object( + client.rest_client.pool_manager, "request", return_value=response + ) + + client.call_api("GET", "/health") + + timeout = request.call_args.kwargs["timeout"] + if configured_timeout is None: + assert timeout is None # nosec B101 + else: + assert isinstance(timeout, urllib3.Timeout) # nosec B101 + assert timeout.total == expected_total # nosec B101 + if expected_total is None: + assert timeout.connect_timeout == expected_connect # nosec B101 + assert timeout.read_timeout == expected_read # nosec B101 + + +def test_sdk_client_uses_updated_configuration_timeout(mocker): + config = Configuration(rest_url=VALID_HOST, timeout=12.5) + client = ords_module._CustomApiClient(config) + response = mocker.Mock(status=200, reason="OK", data=b"{}") + request = mocker.patch.object( + client.rest_client.pool_manager, "request", return_value=response + ) + + client.call_api("GET", "/health") + config.timeout = (1.0, 2.0) + client.call_api("GET", "/health") + + first_timeout = request.call_args_list[0].kwargs["timeout"] + second_timeout = request.call_args_list[1].kwargs["timeout"] + assert first_timeout.total == 12.5 # nosec B101 + assert second_timeout.connect_timeout == 1.0 # nosec B101 + assert second_timeout.read_timeout == 2.0 # nosec B101 + + +@pytest.mark.parametrize( + "method_name, api_name", + [("list_models", "model_api"), ("list_vector_tables", "table_api")], +) +def test_collection_list_operations_apply_configured_timeout( + mocker, method_name, api_name +): + config = Configuration(rest_url=VALID_HOST, timeout=(0.25, 1.5)) + service = ORDSService(config) + response = mocker.Mock( + status=200, + reason="OK", + data=b'{"items": []}', + headers={"content-type": "application/json"}, + ) + request = mocker.patch.object( + service.api_client.rest_client.pool_manager, + "request", + return_value=response, + ) + + getattr(getattr(service, api_name), method_name)() + + timeout = request.call_args.kwargs["timeout"] + assert isinstance(timeout, urllib3.Timeout) # nosec B101 + assert timeout.connect_timeout == 0.25 # nosec B101 + assert timeout.read_timeout == 1.5 # nosec B101 + + +@pytest.mark.parametrize("method_name", ["list_models", "list_vector_tables"]) +def test_collection_list_timeout_is_normalized_with_operation( + mocker, method_name +): + config = Configuration(rest_url=VALID_HOST, timeout=0.01) + service = ORDSService(config) + request = mocker.patch.object( + service.api_client.rest_client.pool_manager, + "request", + side_effect=TimeoutError("simulated request timeout"), + ) + + with pytest.raises(VecDBException) as exc_info: + getattr(service, method_name)() + + error = exc_info.value + assert request.call_count == 1 # nosec B101 + assert error.operation == method_name # nosec B101 + assert error.error_code == "TIMEOUT" # nosec B101 + assert "Increase Configuration.timeout and retry." in str( + error + ) # nosec B101 + + class RecordingApi: def __init__(self, returns: Dict[str, Any] | None = None) -> None: self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = [] @@ -149,7 +265,7 @@ def __init__(self): self.calls = 0 @ORDSResponseHandler - def execute(self): + def list_vector_tables(self): self.calls += 1 if self.calls == 1: raise TransportError(status=429, reason="retry") @@ -157,7 +273,7 @@ def execute(self): endpoint = Endpoint() with pytest.raises(TransportError, match="bad request"): - endpoint.execute() + endpoint.list_vector_tables() assert endpoint.calls == 2 # nosec B101 @@ -376,6 +492,59 @@ def throttled(): assert calls["count"] == 3 # nosec B101 +def test_ords_service_does_not_retry_mutating_operation(): + service = _make_service() + service.config = Configuration(rest_url=VALID_HOST) + service.config.ords_settings.max_retry_count_error_555 = 3 + calls = {"count": 0} + + def create_table(_request): + calls["count"] += 1 + raise TransportError(status=555, reason="ORDS-25001") + + service.table_api.create_vector_table = create_table + + with pytest.raises(VecDBException): + service.create_vector_table(name="docs") + + assert calls["count"] == 1 # nosec B101 + + +@pytest.mark.parametrize( + "retry_after", + ["86400", "Wed, 01 Jan 2099 00:00:00 GMT"], +) +def test_ords_service_caps_server_retry_after_delay(mocker, retry_after): + service = _make_service() + service.config = Configuration( + rest_url=VALID_HOST, + max_retry_delay=2.5, + ) + service.config.ords_settings.max_retry_count_error_429 = 1 + calls = {"count": 0} + delays = [] + + def throttled(): + calls["count"] += 1 + if calls["count"] == 1: + raise TransportError( + status=429, + reason="Too Many Requests", + headers={"Retry-After": retry_after}, + ) + return "tables" + + service.table_api.list_vector_tables = throttled + mocker.patch.object( + response_handlers_module.time, "sleep", side_effect=delays.append + ) + + assert isinstance( + service.list_vector_tables(), ords_module.VectorTableCollectionResponse + ) # nosec B101 + assert delays == [2.5] # nosec B101 + + def test_ords_service_honors_429_max_retries(): service = _make_service() service.config = Configuration(rest_url=VALID_HOST) diff --git a/tests/services/test_ords_exceptions.py b/tests/services/test_ords_exceptions.py index 05ae633..31cd277 100644 --- a/tests/services/test_ords_exceptions.py +++ b/tests/services/test_ords_exceptions.py @@ -4,6 +4,7 @@ import pytest from pydantic import BaseModel, ValidationError +from urllib3.exceptions import MaxRetryError, ReadTimeoutError from oracle_vecdb import VecDBException from oracle_vecdb.services.ords.exceptions import ApiException import oracle_vecdb.vecdb_exception as vecdb_exception_module @@ -301,7 +302,7 @@ def test_service_error_redacts_search_text_and_renders_safe_arguments(): "comment": "Safe table description", "annotations": { "tier": "gold", - "token": "ANNOTATION_SECRET", # nosec B105 + "token": "ANNOTATION_SECRET", # nosec }, "table_params": {"auto_generate_id": True}, }, @@ -327,9 +328,9 @@ def test_service_error_redacts_composite_sensitive_keys_in_safe_arguments(): { "kwargs": { "annotations": { - "token_value": "ANNOTATION_TOKEN_SECRET", # nosec B105 - "api_secret_value": "ANNOTATION_API_SECRET", # nosec B105 - "apiSecretValue": "ANNOTATION_CAMEL_SECRET", # nosec B105 + "token_value": "ANNOTATION_TOKEN_SECRET", # nosec + "api_secret_value": "ANNOTATION_API_SECRET", # nosec + "apiSecretValue": "ANNOTATION_CAMEL_SECRET", # nosec } } }, @@ -432,7 +433,7 @@ def model_dump(self): ], ) def test_exception_redaction_normalizes_sensitive_key_variants(key): - value = "TOP_SECRET_VALUE" # nosec B105 + value = "TOP_SECRET_VALUE" # nosec sanitized = VecDBException._redact_value({key: value}) @@ -466,9 +467,9 @@ def test_service_error_does_not_retain_unknown_nested_annotation_values(): "kwargs": { "annotations": { "tier": "gold", - "credentialValue": "ANNOTATION_SECRET", # nosec B105 + "credentialValue": "ANNOTATION_SECRET", # nosec "customExtension": { - "nestedValue": "NESTED_ANNOTATION_SECRET" # nosec B105 + "nestedValue": "NESTED_ANNOTATION_SECRET" # nosec }, } } @@ -563,6 +564,28 @@ def test_not_found_payload_without_code_is_stable(): ) # nosec B101 +def test_upsert_vector_id_null_error_adds_actionable_guidance(): + error = VecDBException.from_service_error( + "upsert_vectors", + {"kwargs": {"table_name": "DOCS", "vectors": ""}}, + "ORDSService", + ServiceError( + status=400, + reason="Bad Request", + body=( + '{"code":"BadRequest","message":"ORA-01400: cannot ' + 'insert NULL into (\\"SYS\\".\\"DOCS\\".\\"ID\\")"}' + ), + ), + ) + + rendered = str(error) + + assert "ORA-01400: cannot insert NULL" in rendered # nosec B101 + assert "vector ID is missing or exceeds" in rendered # nosec B101 + assert 'table_params={"auto_generate_id": True}' in rendered # nosec B101 + + def test_protocol_error_preserves_original_details(): class ProtocolError(Exception): pass @@ -578,6 +601,43 @@ class ProtocolError(Exception): assert "connection reset by peer" in str(error) # nosec B101 +def test_timeout_error_has_operation_and_actionable_user_message(): + error = VecDBException.from_service_error( + "list_models", + {}, + "ORDSService", + TimeoutError("simulated read timeout"), + ) + + rendered = str(error) + + assert error.error_code == "TIMEOUT" # nosec B101 + assert error.original_exception_type_name == "TimeoutError" # nosec B101 + assert "list_models" in rendered # nosec B101 + assert "client-side request timeout" in rendered # nosec B101 + assert "Increase Configuration.timeout and retry." in rendered # nosec B101 + assert "simulated read timeout" in rendered # nosec B101 + + +def test_wrapped_urllib3_timeout_has_timeout_code_and_message(): + transport_error = MaxRetryError( + "pool", + "https://example.invalid/", + reason=ReadTimeoutError( + "pool", "https://example.invalid/", "Read timed out" + ), + ) + error = VecDBException.from_service_error( + "list_vector_tables", {}, "ORDSService", transport_error + ) + + rendered = str(error) + + assert error.error_code == "TIMEOUT" # nosec B101 + assert "list_vector_tables" in rendered # nosec B101 + assert "Increase Configuration.timeout and retry." in rendered # nosec B101 + + def test_unstructured_error_redacts_marked_secrets_but_preserves_diagnostics(): class UnstructuredError(Exception): def __str__(self): @@ -700,7 +760,7 @@ class TransportError(Exception): data = { "database": "customer_db", "access_token": "token-value", - } # nosec B105 + } # nosec headers = { "Authorization": "Bearer token-value", "Cookie": "session=session-value", @@ -709,7 +769,7 @@ class TransportError(Exception): error = VecDBException.from_service_error( "query", - {"kwargs": {"token": "token-value"}}, # nosec B105 + {"kwargs": {"token": "token-value"}}, # nosec "ORDSService", TransportError(), )