@@ -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