Skip to content

Commit 8771094

Browse files
rustyconoverclaude
andcommitted
Return HTTP 200 instead of 500 for RPC server errors with X-VGI-RPC-Error header (v0.6.5)
Server errors are now sent as HTTP 200 with an X-VGI-RPC-Error: true header so that clients which discard response bodies on 5xx status codes still receive the Arrow IPC error metadata. Client errors (400/401/404/415) remain unchanged. Also fixes OAuth PKCE type narrowing for mypy/ty strict checking and adds OAuth PKCE browser flow support. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7e69bb2 commit 8771094

8 files changed

Lines changed: 1643 additions & 11 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "vgi-rpc"
3-
version = "0.6.4"
3+
version = "0.6.5"
44
description = "Vector Gateway Interface - RPC framework based on Apache Arrow"
55
readme = "README.md"
66
requires-python = ">=3.13"

tests/test_http.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,83 @@ def test_unary_on_exchange_400(self, client: _SyncTestClient) -> None:
211211
assert "does not support /exchange" in err.error_message
212212

213213

214+
class TestServerErrorHeader:
215+
"""Server errors return HTTP 200 with X-VGI-RPC-Error header instead of 500."""
216+
217+
def test_unary_server_error_returns_200_with_error_header(self, client: _SyncTestClient) -> None:
218+
"""Unary method that raises returns 200 with X-VGI-RPC-Error: true."""
219+
from vgi_rpc.metadata import REQUEST_VERSION, REQUEST_VERSION_KEY, RPC_METHOD_KEY
220+
from vgi_rpc.rpc import _EMPTY_SCHEMA
221+
from vgi_rpc.utils import empty_batch
222+
223+
req_buf = BytesIO()
224+
md = pa.KeyValueMetadata({RPC_METHOD_KEY: b"fail_unary", REQUEST_VERSION_KEY: REQUEST_VERSION})
225+
with ipc.new_stream(req_buf, _EMPTY_SCHEMA) as writer:
226+
writer.write_batch(empty_batch(_EMPTY_SCHEMA), custom_metadata=md)
227+
228+
resp = client.post(
229+
f"{_BASE_URL}{client.prefix}/fail_unary",
230+
content=req_buf.getvalue(),
231+
headers={"Content-Type": _ARROW_CONTENT_TYPE},
232+
)
233+
assert resp.status_code == 200
234+
assert resp.headers.get("x-vgi-rpc-error") == "true"
235+
err = _extract_rpc_error(resp)
236+
assert err.error_type == "ValueError"
237+
assert "unary boom" in err.error_message
238+
239+
def test_stream_init_server_error_returns_200_with_error_header(self, client: _SyncTestClient) -> None:
240+
"""Stream init that raises returns 200 with X-VGI-RPC-Error: true."""
241+
from vgi_rpc.metadata import REQUEST_VERSION, REQUEST_VERSION_KEY, RPC_METHOD_KEY
242+
from vgi_rpc.rpc import _EMPTY_SCHEMA
243+
from vgi_rpc.utils import empty_batch
244+
245+
req_buf = BytesIO()
246+
md = pa.KeyValueMetadata(
247+
{RPC_METHOD_KEY: b"fail_stream_init_with_header", REQUEST_VERSION_KEY: REQUEST_VERSION}
248+
)
249+
with ipc.new_stream(req_buf, _EMPTY_SCHEMA) as writer:
250+
writer.write_batch(empty_batch(_EMPTY_SCHEMA), custom_metadata=md)
251+
252+
resp = client.post(
253+
f"{_BASE_URL}{client.prefix}/fail_stream_init_with_header/init",
254+
content=req_buf.getvalue(),
255+
headers={"Content-Type": _ARROW_CONTENT_TYPE},
256+
)
257+
assert resp.status_code == 200
258+
assert resp.headers.get("x-vgi-rpc-error") == "true"
259+
err = _extract_rpc_error(resp)
260+
assert err.error_type == "ValueError"
261+
assert "init boom with header" in err.error_message
262+
263+
def test_stream_exchange_server_error_returns_200_with_error_header(self, client: _SyncTestClient) -> None:
264+
"""Exchange that raises returns 200 with X-VGI-RPC-Error: true (via http_connect)."""
265+
with (
266+
pytest.raises(RpcError, match="bidi boom") as exc_info,
267+
http_connect(RpcFixtureService, client=client) as proxy,
268+
):
269+
session = proxy.fail_bidi_mid(factor=2.0)
270+
assert isinstance(session, HttpStreamSession)
271+
schema = pa.schema([pa.field("value", pa.float64())])
272+
batch = pa.RecordBatch.from_pydict({"value": [1.0]}, schema=schema)
273+
ab = AnnotatedBatch(batch=batch)
274+
# First exchange succeeds
275+
session.exchange(ab)
276+
# Second exchange triggers the error
277+
session.exchange(ab)
278+
assert exc_info.value.error_type == "RuntimeError"
279+
280+
def test_400_errors_do_not_get_error_header(self, client: _SyncTestClient) -> None:
281+
"""Client errors (400) still return 400 without X-VGI-RPC-Error header."""
282+
resp = client.post(
283+
f"{_BASE_URL}{client.prefix}/add/init",
284+
content=b"",
285+
headers={"Content-Type": _ARROW_CONTENT_TYPE},
286+
)
287+
assert resp.status_code == 400
288+
assert resp.headers.get("x-vgi-rpc-error") is None
289+
290+
214291
# ---------------------------------------------------------------------------
215292
# Tests: Resumable producer stream over HTTP
216293
# ---------------------------------------------------------------------------
@@ -1124,6 +1201,7 @@ def test_cors_exposes_standard_headers(self) -> None:
11241201
assert "WWW-Authenticate" in expose
11251202
assert "X-Request-ID" in expose
11261203
assert "X-VGI-Content-Encoding" in expose
1204+
assert "X-VGI-RPC-Error" in expose
11271205

11281206
def test_no_cors_by_default(self) -> None:
11291207
"""Without cors_origins, no CORS headers are added."""

0 commit comments

Comments
 (0)