Skip to content

fix: raise provider-specific errors from vectorizer _set_model_dims() - #680

Open
Aryan-Pardeshi wants to merge 5 commits into
redis:mainfrom
Aryan-Pardeshi:fix/vectorizer-dim-error-messages
Open

fix: raise provider-specific errors from vectorizer _set_model_dims()#680
Aryan-Pardeshi wants to merge 5 commits into
redis:mainfrom
Aryan-Pardeshi:fix/vectorizer-dim-error-messages

Conversation

@Aryan-Pardeshi

@Aryan-Pardeshi Aryan-Pardeshi commented Aug 9, 2026

Copy link
Copy Markdown

Fixes #485

Every vectorizer probes its provider with a throwaway embedding call to learn the model's dimensionality. When that probe failed, all eight raised the same message under a # fall back (TODO get more specific) comment:

Error setting embedding model dimensions: <whatever the SDK said>

That tells the caller nothing about which provider rejected them or what to change, which is exactly the troubleshooting cost the issue describes.

Each _set_model_dims() now catches the exception classes its SDK actually raises and reports the provider, the model, and a concrete next step:

  • text/openai.py, text/azureopenai.pyAuthenticationError, PermissionDeniedError, NotFoundError, APIConnectionError. The Azure messages talk about deployments rather than models, since Azure addresses models by deployment name and a wrong deployment is the common failure.
  • text/cohere.pyUnauthorizedError, NotFoundError, then cohere.core.api_error.ApiError.
  • text/mistral.pymistralai.models.SDKError.
  • voyageai.pyAuthenticationError, InvalidRequestError, APIConnectionError from voyageai.error.
  • bedrock.pyClientError, split on the error code so credential problems and unknown model ids give different advice, then BotoCoreError.
  • vertexai.pyPermissionDenied, Unauthenticated, NotFound, then GoogleAPICallError.
  • text/huggingface.pyOSError for a model that will not load, RuntimeError for the CUDA OOM/device-mismatch case.

Every class name was verified by importing the installed SDK rather than taken from documentation, since a catch for a class that does not exist is dead code.

Two deliberate choices:

The broad except Exception stays as a final clause in all eight. Narrowing to provider classes alone would let anything unanticipated escape raw instead of surfacing as a ValueError, which would be a regression rather than a fix. The generic clause now also names the provider and model.

Imports are local to the method, matching how these modules already treat their optional SDK dependencies. The SDK is guaranteed importable at that point because __init__ initialises the client before calling _set_model_dims().

tests/unit/test_vectorizer_dim_errors.py drives OpenAI, Azure OpenAI, Bedrock and HuggingFace through their real __init__ with the network client stubbed, so it exercises the same path a user hits with a bad key. It also pins the generic fallback: an unexpected ZeroDivisionError must still arrive as a ValueError.

Verified 7 failed against unmodified main, 7 passed with the change. Full unit suite: 1299 passed, 11 skipped. isort --profile black and black --target-version py311 report no changes.


Note

Low Risk
Changes are limited to init-time error wrapping in embedding vectorizers; successful embed paths are unchanged and failures still surface as ValueError.

Overview
When vectorizers fail their startup dimension probe, failures are no longer reported as a generic Error setting embedding model dimensions: … message. OpenAI, Azure OpenAI, Cohere, Mistral, Bedrock, Vertex AI, and VoyageAI now raise a ValueError that names the provider and model/deployment, with the underlying SDK or retry error preserved via raise … from e. The separate KeyError/IndexError branch for “unexpected API response” on that path is removed in favor of the single wrapped handler.

Hugging Face gets the largest behavioral change: SentenceTransformer load failures (OSError, RuntimeError such as CUDA OOM) are caught in _initialize_client with guidance on model path/download and device='cpu', and similar handling is added in _set_model_dims when the probe encode step fails.

tests/unit/test_vectorizer_dim_errors.py locks in model names in messages, exception chaining, HF load errors, VoyageAI’s TypeError path from invalid models, and the generic fallback for unexpected errors.

Reviewed by Cursor Bugbot for commit 5a1ebea. Bugbot is set up for automated code reviews on this repo. Configure here.

Each vectorizer probes its provider with a throwaway embedding call to learn
the model dimensionality. On failure every one of the eight raised the same
generic message under a 'TODO get more specific' comment, which told the
caller nothing about which provider rejected them or what to change.

Catch the exception classes each SDK actually raises and report the provider,
the model, and the concrete next step. The broad 'except Exception' stays as a
final clause so an unanticipated error still surfaces as a ValueError rather
than escaping raw.
Comment thread redisvl/utils/vectorize/text/openai.py Outdated
Comment thread redisvl/utils/vectorize/text/huggingface.py
…atch actually fires

Two layers were hiding the real SDK exception from _set_model_dims():

1. _embed()/_embed_many() already catch the SDK's own exception and re-raise
   a generic ValueError, so catching the provider exception type directly in
   _set_model_dims() (as this PR originally did) never triggers -- Cursor
   Bugbot caught this on review.
2. _embed()/_embed_many() are @retry-decorated with
   retry_if_not_exception_type(TypeError), which does not exempt that
   ValueError -- so a permanent failure like bad credentials or an unknown
   model is retried 6 times with exponential backoff before tenacity gives up
   and raises RetryError, wrapping the ValueError, which itself wraps the SDK
   exception.

_set_model_dims() now unwraps RetryError.last_attempt.exception() first, then
unwraps __cause__/__context__ on what's left, before dispatching on the
provider's real exception type. Also fixes HuggingFace separately: a bad
model name raises OSError from SentenceTransformer() in _initialize_client(),
before _set_model_dims() runs at all -- that OSError is now caught where it
actually happens.

Tests now drive the real _embed()/_initialize_client() code for OpenAI,
Bedrock and HuggingFace (patching only the network client / model load, not
_embed itself), with time.sleep patched so retry backoff doesn't stall the
suite. The remaining five providers get a cause-dispatch test built the same
way _embed really builds its wrapper: raised while handling the SDK
exception, so __context__ is set by real Python chaining rather than
fabricated.

11 passed in test_vectorizer_dim_errors.py (16s). Full unit suite:
1303 passed, 11 skipped -- no regressions from the previous 1299.
@Aryan-Pardeshi

Copy link
Copy Markdown
Author

Good catch, and it went deeper than the first fix. _embed()/_embed_many() already wrap the SDK's own exception in a generic ValueError before _set_model_dims() sees it, so catching the provider exception type directly never fired — confirmed.

There was a second layer underneath that too: _embed/_embed_many are @retry-decorated with retry_if_not_exception_type(TypeError), which does not exempt that ValueError. So a permanent failure like bad credentials gets retried 6 times with exponential backoff before tenacity gives up and raises RetryError, wrapping the ValueError, which wraps the real SDK exception.

_set_model_dims() now unwraps RetryError first, then __cause__/__context__, before dispatching. Tests for OpenAI, Bedrock and HuggingFace now drive the real _embed()/_initialize_client() code (patching only the network client, not _embed itself) so they'd have caught this the first time. HuggingFace also got a real fix for the case you flagged separately — a bad model name now raises where it actually happens, in _initialize_client()'s SentenceTransformer() call, not in _set_model_dims().

Full unit suite: 1303 passed, no regressions.

Comment thread redisvl/utils/vectorize/voyageai.py Outdated
…r/RetryError

_embed_many() re-raises voyageai.error.InvalidRequestError as TypeError
specifically so retry_if_not_exception_type(TypeError) skips retrying it --
a bad model id can never succeed regardless of attempt count. That means it
reaches _set_model_dims() as a bare, unwrapped TypeError, never as
ValueError or RetryError, so the unrecognized-model branch never fired.

Caught by Cursor Bugbot on the second review pass. Verified: reverting the
except tuple back to (ValueError, RetryError) makes the new test fail with
the generic fallback message instead of the InvalidRequestError guidance.
@Aryan-Pardeshi

Copy link
Copy Markdown
Author

Good catch again — pushed. _embed_many() re-raises InvalidRequestError as TypeError specifically so the retry decorator skips retrying it (a bad model id can't ever succeed). That meant it reached _set_model_dims() as a bare, unwrapped TypeError, never caught by the (ValueError, RetryError) tuple, so it fell to the generic fallback instead of the unrecognized-model message.

Added TypeError to the caught tuple and a test that drives the real _embed_many() code (only the client's .embed() call is stubbed), which fails against the previous except tuple and passes now. Full suite: 1304 passed.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 098e5e9. Configure here.

Comment thread redisvl/utils/vectorize/text/cohere.py Outdated
Comment thread redisvl/utils/vectorize/text/huggingface.py
@Aryan-Pardeshi

Copy link
Copy Markdown
Author

Both real, pushed a fix for each.

High: right, cohere.UnauthorizedError/NotFoundError only exist in cohere 5.0+, but the package still declares cohere>=4.44 and the embed path still handles 4.x list responses. Those were referenced unconditionally at import/isinstance time, so any 4.x install would crash on construction. Now using getattr(cohere, "UnauthorizedError", None) etc., and the ApiError import is wrapped in try/except ImportError -- falls through to the generic message on 4.x instead of crashing.

Low: fixed the copy-paste wording, HF's OSError handler now says "failed while determining its dimensions" like the RuntimeError one, since it fires after SentenceTransformer already loaded.

Full suite: 1304 passed, 11 skipped, no regressions.

@vishal-bala vishal-bala left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, thanks for contributing to RedisVL! I appreciate the idea you're going for here, but the current implementation adds a lot of bloated logic and comments for limited lift to an end user. If the goal is to expose the underlying specific error more concretely, I think we can do that by simply chaining the exception into the ValueError

except (...) as ke:
    raise ValueError("...") from ke

That should make it sufficiently visible in the traceback and actionable by others catching this exception, while also maintaining the standard of code quality we expect for this project.

@vishal-bala vishal-bala self-assigned this Aug 13, 2026
Per review on redis#680: the RetryError-unwrap-and-dispatch-by-SDK-type logic
added a lot of bloated branching for limited lift. Collapse each
provider's _set_model_dims() to one except Exception clause that wraps
in a ValueError and chains the original exception with `from e`,
which already surfaces the real cause in the traceback without the
manual unwrapping.
@Aryan-Pardeshi

Copy link
Copy Markdown
Author

@vishal-bala thanks — agreed, and I took the whole thing out rather than trimming it.

5a1ebea collapses every vectorizer's _set_model_dims() to one except Exception as e: raise ValueError(...) from e. The provider-specific handlers, the RetryError.last_attempt.exception() unwrapping and the __cause__/__context__ dispatch are all gone; from e already puts the real SDK error in the traceback, which is what the extra code was reimplementing badly. The message still names the provider and the model so the actionable part survives.

Net diff across the 8 files is +80 / -512.

Verified: black --check and isort --check-only clean on the touched files, mypy reports "Success: no issues found in 7 source files". The rewritten tests/unit/test_vectorizer_dim_errors.py covers OpenAI/Bedrock/HuggingFace end to end against real SDK failures plus a parametrized cause-chaining check for the rest — I ran those standalone rather than through pytest, since tests/conftest.py's session-scoped redis_container fixture needs Docker for even pure unit tests.

The Bugbot comments above all predate this rewrite and point at handlers that no longer exist.

vishal-bala added a commit that referenced this pull request Aug 19, 2026
… retry errors (#696)

## Why

The Azure OpenAI resource CI used is no longer available, with no
replacement yet. The six live `AzureOpenAITextVectorizer` tests failed
on every run, and because the redis-py matrix and notebook jobs both
declare `needs: service-tests`, one unavailable provider blocked all 31
downstream jobs — including a matrix that runs `make test` with no API
keys and has zero Azure exposure.

`AzureOpenAITextVectorizer` also had no offline tests at all, unlike its
`ollama` and `google_genai` siblings. Gating the live tests without
adding any would have taken its coverage to zero.

## What changed

| | |
| --- | --- |
| `tests/integration/test_vectorizers.py` | `skipif` gate on the Azure
entry in `_vectorizer_params` and `_dtype_params`.
`_non_supported_dtype_params` is deliberately **not** gated — `dtype` is
validated in `super().__init__` before any client is built, so it passes
with no network. |
| `tests/unit/test_azure_openai_vectorizer.py` | New. 36 tests + 1
SDK-contract test that skips when `openai` is absent. Fake `openai`
module via `types.ModuleType`, following the two existing sibling files.
|
| `docs/user_guide/04_vectorizers.ipynb` | `# NBVAL_SKIP` on the three
Azure cells. Required: without the credentials, `os.environ.get(...) or
getpass.getpass(...)` falls through to `getpass`, which has no stdin
under nbval. The pre-existing `_azure_configured` guard cannot help — it
is computed *after* the prompt. |
| `redisvl/utils/vectorize/text/azureopenai.py` | Two fixes, below. |
| `.github/workflows/test.yml` | Comments only. No structural change;
`needs: service-tests` is untouched. |

**Self-healing:** the gate keys on `AZURE_OPENAI_ENDPOINT`,
`AZURE_OPENAI_API_KEY` and `OPENAI_API_VERSION` all being truthy. An
unset secret expands to `""`, so the tests skip today and re-enable with
no code change once the credentials are available again. The
`secrets.AZURE_OPENAI_*` lines stay wired up for exactly that reason.
The notebook markers are unconditional and *do* have to be removed by
hand.

## Behavior changes

Both are in `azureopenai.py` and both were found while writing the
tests. Worth a look for release notes:

1. **`reraise=True` on the four `@retry` decorators.** Retry exhaustion
now surfaces the underlying `ValueError` instead of
`tenacity.RetryError`. All four docstrings already promised `Raises:
ValueError`, so this fixes a contract violation. Matches
`googlegenai.py` and `ollama.py`; nothing in `redisvl/` catches
`RetryError`.
2. **`_initialize_clients` no longer mutates the caller's
`api_config`**, and a *partial* `api_config` now falls back to the
environment per key as the class docstring promises, instead of raising
a bare `KeyError`. Uses the `dict(api_config or {})` pattern already in
`googlegenai.py`.

## Coverage: honestly

**Lost** — six live IDs stop running in CI: `test_default_dtype[Azure]`,
`test_vectorizer_dtype_assignment[Azure]`, and the four
`vectorizer`-fixture tests. Realistically that goes to zero with or
without this PR, since the resource is unavailable; the gate removes a
false signal rather than coverage. What the mocks cannot catch is
`openai` SDK drift, deployment-vs-model semantics, real authentication,
and true dimensionality.

**Mitigations** — the `embeddings.create` → `response.data[i].embedding`
shape still has *live* coverage through `OpenAITextVectorizer`, which
shares it and is unaffected. And
`test_fake_matches_real_openai_sdk_contract` asserts the fake's shape
against the installed SDK, so a reshape breaks this file too.

**Gained** — several things the live tests never covered: the batching
loop (they used 2 texts at default `batch_size=10`, so it never ran),
sync/async client symmetry, the three sequential credential guards,
`dtype` buffer widths, and that `__init__` fires one billable embed
request.

## Verification

- Gate fires with the env vars unset **and** empty-string (the actual CI
state) → 6 skipped; un-fires when set → tests reach the network, which
is correct, since the gate is credential-presence not reachability.
- New file: 36 passed + 1 skipped in ~2.7s. Speed is the assertion — a
missed `retry.sleep` patch would cost 30s+, and a guard test pins that
exactly four methods are retry-decorated.
- `black`, `isort`, `mypy`, `codespell` clean. Notebook diff touches
only `source` arrays.

## Notes for review

- A proposed message-redaction fix was **reverted** after verifying
against the installed SDK that the API key is not reachable in provider
exception messages — `openai` builds them from `response.status_code`
and the response body, and the Azure key travels only as the `api-key`
header. It would also have made Azure the only one of ~21 sites not
interpolating `{e}`.
- Merge-order note: #680 also touches `azureopenai.py`, three lines
above the first `reraise=True`. Adjacent, not overlapping. A comment
here refers to `tests/unit/test_vectorizer_dim_errors.py` as added by
that PR.
- #692 touches the same two test/notebook files but appends well clear
of these edits.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Behavior changes in AzureOpenAITextVectorizer (api_config copying,
per-key env fallback, retry reraise) affect callers relying on mutation
or RetryError; CI gating is low risk but live Azure coverage drops until
credentials return.
> 
> **Overview**
> Unblocks CI after the Azure OpenAI deployment used in tests went away
by **skipping live Azure integration tests** when
`AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, and
`OPENAI_API_VERSION` are unset, while **adding a large offline unit
suite** with a fake `openai` module. **Notebook validation** no longer
hangs on `getpass` because the three Azure cells in
`04_vectorizers.ipynb` are marked `# NBVAL_SKIP`.
> 
> **`AzureOpenAITextVectorizer`** now copies `api_config` before popping
credentials (no caller mutation), resolves each credential from config
**or** env per key, and sets **`reraise=True`** on embed retries so
failures surface as `ValueError` instead of `tenacity.RetryError`.
Workflow comments document how live tests and notebooks re-enable when
secrets return.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
030769c. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve provider-specific errors in vectorizer _set_model_dims()

2 participants