Skip to content

Commit 840b9ad

Browse files
authored
Surface non-Connect handler exceptions to user (#219)
Signed-off-by: Anuraag Agrawal <anuraaga@gmail.com>
1 parent 984b0a0 commit 840b9ad

5 files changed

Lines changed: 214 additions & 60 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ classifiers = [
2828
"Topic :: Software Development :: Libraries :: Python Modules",
2929
"Typing :: Typed",
3030
]
31-
dependencies = ["protobuf>=5.28", "pyqwest>=0.4.1"]
31+
dependencies = ["protobuf>=5.28", "pyqwest>=0.5.1"]
3232

3333
[project.urls]
3434
Documentation = "https://connectrpc.com/docs/python/getting-started/"

src/connectrpc/_server_async.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,10 @@ async def __call__(
232232
ctx,
233233
)
234234
except Exception as e:
235-
return await self._handle_error(e, ctx, send)
235+
await self._handle_error(e, ctx, send)
236+
if not isinstance(e, (ConnectError, HTTPException)):
237+
raise
238+
return None
236239

237240
# Streams have their own error handling so move out of the try block.
238241
return await self._handle_stream(
@@ -486,6 +489,8 @@ async def _watch_for_disconnect() -> None:
486489
"more_trailers": False,
487490
}
488491
)
492+
if error and not isinstance(error, ConnectError):
493+
raise error
489494

490495
async def _handle_error(
491496
self, exc: Exception, ctx: RequestContext | None, send: ASGISendCallable

src/connectrpc/_server_sync.py

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

33
import base64
44
import functools
5+
import traceback
56
from abc import ABC, abstractmethod
67
from dataclasses import replace
78
from http import HTTPStatus
@@ -46,9 +47,9 @@
4647
from .compression import Compression
4748

4849
if sys.version_info >= (3, 11):
49-
from wsgiref.types import StartResponse, WSGIEnvironment
50+
from wsgiref.types import ErrorStream, StartResponse, WSGIEnvironment
5051
else:
51-
from _typeshed.wsgi import StartResponse, WSGIEnvironment
52+
from _typeshed.wsgi import ErrorStream, StartResponse, WSGIEnvironment
5253
else:
5354
StartResponse = "wsgiref.types.StartResponse"
5455
WSGIEnvironment = "wsgiref.types.WSGIEnvironment"
@@ -251,6 +252,7 @@ def __call__(
251252

252253
except Exception as e:
253254
_drain_request_body(environ)
255+
_maybe_log_exception(environ, e)
254256
return self._handle_error(e, ctx, start_response)
255257

256258
def _handle_unary(
@@ -502,6 +504,7 @@ def _handle_stream(
502504
# response message will be handled by _response_stream, so here we have a
503505
# full error-only response.
504506
_drain_request_body(environ)
507+
_maybe_log_exception(environ, e)
505508
_send_stream_response_headers(
506509
start_response, protocol, codec, resp_compression.name(), ctx
507510
)
@@ -668,3 +671,12 @@ def _drain_request_body(environ: WSGIEnvironment) -> None:
668671
# server that doesn't do so, so we go ahead and do it ourselves.
669672
for _ in _read_body(environ):
670673
pass
674+
675+
676+
def _maybe_log_exception(environ: WSGIEnvironment, exc: Exception) -> None:
677+
if isinstance(exc, (ConnectError, HTTPException)):
678+
return
679+
errors: ErrorStream = environ["wsgi.errors"]
680+
errors.write(
681+
f"Exception in WSGI application\n{''.join(traceback.format_exception(type(exc), exc, exc.__traceback__))}"
682+
)

test/test_errors.py

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import asyncio
44
import threading
55
from http import HTTPStatus
6-
from typing import NoReturn
6+
from typing import TYPE_CHECKING, NoReturn
77

88
import pytest
99
from pyqwest import (
@@ -19,6 +19,7 @@
1919
)
2020
from pyqwest.testing import ASGITransport, WSGITransport
2121

22+
from connectrpc._protocol import HTTPException
2223
from connectrpc.code import Code
2324
from connectrpc.errors import ConnectError
2425

@@ -32,6 +33,9 @@
3233
)
3334
from .haberdasher_pb2 import Hat, Size
3435

36+
if TYPE_CHECKING:
37+
from connectrpc.request import RequestContext
38+
3539
_errors = [
3640
(Code.CANCELED, "Operation was cancelled", 499),
3741
(Code.UNKNOWN, "An unknown error occurred", 500),
@@ -424,3 +428,148 @@ async def make_hat(self, request, ctx) -> NoReturn:
424428
assert exc_info.value.code == Code.DEADLINE_EXCEEDED
425429
assert exc_info.value.message == "Request timed out"
426430
assert recorded_timeout_header == "200"
431+
432+
433+
@pytest.mark.asyncio
434+
async def test_async_unhandled_exception_reraised() -> None:
435+
class RaisingHaberdasher(Haberdasher):
436+
async def make_hat(self, request, ctx) -> NoReturn:
437+
raise TypeError("Something went wrong")
438+
439+
app = HaberdasherASGIApplication(RaisingHaberdasher())
440+
transport = ASGITransport(app)
441+
http_client = Client(transport)
442+
443+
async with HaberdasherClient(
444+
"http://localhost", timeout_ms=200, http_client=http_client
445+
) as client:
446+
with pytest.raises(ConnectError, match="Something went wrong"):
447+
await client.make_hat(request=Size(inches=10))
448+
449+
assert isinstance(transport.app_exception, TypeError)
450+
assert str(transport.app_exception) == "Something went wrong"
451+
452+
453+
@pytest.mark.asyncio
454+
async def test_async_unhandled_exception_reraised_stream() -> None:
455+
class RaisingHaberdasher(Haberdasher):
456+
def make_similar_hats(self, request: Size, ctx: RequestContext) -> NoReturn:
457+
raise TypeError("Something went wrong")
458+
459+
app = HaberdasherASGIApplication(RaisingHaberdasher())
460+
transport = ASGITransport(app)
461+
http_client = Client(transport)
462+
463+
async with HaberdasherClient(
464+
"http://localhost", timeout_ms=200, http_client=http_client
465+
) as client:
466+
with pytest.raises(ConnectError, match="Something went wrong"):
467+
async for _ in client.make_similar_hats(request=Size(inches=10)):
468+
pass
469+
470+
assert isinstance(transport.app_exception, TypeError)
471+
assert str(transport.app_exception) == "Something went wrong"
472+
473+
474+
@pytest.mark.asyncio
475+
async def test_async_connect_exception_not_reraised() -> None:
476+
class RaisingHaberdasher(Haberdasher):
477+
async def make_hat(self, request, ctx) -> NoReturn:
478+
raise ConnectError(Code.INTERNAL, "We're broken")
479+
480+
app = HaberdasherASGIApplication(RaisingHaberdasher())
481+
transport = ASGITransport(app)
482+
http_client = Client(transport)
483+
484+
async with HaberdasherClient(
485+
"http://localhost", timeout_ms=200, http_client=http_client
486+
) as client:
487+
with pytest.raises(ConnectError, match="We're broken"):
488+
await client.make_hat(request=Size(inches=10))
489+
490+
assert transport.app_exception is None
491+
492+
493+
@pytest.mark.asyncio
494+
async def test_async_connect_exception_not_reraised_stream() -> None:
495+
class RaisingHaberdasher(Haberdasher):
496+
def make_similar_hats(self, request: Size, ctx: RequestContext) -> NoReturn:
497+
raise ConnectError(Code.INTERNAL, "We're broken")
498+
499+
app = HaberdasherASGIApplication(RaisingHaberdasher())
500+
transport = ASGITransport(app)
501+
http_client = Client(transport)
502+
503+
async with HaberdasherClient(
504+
"http://localhost", timeout_ms=200, http_client=http_client
505+
) as client:
506+
with pytest.raises(ConnectError, match="We're broken"):
507+
async for _ in client.make_similar_hats(request=Size(inches=10)):
508+
pass
509+
510+
assert transport.app_exception is None
511+
512+
513+
@pytest.mark.asyncio
514+
async def test_async_http_exception_not_reraised() -> None:
515+
class RaisingHaberdasher(Haberdasher):
516+
async def make_hat(self, request, ctx) -> NoReturn:
517+
raise HTTPException(status=HTTPStatus.INTERNAL_SERVER_ERROR, headers=[])
518+
519+
app = HaberdasherASGIApplication(RaisingHaberdasher())
520+
transport = ASGITransport(app)
521+
http_client = Client(transport)
522+
523+
async with HaberdasherClient(
524+
"http://localhost", timeout_ms=200, http_client=http_client
525+
) as client:
526+
with pytest.raises(ConnectError, match="Internal Server Error"):
527+
await client.make_hat(request=Size(inches=10))
528+
529+
assert transport.app_exception is None
530+
531+
532+
def test_sync_unhandled_exception_logged() -> None:
533+
class RaisingHaberdasher(HaberdasherSync):
534+
def make_hat(self, request, ctx) -> NoReturn:
535+
raise TypeError("Something went wrong")
536+
537+
app = HaberdasherWSGIApplication(RaisingHaberdasher())
538+
transport = WSGITransport(app)
539+
http_client = SyncClient(transport)
540+
541+
with (
542+
HaberdasherClientSync(
543+
"http://localhost", timeout_ms=200, http_client=http_client
544+
) as client,
545+
pytest.raises(ConnectError, match="Something went wrong"),
546+
):
547+
client.make_hat(request=Size(inches=10))
548+
549+
logged_error = transport.error_stream.getvalue()
550+
assert "Exception in WSGI application" in logged_error
551+
assert "TypeError: Something went wrong" in logged_error
552+
assert "Traceback" in logged_error
553+
554+
555+
def test_sync_unhandled_exception_logged_stream() -> None:
556+
class RaisingHaberdasher(HaberdasherSync):
557+
def make_similar_hats(self, request, ctx) -> NoReturn:
558+
raise TypeError("Something went wrong")
559+
560+
app = HaberdasherWSGIApplication(RaisingHaberdasher())
561+
transport = WSGITransport(app)
562+
http_client = SyncClient(transport)
563+
564+
with (
565+
HaberdasherClientSync(
566+
"http://localhost", timeout_ms=200, http_client=http_client
567+
) as client,
568+
pytest.raises(ConnectError, match="Something went wrong"),
569+
):
570+
next(client.make_similar_hats(request=Size(inches=10)))
571+
572+
logged_error = transport.error_stream.getvalue()
573+
assert "Exception in WSGI application" in logged_error
574+
assert "TypeError: Something went wrong" in logged_error
575+
assert "Traceback" in logged_error

0 commit comments

Comments
 (0)