Skip to content

Commit ac2cf82

Browse files
rustyconoverclaude
andcommitted
feat: gRPC error codes on the SDK's own errors
Classify the SDK's errors with vgi-rpc's error model (error_code class attribute, WIRE_PROTOCOL.md section 8) so a client can tell bad input from a worker bug: - INVALID_ARGUMENT: ArgumentValidationError, MissingAttachOptionsError, TypeMismatchError on an input parameter, and two new classes: ArgumentTypeError (a failed type_bound; subclasses SchemaValidationError) and InvalidArgumentError (no matching overload, ambiguous call, wrong call shape, mismatched at_unit/at_value). - NOT_FOUND: new NotFoundError (ValueError) for unknown functions, catalogs, and tables in Worker, MetaWorker, and CatalogInterface. - FAILED_PRECONDITION: CatalogReadOnlyError. - UNIMPLEMENTED: new UnsupportedOperationError (NotImplementedError) for the CatalogInterface DDL/DML defaults and secret resolution on aggregates. Worker-output schema violations (SchemaValidationError, TypeMismatchError on a return value) stay UNKNOWN. The fixture's numeric-type check raises ArgumentTypeError, so double('abc') and sequence(10, batch_size := 0) both arrive as INVALID_ARGUMENT. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 84fc76f commit ac2cf82

12 files changed

Lines changed: 337 additions & 95 deletions

File tree

‎docs/api/exceptions.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,31 @@ validation errors also live alongside the features they guard (see
55
[Arguments](arguments.md#vgi.arguments.ArgumentValidationError) and
66
[Metadata](metadata.md)).
77

8+
## Error codes
9+
10+
Errors cross the wire with a gRPC-style `error_code` from vgi-rpc's error model
11+
(`vgi_rpc.errors.Code`). An exception class declares its code with an
12+
`error_code` class attribute; one without it is sent as `UNKNOWN`. The SDK codes
13+
its own errors so a client can tell bad input from a worker bug:
14+
15+
| Code | Raised for |
16+
|------|------------|
17+
| `INVALID_ARGUMENT` | `ArgumentValidationError`, `ArgumentTypeError` (failed `type_bound`), `TypeMismatchError` on an input parameter, `InvalidArgumentError` (no matching overload, ambiguous call, wrong call shape), `MissingAttachOptionsError` |
18+
| `NOT_FOUND` | `NotFoundError` (unknown function, catalog, or table) |
19+
| `FAILED_PRECONDITION` | `CatalogReadOnlyError` |
20+
| `UNIMPLEMENTED` | `UnsupportedOperationError` (catalog operations a catalog does not implement) |
21+
| `UNKNOWN` | everything else, including `SchemaValidationError` on a worker's output and `TypeMismatchError` on a return value -- those are worker bugs |
22+
23+
Your own exceptions can declare a code the same way:
24+
25+
```python
26+
from typing import ClassVar
27+
28+
from vgi_rpc.errors import Code
29+
30+
31+
class QuotaExceededError(Exception):
32+
error_code: ClassVar[Code] = Code.RESOURCE_EXHAUSTED
33+
```
34+
835
::: vgi.exceptions

‎docs/catalog-interface.md‎

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -214,37 +214,40 @@ class MyCatalog(CatalogInterface):
214214

215215
| Category | Method | Default Behavior |
216216
|----------|--------|------------------|
217-
| **Catalog** | `catalog_create()` | `NotImplementedError` |
218-
| | `catalog_drop()` | `NotImplementedError` |
217+
| **Catalog** | `catalog_create()` | `UnsupportedOperationError` |
218+
| | `catalog_drop()` | `UnsupportedOperationError` |
219219
| | `catalog_detach()` | No-op |
220220
| | `catalog_version()` | Returns `0` |
221-
| **Transaction** | `catalog_transaction_begin()` | `NotImplementedError` |
222-
| | `catalog_transaction_commit()` | `NotImplementedError` |
223-
| | `catalog_transaction_rollback()` | `NotImplementedError` |
221+
| **Transaction** | `catalog_transaction_begin()` | `UnsupportedOperationError` |
222+
| | `catalog_transaction_commit()` | `UnsupportedOperationError` |
223+
| | `catalog_transaction_rollback()` | `UnsupportedOperationError` |
224224
| **Schema** | `schemas()` | Returns `["main"]` |
225-
| | `schema_create()` | `NotImplementedError` |
226-
| | `schema_drop()` | `NotImplementedError` |
227-
| | `schema_contents()` | `NotImplementedError` |
228-
| **Table** | `table_create()` | `NotImplementedError` |
229-
| | `table_drop()` | `NotImplementedError` |
230-
| | `table_rename()` | `NotImplementedError` |
231-
| | `table_comment_set()` | `NotImplementedError` |
232-
| | `table_column_add()` | `NotImplementedError` |
233-
| | `table_column_drop()` | `NotImplementedError` |
234-
| | `table_column_rename()` | `NotImplementedError` |
235-
| | `table_column_type_change()` | `NotImplementedError` |
236-
| | `table_column_default_set()` | `NotImplementedError` |
237-
| | `table_column_default_drop()` | `NotImplementedError` |
238-
| | `table_not_null_set()` | `NotImplementedError` |
239-
| | `table_not_null_drop()` | `NotImplementedError` |
240-
| | `table_scan_function_get()` | `NotImplementedError` |
241-
| **View** | `view_create()` | `NotImplementedError` |
242-
| | `view_drop()` | `NotImplementedError` |
243-
| | `view_rename()` | `NotImplementedError` |
244-
| | `view_comment_set()` | `NotImplementedError` |
225+
| | `schema_create()` | `UnsupportedOperationError` |
226+
| | `schema_drop()` | `UnsupportedOperationError` |
227+
| | `schema_contents()` | `UnsupportedOperationError` |
228+
| **Table** | `table_create()` | `UnsupportedOperationError` |
229+
| | `table_drop()` | `UnsupportedOperationError` |
230+
| | `table_rename()` | `UnsupportedOperationError` |
231+
| | `table_comment_set()` | `UnsupportedOperationError` |
232+
| | `table_column_add()` | `UnsupportedOperationError` |
233+
| | `table_column_drop()` | `UnsupportedOperationError` |
234+
| | `table_column_rename()` | `UnsupportedOperationError` |
235+
| | `table_column_type_change()` | `UnsupportedOperationError` |
236+
| | `table_column_default_set()` | `UnsupportedOperationError` |
237+
| | `table_column_default_drop()` | `UnsupportedOperationError` |
238+
| | `table_not_null_set()` | `UnsupportedOperationError` |
239+
| | `table_not_null_drop()` | `UnsupportedOperationError` |
240+
| | `table_scan_function_get()` | `UnsupportedOperationError` |
241+
| **View** | `view_create()` | `UnsupportedOperationError` |
242+
| | `view_drop()` | `UnsupportedOperationError` |
243+
| | `view_rename()` | `UnsupportedOperationError` |
244+
| | `view_comment_set()` | `UnsupportedOperationError` |
245245
| **Observability** | `loggable_attach_options()` | Returns `{}` (no options logged — see below) |
246246
| **Bulk load** | `catalog_contents()` | Composes `schemas()` + `schema_contents()` per kind; no etag (see below) |
247247

248+
`UnsupportedOperationError` (from `vgi.exceptions`) subclasses `NotImplementedError` and is
249+
sent to the client with error code `UNIMPLEMENTED`.
250+
248251
---
249252

250253
## Loading the Whole Catalog: `catalog_contents`
@@ -847,7 +850,7 @@ Errors are returned as exceptions that propagate through the VGI protocol:
847850
| Error | When Raised |
848851
|-------|-------------|
849852
| `ValueError` | Invalid arguments, object not found |
850-
| `NotImplementedError` | Method not supported |
853+
| `UnsupportedOperationError` | Method not supported |
851854
| `CatalogReadOnlyError` | DDL on read-only catalog |
852855

853856
Example error handling:

‎tests/catalog/test_writable_table.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
TableInfo,
1515
WriteFunctionResult,
1616
)
17-
from vgi.exceptions import CatalogReadOnlyError
17+
from vgi.exceptions import CatalogReadOnlyError, NotFoundError
1818
from vgi.invocation import BindResponse
1919
from vgi.table_function import BindParams, TableFunctionGenerator
2020
from vgi.table_in_out_function import TableInOutGenerator
@@ -307,8 +307,8 @@ def test_readonly_table_raises_on_delete(self, catalog_with_writable_table: Read
307307
)
308308

309309
def test_nonexistent_table_raises(self, catalog_with_writable_table: ReadOnlyCatalogInterface) -> None:
310-
"""Non-existent tables raise NotImplementedError."""
311-
with pytest.raises(NotImplementedError, match="not found"):
310+
"""Non-existent tables raise NotFoundError (NOT_FOUND)."""
311+
with pytest.raises(NotFoundError, match="not found"):
312312
catalog_with_writable_table.table_insert_function_get(
313313
attach_opaque_data=AttachOpaqueData(b"test"),
314314
transaction_opaque_data=None,

‎tests/test_error_codes.py‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Copyright 2025, 2026 Query Farm LLC - https://query.farm
2+
3+
"""The SDK's own errors carry vgi-rpc error codes (WIRE_PROTOCOL.md §8).
4+
5+
Bad input is ``INVALID_ARGUMENT``, a missing object is ``NOT_FOUND``, a write
6+
against a read-only catalog is ``FAILED_PRECONDITION``, an unsupported operation
7+
is ``UNIMPLEMENTED``. A worker's own bug -- its output violating its declared
8+
schema -- stays unclassified (``UNKNOWN``) so it is never mistaken for bad input.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from collections.abc import Iterator
14+
15+
import pyarrow as pa
16+
import pytest
17+
from vgi_rpc.errors import Code, error_code_of
18+
from vgi_rpc.rpc import RpcError
19+
20+
from tests.conftest import FIXTURE_WORKER
21+
from vgi.arguments import Arg, Arguments, ArgumentValidationError
22+
from vgi.catalog.attach_option import MissingAttachOptionsError
23+
from vgi.client.client import Client, ClientError
24+
from vgi.exceptions import (
25+
ArgumentTypeError,
26+
CatalogReadOnlyError,
27+
InvalidArgumentError,
28+
NotFoundError,
29+
SchemaValidationError,
30+
UnsupportedOperationError,
31+
)
32+
from vgi.scalar_function import TypeMismatchError
33+
34+
35+
def _is_numeric(t: pa.DataType) -> bool:
36+
return pa.types.is_integer(t) or pa.types.is_floating(t)
37+
38+
39+
class TestDeclaredCodes:
40+
"""Each SDK error class declares the code the cross-SDK mapping assigns it."""
41+
42+
@pytest.mark.parametrize(
43+
("exc", "code", "base"),
44+
[
45+
(ArgumentValidationError("bad", constraint="must be >= 1"), Code.INVALID_ARGUMENT, ValueError),
46+
(ArgumentTypeError("bad type"), Code.INVALID_ARGUMENT, SchemaValidationError),
47+
(InvalidArgumentError("no overload"), Code.INVALID_ARGUMENT, ValueError),
48+
(MissingAttachOptionsError("cat", ["token"]), Code.INVALID_ARGUMENT, ValueError),
49+
(NotFoundError("Unknown function: 'x'"), Code.NOT_FOUND, ValueError),
50+
(CatalogReadOnlyError("read-only"), Code.FAILED_PRECONDITION, Exception),
51+
(UnsupportedOperationError("Table create not implemented."), Code.UNIMPLEMENTED, NotImplementedError),
52+
],
53+
)
54+
def test_code_and_hierarchy(self, exc: BaseException, code: Code, base: type[BaseException]) -> None:
55+
"""The code is declared and the class still satisfies existing handlers."""
56+
assert error_code_of(exc) == code
57+
assert isinstance(exc, base)
58+
59+
def test_worker_output_schema_violation_stays_unknown(self) -> None:
60+
"""A worker's output not matching its schema is a bug, not bad input."""
61+
assert error_code_of(SchemaValidationError("output mismatch")) == Code.UNKNOWN
62+
63+
def test_type_mismatch_on_input_is_invalid_argument(self) -> None:
64+
"""An input parameter of the wrong type is the caller's error."""
65+
exc = TypeMismatchError("x", param_name="x", expected_type=pa.int64(), actual_type=pa.string())
66+
assert error_code_of(exc) == Code.INVALID_ARGUMENT
67+
assert isinstance(exc, TypeError)
68+
69+
def test_type_mismatch_on_return_stays_unknown(self) -> None:
70+
"""A wrong return type is the worker's bug."""
71+
exc = TypeMismatchError("out", param_name="return", expected_type=pa.int64(), actual_type=pa.string())
72+
assert error_code_of(exc) == Code.UNKNOWN
73+
74+
def test_type_bound_failure_is_invalid_argument(self) -> None:
75+
"""A failed ``type_bound`` raises the coded subclass of SchemaValidationError."""
76+
arg: Arg[object] = Arg(0, type_bound=_is_numeric)
77+
with pytest.raises(SchemaValidationError) as excinfo:
78+
arg.validate_type_bound(pa.string())
79+
assert isinstance(excinfo.value, ArgumentTypeError)
80+
assert error_code_of(excinfo.value) == Code.INVALID_ARGUMENT
81+
82+
83+
def _rpc_error(exc: BaseException) -> RpcError:
84+
cause = exc.__cause__
85+
assert isinstance(cause, RpcError), f"expected an RpcError cause, got {cause!r}"
86+
return cause
87+
88+
89+
@pytest.fixture(scope="module")
90+
def client() -> Iterator[Client]:
91+
"""A client on the fixture worker."""
92+
with Client(FIXTURE_WORKER) as c:
93+
yield c
94+
95+
96+
class TestOverTheWire:
97+
"""The fixture worker's rejections arrive with their code."""
98+
99+
def test_scalar_type_rejection(self, client: Client) -> None:
100+
"""``double('abc')``: a string fails the numeric type bound."""
101+
batch = pa.record_batch({"x": pa.array(["abc"])})
102+
with pytest.raises(ClientError) as excinfo:
103+
list(client.scalar_function(function_name="double", schema_path=["main"], input=iter([batch])))
104+
assert _rpc_error(excinfo.value).error_code == "INVALID_ARGUMENT"
105+
106+
def test_table_argument_constraint(self, client: Client) -> None:
107+
"""``sequence(10, batch_size := 0)``: violates ``batch_size >= 1``."""
108+
with pytest.raises(ClientError) as excinfo:
109+
list(
110+
client.table_function(
111+
function_name="sequence",
112+
schema_path=["main"],
113+
arguments=Arguments(positional=(pa.scalar(10),), named={"batch_size": pa.scalar(0)}),
114+
)
115+
)
116+
err = _rpc_error(excinfo.value)
117+
assert "must be >= 1" in str(err)
118+
assert err.error_code == "INVALID_ARGUMENT"
119+
120+
def test_unknown_function(self, client: Client) -> None:
121+
"""An unknown function name is NOT_FOUND."""
122+
with pytest.raises(ClientError) as excinfo:
123+
list(
124+
client.table_function(
125+
function_name="nonexistent_function",
126+
schema_path=["main"],
127+
arguments=Arguments(positional=()),
128+
)
129+
)
130+
assert _rpc_error(excinfo.value).error_code == "NOT_FOUND"

‎vgi/_test_fixtures/scalar/_common.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import pyarrow as pa
88

9-
from vgi.exceptions import SchemaValidationError
9+
from vgi.exceptions import ArgumentTypeError
1010

1111

1212
def _is_addable_type(dtype: pa.DataType) -> bool:
@@ -66,4 +66,4 @@ def _promote_for_addition(dtype: pa.DataType) -> pa.DataType:
6666
# accepts that values >= 5e37 will overflow at compute time.
6767
new_precision = min(dtype.precision + 1, 38)
6868
return pa.decimal128(new_precision, dtype.scale)
69-
raise SchemaValidationError(f"Unsupported numeric type for addition: {dtype}")
69+
raise ArgumentTypeError(f"Unsupported numeric type for addition: {dtype}")

‎vgi/arguments.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@
1919
import warnings
2020
from collections.abc import Callable, Mapping, Sequence
2121
from dataclasses import dataclass
22-
from typing import TYPE_CHECKING, Any, Final, TypeVar, overload
22+
from typing import TYPE_CHECKING, Any, ClassVar, Final, TypeVar, overload
2323

2424
import pyarrow as pa
2525
from vgi_rpc import ArrowSerializableDataclass
26+
from vgi_rpc.errors import Code
2627

2728
if TYPE_CHECKING:
2829
from pyarrow import Scalar
@@ -638,6 +639,7 @@ class ArgumentValidationError(ValueError):
638639
valid_range: Human-readable description of valid values.
639640
default: Default value (if any) that could be used instead.
640641
choices: Valid choices, if the argument is constrained to a set.
642+
error_code: ``INVALID_ARGUMENT`` -- the caller's input is wrong, not the worker.
641643
642644
"""
643645

@@ -649,6 +651,7 @@ class ArgumentValidationError(ValueError):
649651
valid_range: str | None
650652
default: Any
651653
choices: Sequence[Any] | None
654+
error_code: ClassVar[Code] = Code.INVALID_ARGUMENT
652655

653656
def __init__(
654657
self,
@@ -1429,10 +1432,12 @@ def validate_type_bound(self, field_type: pa.DataType) -> None:
14291432
field_type: The Arrow type of the column to validate.
14301433
14311434
Raises:
1432-
[`SchemaValidationError`][]: If the type bound is not satisfied.
1435+
[`ArgumentTypeError`][vgi.exceptions.ArgumentTypeError]: If the type bound is
1436+
not satisfied (a [`SchemaValidationError`][vgi.exceptions.SchemaValidationError]
1437+
coded ``INVALID_ARGUMENT``).
14331438
14341439
"""
1435-
from vgi.exceptions import SchemaValidationError
1440+
from vgi.exceptions import ArgumentTypeError
14361441

14371442
if self.type_bound is None:
14381443
return
@@ -1446,7 +1451,7 @@ def validate_type_bound(self, field_type: pa.DataType) -> None:
14461451
# OR logic: at least one predicate must pass
14471452
if not any(predicate(field_type) for predicate in predicates):
14481453
predicate_names = [getattr(p, "__name__", str(p)) for p in predicates]
1449-
raise SchemaValidationError(
1454+
raise ArgumentTypeError(
14501455
self.format_error(f"column type {field_type} does not match any of: {', '.join(predicate_names)}")
14511456
)
14521457

‎vgi/catalog/attach_option.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from typing import Any, ClassVar
2424

2525
import pyarrow as pa
26+
from vgi_rpc.errors import Code
2627

2728
from vgi.catalog._descriptor_spec import _DescriptorBase, _extract_specs, _SpecBase
2829

@@ -156,10 +157,12 @@ class MissingAttachOptionsError(ValueError):
156157
157158
Attributes:
158159
missing: Names of the required options that were not supplied.
160+
error_code: ``INVALID_ARGUMENT``.
159161
160162
"""
161163

162164
missing: list[str]
165+
error_code: ClassVar[Code] = Code.INVALID_ARGUMENT
163166

164167
def __init__(self, catalog_name: str, missing: list[str]) -> None:
165168
"""Build the error from the catalog name and the missing option names."""

0 commit comments

Comments
 (0)