fix(python): surface typed remote resource errors (#4789)

## Summary

- Parse structured `error_code` and `resource` fields from remote HTTP
error bodies and carry them through Rust/PyO3.
- Add `SecretNotFoundError`, `FunctionNotFoundError`,
`NamespaceNotFoundError`, and `AlreadyExistsError` as `HttpError`
subclasses.
- Expose `error_code`, `resource_kind`, `resource_name`, and
`namespace_path` on HTTP exceptions while preserving `status_code`,
`request_id`, and existing `except HttpError` handlers.
- Do not retry structured domain errors that would otherwise become
`RetryError` (notably `already_exists` on 409).

Older servers that do not provide the structured fields continue to
raise a plain `HttpError`.

## Compatibility

This consumes the stable resource error contract added in
lancedb/sophon#9516 (merge commit `35858561b3`). The existing
`TableNotFoundError` behavior is unchanged.

Part of ENT-3373.
This commit is contained in:
Xuanwo
2026-11-02 05:11:38 +08:00
committed by GitHub
parent eeb244fa57
commit 9786f2b63b
7 changed files with 574 additions and 18 deletions
+85 -1
View File
@@ -46,9 +46,93 @@ class HttpError(LanceDBClientError):
status_code: int
The HTTP status code of the response. May be None if the request
failed before the response was received.
error_code: str
A machine-readable error code reported by the server, if available.
resource_kind: str
The kind of resource reported by the server, if available.
resource_name: str
The name of the resource reported by the server, if available.
namespace_path: tuple[str, ...]
The parent namespace path of the resource, if available.
"""
pass
def __new__(
cls,
message: str,
request_id: str,
status_code: Optional[int] = None,
error_code: Optional[str] = None,
resource_kind: Optional[str] = None,
resource_name: Optional[str] = None,
namespace_path: Optional[tuple[str, ...]] = None,
):
if cls is HttpError:
mapped_cls = _http_error_class(error_code, resource_kind)
if mapped_cls is not None:
cls = mapped_cls
return RuntimeError.__new__(cls)
def __init__(
self,
message: str,
request_id: str,
status_code: Optional[int] = None,
error_code: Optional[str] = None,
resource_kind: Optional[str] = None,
resource_name: Optional[str] = None,
namespace_path: Optional[tuple[str, ...]] = None,
):
super().__init__(message, request_id, status_code)
self.error_code = error_code
self.resource_kind = resource_kind
self.resource_name = resource_name
self.namespace_path = (
tuple(namespace_path) if namespace_path is not None else None
)
def __reduce__(self) -> tuple[type, tuple]:
return (
self.__class__,
(
str(self),
self.request_id,
self.status_code,
self.error_code,
self.resource_kind,
self.resource_name,
self.namespace_path,
),
)
def _http_error_class(
error_code: Optional[str], resource_kind: Optional[str]
) -> Optional[type[HttpError]]:
if error_code == "already_exists":
return AlreadyExistsError
if error_code != "not_found" or resource_kind is None:
return None
return {
"secret": SecretNotFoundError,
"function": FunctionNotFoundError,
"namespace": NamespaceNotFoundError,
}.get(resource_kind)
class SecretNotFoundError(HttpError):
"""A remote secret was not found."""
class FunctionNotFoundError(HttpError):
"""A remote function was not found."""
class NamespaceNotFoundError(HttpError):
"""A remote namespace was not found."""
class AlreadyExistsError(HttpError):
"""A remote resource already exists."""
class TableNotFoundError(HttpError, ValueError, _NamespaceTableNotFoundError):
+95
View File
@@ -9,9 +9,13 @@ from lance_namespace.errors import TableNotFoundError as NamespaceTableNotFoundE
from lancedb.db import DBConnection
from lancedb.remote.errors import (
AlreadyExistsError,
FunctionNotFoundError,
HttpError,
LanceDBClientError,
NamespaceNotFoundError,
RetryError,
SecretNotFoundError,
TableNotFoundError,
)
from lancedb.table import Table
@@ -59,6 +63,97 @@ def test_pickle_http_error():
assert restored.status_code == 404
@pytest.mark.parametrize(
"error_code,resource_kind,status_code,expected_type",
[
("not_found", "secret", 404, SecretNotFoundError),
("not_found", "function", 404, FunctionNotFoundError),
("not_found", "namespace", 404, NamespaceNotFoundError),
("already_exists", "secret", 409, AlreadyExistsError),
],
)
def test_http_error_structured_resource_error(
error_code, resource_kind, status_code, expected_type
):
err = HttpError(
"resource request failed",
"req-structured",
status_code,
error_code,
resource_kind,
"resource-name",
["parent", "child"],
)
assert type(err) is expected_type
assert isinstance(err, HttpError)
assert err.error_code == error_code
assert err.resource_kind == resource_kind
assert err.resource_name == "resource-name"
assert err.namespace_path == ("parent", "child")
assert err.request_id == "req-structured"
assert err.status_code == status_code
@pytest.mark.parametrize(
"error_code,resource_kind",
[
(None, None),
("not_found", None),
("not_found", "unknown"),
("invalid", "secret"),
],
)
def test_http_error_without_known_structured_fields_stays_generic(
error_code, resource_kind
):
err = HttpError(
"not found",
"req-generic",
404,
error_code,
resource_kind,
"resource-name",
[],
)
assert type(err) is HttpError
assert err.error_code == error_code
assert err.resource_kind == resource_kind
@pytest.mark.parametrize(
"exception_type,error_code,resource_kind",
[
(SecretNotFoundError, "not_found", "secret"),
(FunctionNotFoundError, "not_found", "function"),
(NamespaceNotFoundError, "not_found", "namespace"),
(AlreadyExistsError, "already_exists", "namespace"),
],
)
def test_pickle_structured_http_error(exception_type, error_code, resource_kind):
err = HttpError(
"resource request failed",
"req-pickle",
409 if error_code == "already_exists" else 404,
error_code,
resource_kind,
"resource-name",
["parent"],
)
assert type(err) is exception_type
restored = pickle.loads(pickle.dumps(err))
assert type(restored) is exception_type
assert restored.error_code == error_code
assert restored.resource_kind == resource_kind
assert restored.resource_name == "resource-name"
assert restored.namespace_path == ("parent",)
assert restored.request_id == "req-pickle"
assert restored.status_code == err.status_code
def test_pickle_table_not_found_error():
err = TableNotFoundError("Table 'missing' was not found", "req-missing", 404)
restored = pickle.loads(pickle.dumps(err))
+111 -1
View File
@@ -42,7 +42,12 @@ from lancedb.query import (
MatchQuery,
)
from lancedb.remote import ClientConfig
from lancedb.remote.errors import HttpError, RetryError
from lancedb.remote.errors import (
AlreadyExistsError,
HttpError,
NamespaceNotFoundError,
RetryError,
)
import pytest
import pyarrow as pa
from utils import start_server_thread
@@ -258,6 +263,111 @@ async def test_async_remote_exists(method, identifier, status, existence_handler
assert exc_info.value.status_code == status
def test_remote_describe_namespace_typed_not_found():
request_ids = []
def handler(request):
assert request.command == "POST"
assert request.path == "/v1/namespace/parent$child/describe"
request_ids.append(request.headers["x-request-id"])
request.send_response(404)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(
json.dumps(
{
"code": 5,
"error": "Namespace not found",
"error_code": "not_found",
"resource": {
"kind": "namespace",
"name": "child",
"namespace_path": ["parent"],
},
}
).encode()
)
with mock_lancedb_connection(handler) as db:
with pytest.raises(NamespaceNotFoundError) as exc_info:
db.describe_namespace(["parent", "child"])
error = exc_info.value
assert isinstance(error, HttpError)
assert error.error_code == "not_found"
assert error.resource_kind == "namespace"
assert error.resource_name == "child"
assert error.namespace_path == ("parent",)
assert error.status_code == 404
assert error.request_id == request_ids[-1]
def test_remote_create_namespace_typed_already_exists():
requests = []
def handler(request):
requests.append(request.path)
assert request.command == "POST"
assert request.path == "/v1/namespace/parent$child/create"
request.send_response(409)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(
json.dumps(
{
"code": 6,
"error": "Namespace already exists",
"error_code": "already_exists",
"resource": {
"kind": "namespace",
"name": "child",
"namespace_path": ["parent"],
},
}
).encode()
)
with mock_lancedb_connection(handler) as db:
with pytest.raises(AlreadyExistsError) as exc_info:
db.create_namespace(["parent", "child"])
error = exc_info.value
assert error.error_code == "already_exists"
assert error.resource_kind == "namespace"
assert error.resource_name == "child"
assert error.namespace_path == ("parent",)
assert error.status_code == 409
assert len(requests) == 1
def test_remote_legacy_namespace_error_stays_generic_http_error():
def handler(request):
assert request.path == "/v1/namespace/parent$child/describe"
request.send_response(404)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(
json.dumps(
{
"code": 5,
"error": "Namespace not found",
}
).encode()
)
with mock_lancedb_connection(handler) as db:
with pytest.raises(HttpError) as exc_info:
db.describe_namespace(["parent", "child"])
error = exc_info.value
assert type(error) is HttpError
assert error.error_code is None
assert error.resource_kind is None
assert error.resource_name is None
assert error.namespace_path is None
assert error.status_code == 404
@pytest.mark.parametrize("table_id", [[], ["invalid/name"]])
def test_remote_table_exists_rejects_invalid_identifier(table_id):
requests = []