feat(connectors): connectors without named checks get the design's generic row

The fallback check of a source without named checks is the design's
generic row:
- Its name is "Test connection". A pass gives "Connected".
- A failure of the legacy settings probe gets a line and a description
  for its kind: rejected sign-in, denied permission, not found, settings
  not accepted, rate limited, unreachable, or unexpected.
- An untyped error gets its class from its HTTP status (401, 403, 404,
  429), as the named checks map them. A connection error or a timeout is
  unreachable, so a form run fails it.
- The blocking-validation recorder writes the same row.

A source whose connector keeps the no-op validate_connector_settings
gets no INDEXING check, and the recorder writes no row for its pass.
Its plan and runs hold no check, so the form shows no card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFMazQbuG8ui3jxBxm47sC
This commit is contained in:
Claude committed 2026-10-17 03:12:11 +00:00
1 parent 63a9ba2e4d
commit 0a2d9b9f79
9 files changed
+490 -40

No files matched your search

+4 -1
View File
@@ -458,7 +458,10 @@ decides which capabilities apply to a source on this build (CE: `INDEXING`
only; EE adds the perm-sync ones). `capability_checks/registry.py` maps a
source to named `CapabilityCheck`s (only Slack, Confluence, OneDrive, Outlook, Jira, Linear, HubSpot, Google Drive, Notion, SharePoint, and GitHub
register real ones today; every other source gets a synthesized fallback wrapping
`validate_connector_settings`). A source with named checks must have a
`validate_connector_settings`, the generic "Test connection" row of
`capability_checks/generic_row.py`. A source whose connector keeps the no-op
`validate_connector_settings` (`factory.py:source_has_settings_probe`) gets no
INDEXING check, so its plan and runs hold no row). A source with named checks must have a
`SourceOperations` gateway; `get_capability_checks` asserts this. `capability_checks/runner.py:generate_capability_report`
instantiates the connector in isolation with a timeout guard, builds the
source's `SourceOperations` gateway if one is registered, and runs checks
@@ -0,0 +1,154 @@
"""The generic row: what a source without named checks shows for its legacy
``validate_connector_settings`` probe. The fallback check and the
blocking-validation recorder both build it here, so a draft run and a stored
report show the same row.
Import-light, like the recorder: no registry, runner or factory imports.
"""
import requests
from pydantic import BaseModel
from onyx.connectors.exceptions import (
ConnectorValidationError,
CredentialExpiredError,
CredentialInvalidError,
InsufficientPermissionsError,
RateLimitedValidationError,
SourceApiError,
UnexpectedValidationError,
UnreachableValidationError,
ValidationError,
)
GENERIC_CHECK_NAME = "Test connection"
GENERIC_PASS_LINE = "Connected"
_UNAUTHORIZED = 401
_FORBIDDEN = 403
_NOT_FOUND = 404
_TOO_MANY_REQUESTS = 429
# No answer from the source: the connection failed or timed out.
_UNREACHABLE_ERRORS: tuple[type[Exception], ...] = (
requests.ConnectionError,
requests.Timeout,
ConnectionError,
TimeoutError,
)
class _RowText(BaseModel):
summary: str
description: str
_REJECTED_ROW = _RowText(
summary="Sign-in rejected",
description=(
"The source rejected the account's credential.\n"
"Update the credential in the account, then re-run."
),
)
_DENIED_ROW = _RowText(
summary="No permission for the connector",
description=(
"The credential signs in, but lacks a permission the connector needs.\n"
"Give the account that permission, then re-run."
),
)
_NOT_FOUND_ROW = _RowText(
summary="Not found at the source",
description=(
"The source found nothing at the configured address or ID.\n"
"Correct the connector settings, then re-run."
),
)
_SETTINGS_ROW = _RowText(
summary="Settings not accepted",
description=(
"The source did not accept the connector settings.\n"
"Read the error details, then correct the settings."
),
)
_RATE_LIMITED_ROW = _RowText(
summary="Rate limited by the source",
description=(
"The source limited the number of requests.\nWait a minute, then re-run."
),
)
_UNREACHABLE_ROW = _RowText(
summary="Cannot reach the source",
description=(
"Onyx could not connect to the source.\n"
"Check the address in the settings, then re-run."
),
)
_UNEXPECTED_ROW = _RowText(
summary="Unexpected response from the source",
description=(
"The source answered in a way the check does not know.\n"
"Re-run the checks. If it repeats, share the error details with support."
),
)
def _http_status(error: BaseException) -> int | None:
"""The HTTP status of the first error in the chain that carries one."""
seen: set[int] = set()
current: BaseException | None = error
while current is not None and id(current) not in seen:
if isinstance(current, SourceApiError):
return current.status_code
if isinstance(current, requests.HTTPError) and current.response is not None:
return current.response.status_code
seen.add(id(current))
current = current.__cause__ or current.__context__
return None
def _with_status(row: _RowText, status: int | None) -> _RowText:
"""Adds the HTTP status to the row's first description line."""
if status is None:
return row
first, newline, rest = row.description.partition("\n")
return _RowText(
summary=row.summary, description=f"{first} (HTTP {status}){newline}{rest}"
)
def _typed(
error: Exception, status: int | None
) -> tuple[type[ValidationError], _RowText]:
"""The error's class and row. A typed error keeps its class; an untyped
one gets its class from its HTTP status, as the named checks map them."""
if isinstance(error, RateLimitedValidationError) or status == _TOO_MANY_REQUESTS:
return RateLimitedValidationError, _RATE_LIMITED_ROW
if isinstance(error, (UnreachableValidationError, *_UNREACHABLE_ERRORS)):
return UnreachableValidationError, _UNREACHABLE_ROW
if isinstance(error, CredentialExpiredError):
return CredentialExpiredError, _REJECTED_ROW
if isinstance(error, CredentialInvalidError):
return CredentialInvalidError, _REJECTED_ROW
if isinstance(error, InsufficientPermissionsError):
return InsufficientPermissionsError, _DENIED_ROW
if status == _UNAUTHORIZED:
return CredentialInvalidError, _REJECTED_ROW
if status == _FORBIDDEN:
return InsufficientPermissionsError, _DENIED_ROW
if status == _NOT_FOUND:
return ConnectorValidationError, _NOT_FOUND_ROW
if isinstance(error, ConnectorValidationError):
return ConnectorValidationError, _SETTINGS_ROW
return UnexpectedValidationError, _UNEXPECTED_ROW
def generic_row_error(error: Exception) -> ValidationError:
"""The validation error the generic row shows for a legacy probe's
error, with its line and description. ``message`` keeps the probe's own
text for Error Details. An error that already gives its row is kept."""
if isinstance(error, ValidationError) and error.summary is not None:
return error
status = _http_status(error)
error_class, row = _typed(error, status)
row = _with_status(row, status)
return error_class(str(error), summary=row.summary, description=row.description)
@@ -19,6 +19,11 @@ from onyx.configs.constants import DocumentSource
from onyx.connectors.capability_checks.applicability import (
get_applicable_capabilities,
)
from onyx.connectors.capability_checks.generic_row import (
GENERIC_CHECK_NAME,
GENERIC_PASS_LINE,
generic_row_error,
)
from onyx.connectors.capability_checks.models import (
CapabilityCheckResult,
CapabilityCheckStatus,
@@ -58,28 +63,39 @@ def _synthesize_results(
) -> list[CapabilityCheckResult]:
"""Builds fallback-shaped results mirroring what the blocking path ran.
The INDEXING row is the generic row, as the fallback check gives it. A
passing source with no settings probe gets none: it tested nothing.
Perm-sync rows appear only when the caller knows ``validate_perm_sync`` ran
(the success path with sync access). On failure the outcome cannot be
attributed between the settings and perm-sync probes, so only the INDEXING
row carries it.
"""
status = _outcome_status(error)
# Inline import: the factory imports this module.
from onyx.connectors.factory import source_has_settings_probe
typed = generic_row_error(error) if error is not None else None
status = _outcome_status(typed)
message = str(error) if error is not None else ""
# The original class, for support: ``typed`` wraps it.
error_type = type(error).__name__ if error is not None else None
error_kind = check_error_kind(error) if error is not None else None
results = [
CapabilityCheckResult(
capability=CredentialCapability.INDEXING,
check_id=f"{source.value}_connector_settings",
display_name="Connector settings validation",
required=True,
status=status,
message=message,
error_type=error_type,
error_kind=error_kind,
is_fallback=True,
error_kind = check_error_kind(typed) if typed is not None else None
results: list[CapabilityCheckResult] = []
if typed is not None or source_has_settings_probe(source):
results.append(
CapabilityCheckResult(
capability=CredentialCapability.INDEXING,
check_id=f"{source.value}_connector_settings",
display_name=GENERIC_CHECK_NAME,
required=True,
status=status,
message=message,
error_type=error_type,
error_kind=error_kind,
summary=typed.summary if typed is not None else GENERIC_PASS_LINE,
description=typed.description if typed is not None else None,
is_fallback=True,
)
)
]
if perm_sync_validated:
results.extend(
CapabilityCheckResult(
@@ -118,6 +134,8 @@ def record_blocking_validation_outcome(
try:
applicable = get_applicable_capabilities(source)
results = _synthesize_results(source, applicable, error, perm_sync_validated)
if not results:
return
report = CredentialCapabilityReport(
credential_id=credential_id,
source=source,
@@ -6,14 +6,21 @@ from onyx.configs.constants import DocumentSource
from onyx.connectors.capability_checks.applicability import (
get_applicable_capabilities as get_applicable_capabilities,
)
from onyx.connectors.capability_checks.generic_row import (
GENERIC_CHECK_NAME,
GENERIC_PASS_LINE,
generic_row_error,
)
from onyx.connectors.capability_checks.models import (
CapabilityCheck,
CapabilityCheckContext,
CheckOutput,
CredentialCapability,
)
from onyx.connectors.confluence.capability_checks import (
build_confluence_indexing_checks,
)
from onyx.connectors.factory import source_has_settings_probe
from onyx.connectors.github.capability_checks import build_github_indexing_checks
from onyx.connectors.google_drive.capability_checks import (
build_google_drive_indexing_checks,
@@ -50,26 +57,33 @@ _INDEXING_CHECKS_BY_SOURCE: dict[DocumentSource, list[CapabilityCheck]] = {
class _ConnectorSettingsFallbackCheck(CapabilityCheck):
"""Baseline INDEXING check where the source registers no named checks.
"""The generic row, where the source registers no named INDEXING checks.
Wraps the legacy ``validate_connector_settings`` blob so every connector has
day-one coverage until a per-connector session registers named,
per-permission checks that shadow this.
Wraps the legacy ``validate_connector_settings`` probe until a
per-connector session registers named checks that shadow this. Like a
sign-in, it fails on a form run when the source does not answer.
"""
def __init__(self, source: DocumentSource) -> None:
super().__init__(
capability=CredentialCapability.INDEXING,
check_id=f"{source.value}_connector_settings",
display_name="Connector settings validation",
display_name=GENERIC_CHECK_NAME,
is_fallback=True,
fails_when_unreachable=True,
)
def run(self, context: CapabilityCheckContext) -> None:
assert context.connector is not None, (
"The runner guarantees an instance of a connector."
)
context.connector.validate_connector_settings()
def run(self, context: CapabilityCheckContext) -> CheckOutput:
if context.connector is None:
raise TypeError("Bug: The runner guarantees an instance of a connector.")
try:
context.connector.validate_connector_settings()
except Exception as e:
typed = generic_row_error(e)
if typed is e:
raise
raise typed from e
return CheckOutput(summary=GENERIC_PASS_LINE)
def get_capability_checks(source: DocumentSource) -> list[CapabilityCheck]:
@@ -81,6 +95,9 @@ def get_capability_checks(source: DocumentSource) -> list[CapabilityCheck]:
implementation and are empty on OSS builds, where the perm-sync feature does
not exist.
A source whose connector has no settings probe gets no INDEXING check:
a check that reaches nothing must not pass.
Ratchet: named checks require a registered source-operations gateway --
participation in the checks system is an anti-drift guarantee. Unmigrated
sources keep the fallback path.
@@ -90,7 +107,7 @@ def get_capability_checks(source: DocumentSource) -> list[CapabilityCheck]:
f"{source.value} registers named INDEXING checks but no "
"source-operations gateway; migrate the connector first."
)
if not checks:
if not checks and source_has_settings_probe(source):
checks.append(_ConnectorSettingsFallbackCheck(source))
get_perm_sync_checks = fetch_ee_implementation_or_noop(
"onyx.connectors.capability_checks",
+12
View File
@@ -133,6 +133,18 @@ def source_supports_windowed_runs(source: DocumentSource) -> bool:
return issubclass(connector_class, (CheckpointedConnector, PollConnector))
def source_has_settings_probe(source: DocumentSource) -> bool:
"""True if the source's connector overrides ``validate_connector_settings``.
The default is a no-op that reaches nothing, so it tests nothing."""
if source not in CONNECTOR_CLASS_MAP:
return False
connector_class = _load_connector_class(source)
return (
connector_class.validate_connector_settings
is not BaseConnector.validate_connector_settings
)
def source_prune_honors_indexing_start(source: DocumentSource) -> bool:
"""True if a prune of the source removes documents older than the
indexing start. A source without a connector class prunes nothing."""
@@ -19,10 +19,7 @@ from onyx.configs.constants import (
OnyxCeleryTask,
)
from onyx.connectors.capabilities import CredentialCapability
from onyx.connectors.capability_checks.models import (
CapabilityCheckStatus,
CapabilityVerdict,
)
from onyx.connectors.capability_checks.models import CapabilityVerdict
from onyx.connectors.models import InputType
from onyx.db.credential_capability import mark_capability_report_running
from onyx.db.engine.sql_engine import get_session_with_current_tenant
@@ -103,13 +100,8 @@ def test_credential_scoped_run_completes_and_persists_a_report(
report = completed["report"]
assert report["connector_id"] is None
assert report["trigger"] == CapabilityCheckTrigger.MANUAL.value
assert report["check_results"]
# Config-less: the mock connector cannot be constructed, so every
# instance-requiring check skips rather than probing anything.
assert all(
result["status"] == CapabilityCheckStatus.SKIPPED.value
for result in report["check_results"]
)
# The mock connector has no settings probe, so the run holds no check.
assert report["check_results"] == []
indexing_verdict = report["verdicts"][CredentialCapability.INDEXING.value]
assert indexing_verdict == CapabilityVerdict.SKIPPED.value
@@ -11,6 +11,7 @@ from onyx.connectors.capability_checks.models import (
CredentialCapability,
)
from onyx.connectors.capability_checks.registry import get_capability_checks
from onyx.connectors.factory import source_has_settings_probe
from onyx.connectors.source_operations import SourceOperations
from tests.unit.onyx.connectors.source_operation_harnesses import (
import_all_source_operation_gateways,
@@ -141,7 +142,14 @@ def test_every_source_resolves_checks_without_tripping_the_ratchet() -> None:
# Under test and postcondition.
for source in DocumentSource:
assert get_capability_checks(source)
checks = get_capability_checks(source)
has_indexing_check = any(
check.capability == CredentialCapability.INDEXING for check in checks
)
assert has_indexing_check == (
source in registry._INDEXING_CHECKS_BY_SOURCE
or source_has_settings_probe(source)
)
@pytest.mark.usefixtures("enable_ee")
@@ -152,4 +160,11 @@ def test_every_source_resolves_checks_with_ee_enabled() -> None:
# Under test and postcondition.
for source in DocumentSource:
assert get_capability_checks(source)
checks = get_capability_checks(source)
has_indexing_check = any(
check.capability == CredentialCapability.INDEXING for check in checks
)
assert has_indexing_check == (
source in registry._INDEXING_CHECKS_BY_SOURCE
or source_has_settings_probe(source)
)
@@ -0,0 +1,227 @@
"""The generic row of a source without named checks: "Test connection",
"Connected" on a pass, and a typed line and description for each failure of
the legacy settings probe, in a draft run and in a recorded outcome alike."""
from unittest.mock import MagicMock
import pytest
import requests
from onyx.configs.constants import DocumentSource
from onyx.connectors.capability_checks.models import (
CapabilityCheckContext,
CapabilityCheckResult,
CapabilityCheckStatus,
CheckErrorKind,
CredentialCapability,
)
from onyx.connectors.capability_checks.recorder import _synthesize_results
from onyx.connectors.capability_checks.registry import get_capability_checks
from onyx.connectors.capability_checks.runner import run_capability_checks
from onyx.connectors.exceptions import (
ConnectorValidationError,
CredentialExpiredError,
InsufficientPermissionsError,
RateLimitedValidationError,
SourceApiError,
UnexpectedValidationError,
)
from onyx.connectors.interfaces import BaseConnector
_SOURCE = DocumentSource.BOX
def _http_error(status: int) -> requests.HTTPError:
response = requests.Response()
response.status_code = status
response._content = b'{"message": "denied"}'
response.url = "https://api.example.com/2.0/folders/0?token=secret"
return requests.HTTPError(f"{status} error", response=response)
def _run(error: Exception | None, form_run: bool = False) -> CapabilityCheckResult:
connector = MagicMock(spec=BaseConnector)
connector.validate_connector_settings.side_effect = error
checks = [
check
for check in get_capability_checks(_SOURCE)
if check.capability == CredentialCapability.INDEXING
]
(result,) = run_capability_checks(
checks,
CapabilityCheckContext(source=_SOURCE, credential_json={}, connector=connector),
form_run=form_run,
)
return result
def test_pass_shows_connected() -> None:
# Under test.
result = _run(None)
# Postcondition.
assert result.display_name == "Test connection"
assert result.status == CapabilityCheckStatus.PASSED
assert result.summary == "Connected"
@pytest.mark.parametrize(
("error", "status", "kind", "error_type", "summary"),
[
(
_http_error(401),
CapabilityCheckStatus.FAILED,
CheckErrorKind.REJECTED,
"CredentialInvalidError",
"Sign-in rejected",
),
(
CredentialExpiredError("token expired"),
CapabilityCheckStatus.FAILED,
CheckErrorKind.REJECTED,
"CredentialExpiredError",
"Sign-in rejected",
),
(
_http_error(403),
CapabilityCheckStatus.FAILED,
CheckErrorKind.PERMISSION,
"InsufficientPermissionsError",
"No permission for the connector",
),
(
InsufficientPermissionsError("missing scope"),
CapabilityCheckStatus.FAILED,
CheckErrorKind.PERMISSION,
"InsufficientPermissionsError",
"No permission for the connector",
),
(
SourceApiError("no folder", status_code=404),
CapabilityCheckStatus.FAILED,
CheckErrorKind.INVALID,
"ConnectorValidationError",
"Not found at the source",
),
(
ConnectorValidationError("Folder ID must be numeric"),
CapabilityCheckStatus.FAILED,
CheckErrorKind.INVALID,
"ConnectorValidationError",
"Settings not accepted",
),
(
_http_error(429),
CapabilityCheckStatus.INCOMPLETE,
CheckErrorKind.RATE_LIMITED,
"RateLimitedValidationError",
"Rate limited by the source",
),
(
requests.ConnectionError("connection refused"),
CapabilityCheckStatus.INCOMPLETE,
CheckErrorKind.UNREACHABLE,
"UnreachableValidationError",
"Cannot reach the source",
),
(
RuntimeError("boom"),
CapabilityCheckStatus.INCOMPLETE,
CheckErrorKind.UNEXPECTED,
"UnexpectedValidationError",
"Unexpected response from the source",
),
],
)
def test_failure_gets_its_typed_row(
error: Exception,
status: CapabilityCheckStatus,
kind: CheckErrorKind,
error_type: str,
summary: str,
) -> None:
# Under test.
result = _run(error)
# Postcondition.
assert result.status == status
assert result.error_kind == kind
assert result.error_type == error_type
assert result.summary == summary
assert result.description is not None
assert len(result.description.splitlines()) <= 3
# The probe's own text stays the message, for Error Details.
assert result.message == str(error)
def test_description_names_the_http_status() -> None:
# Under test.
result = _run(_http_error(401))
# Postcondition.
assert result.description is not None
assert result.description.splitlines()[0].endswith("(HTTP 401)")
assert result.error_detail is not None
assert result.error_detail.status_code == 401
assert result.error_detail.request == "api.example.com/2.0/folders/0"
def test_error_with_its_own_row_keeps_it() -> None:
# Precondition.
error = UnexpectedValidationError(
"Box API is down", summary="Box is down", description="Try later."
)
# Under test.
result = _run(error)
# Postcondition.
assert result.summary == "Box is down"
assert result.description == "Try later."
def test_unreachable_source_fails_on_a_form_run_only() -> None:
# Under test and postcondition.
error = requests.ConnectionError("connection refused")
assert _run(error, form_run=True).status == CapabilityCheckStatus.FAILED
assert _run(error).status == CapabilityCheckStatus.INCOMPLETE
def test_rate_limit_from_a_typed_error() -> None:
# Under test.
result = _run(RateLimitedValidationError())
# Postcondition.
assert result.error_kind == CheckErrorKind.RATE_LIMITED
assert result.summary == "Rate limited by the source"
def test_recorded_outcome_is_the_generic_row() -> None:
# Under test.
passed = _synthesize_results(_SOURCE, {CredentialCapability.INDEXING}, None, False)
failed = _synthesize_results(
_SOURCE, {CredentialCapability.INDEXING}, _http_error(403), False
)
# Postcondition.
(passed_row,) = passed
assert passed_row.display_name == "Test connection"
assert passed_row.summary == "Connected"
(failed_row,) = failed
assert failed_row.status == CapabilityCheckStatus.FAILED
assert failed_row.error_kind == CheckErrorKind.PERMISSION
assert failed_row.summary == "No permission for the connector"
# The original class, for support.
assert failed_row.error_type == "HTTPError"
def test_recorded_pass_without_a_settings_probe_has_no_row() -> None:
"""A source whose connector keeps the no-op probe tested nothing, so its
pass records no row; a failure, e.g. at construction, still does."""
# Under test and postcondition.
indexing = {CredentialCapability.INDEXING}
assert _synthesize_results(DocumentSource.GITLAB, indexing, None, False) == []
(row,) = _synthesize_results(
DocumentSource.GITLAB, indexing, RuntimeError("bad config"), False
)
assert row.status == CapabilityCheckStatus.INCOMPLETE
@@ -40,11 +40,21 @@ def test_unregistered_source_gets_connector_settings_fallback() -> None:
assert len(indexing_checks) == 1
fallback = indexing_checks[0]
assert fallback.check_id == "box_connector_settings"
assert fallback.display_name == "Test connection"
assert fallback.is_fallback is True
assert fallback.fails_when_unreachable is True
assert fallback.required is True
assert fallback.requires_connector_instance is True
def test_source_without_a_settings_probe_gets_no_check() -> None:
"""Verifies a source whose connector keeps the no-op
``validate_connector_settings`` has no INDEXING check: it would pass
without testing anything."""
# Under test and postcondition.
assert get_capability_checks(DocumentSource.GITLAB) == []
def test_registered_source_gets_no_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
"""Verifies that registered named checks clobber the INDEXING fallback."""
# Precondition.
@@ -91,10 +101,12 @@ def test_fallback_check_calls_validate_connector_settings() -> None:
check = get_capability_checks(DocumentSource.BOX)[0]
# Under test.
check.run(context)
output = check.run(context)
# Postcondition.
connector.validate_connector_settings.assert_called_once_with()
assert output is not None
assert output.summary == "Connected"
def test_oss_build_only_has_indexing_capability() -> None: