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(),
)