55
66import asyncio
77import json
8+ import logging
9+ from collections .abc import Callable
810from dataclasses import dataclass
911from typing import Any
1012
1416from models_library .utils .json_serialization import json_dumps
1517from servicelib .aiohttp import status
1618from servicelib .aiohttp .rest_middlewares import (
19+ FMSG_INTERNAL_ERROR_USER_FRIENDLY ,
1720 envelope_middleware_factory ,
1821 error_middleware_factory ,
1922)
2023from servicelib .aiohttp .rest_responses import is_enveloped , unwrap_envelope
24+ from servicelib .error_codes import parse_error_code
2125
2226
2327@dataclass
@@ -26,9 +30,13 @@ class Data:
2630 y : str = "foo"
2731
2832
33+ class SomeUnexpectedError (Exception ):
34+ ...
35+
36+
2937class Handlers :
3038 @staticmethod
31- async def get_health_wrong (request : web .Request ):
39+ async def get_health_wrong (_request : web .Request ):
3240 return {
3341 "name" : __name__ .split ("." )[0 ],
3442 "version" : "1.0" ,
@@ -37,7 +45,7 @@ async def get_health_wrong(request: web.Request):
3745 }
3846
3947 @staticmethod
40- async def get_health (request : web .Request ):
48+ async def get_health (_request : web .Request ):
4149 return {
4250 "name" : __name__ .split ("." )[0 ],
4351 "version" : "1.0" ,
@@ -46,62 +54,126 @@ async def get_health(request: web.Request):
4654 }
4755
4856 @staticmethod
49- async def get_dict (request : web .Request ):
57+ async def get_dict (_request : web .Request ):
5058 return {"x" : 3 , "y" : "3" }
5159
5260 @staticmethod
53- async def get_envelope (request : web .Request ):
61+ async def get_envelope (_request : web .Request ):
5462 data = {"x" : 3 , "y" : "3" }
5563 return {"error" : None , "data" : data }
5664
5765 @staticmethod
58- async def get_list (request : web .Request ):
66+ async def get_list (_request : web .Request ):
5967 return [{"x" : 3 , "y" : "3" }] * 3
6068
6169 @staticmethod
62- async def get_attobj ( request : web .Request ):
70+ async def get_obj ( _request : web .Request ):
6371 return Data (3 , "3" )
6472
6573 @staticmethod
66- async def get_string (request : web .Request ):
74+ async def get_string (_request : web .Request ):
6775 return "foo"
6876
6977 @staticmethod
70- async def get_number (request : web .Request ):
78+ async def get_number (_request : web .Request ):
7179 return 3
7280
7381 @staticmethod
74- async def get_mixed (request : web .Request ):
82+ async def get_mixed (_request : web .Request ):
7583 return [{"x" : 3 , "y" : "3" , "z" : [Data (3 , "3" )] * 2 }] * 3
7684
7785 @classmethod
78- def get (cls , suffix ):
86+ def returns_value (cls , suffix ):
7987 handlers = cls ()
8088 coro = getattr (handlers , "get_" + suffix )
8189 loop = asyncio .get_event_loop ()
82- data = loop .run_until_complete (coro (None ))
90+ returned_value = loop .run_until_complete (coro (None ))
91+ return json .loads (json_dumps (returned_value ))
92+
93+ EXPECTED_RAISE_UNEXPECTED_REASON = "Unexpected error"
94+
95+ @classmethod
96+ async def raise_exception (cls , request : web .Request ):
97+ exc_name = request .query .get ("exc" )
98+ match exc_name :
99+ case NotImplementedError .__name__ :
100+ raise NotImplementedError
101+ case asyncio .TimeoutError .__name__ :
102+ raise asyncio .TimeoutError
103+ case web .HTTPOk .__name__ :
104+ raise web .HTTPOk # 2XX
105+ case web .HTTPUnauthorized .__name__ :
106+ raise web .HTTPUnauthorized # 4XX
107+ case web .HTTPServiceUnavailable .__name__ :
108+ raise web .HTTPServiceUnavailable # 5XX
109+ case _: # unexpected
110+ raise SomeUnexpectedError (cls .EXPECTED_RAISE_UNEXPECTED_REASON )
111+
112+ @staticmethod
113+ async def raise_error (_request : web .Request ):
114+ raise web .HTTPNotFound
83115
84- return json .loads (json_dumps (data ))
116+ @staticmethod
117+ async def raise_error_with_reason (_request : web .Request ):
118+ raise web .HTTPNotFound (reason = "I did not find it" )
119+
120+ @staticmethod
121+ async def raise_success (_request : web .Request ):
122+ raise web .HTTPOk
123+
124+ @staticmethod
125+ async def raise_success_with_reason (_request : web .Request ):
126+ raise web .HTTPOk (reason = "I'm ok" )
127+
128+ @staticmethod
129+ async def raise_success_with_text (_request : web .Request ):
130+ # NOTE: explicitly NOT enveloped!
131+ raise web .HTTPOk (reason = "I'm ok" , text = json .dumps ({"ok" : True }))
85132
86133
87134@pytest .fixture
88- def client (event_loop , aiohttp_client ):
135+ def client (
136+ event_loop : asyncio .AbstractEventLoop ,
137+ aiohttp_client : Callable ,
138+ monkeypatch : pytest .MonkeyPatch ,
139+ ):
140+ monkeypatch .setenv ("SC_BUILD_TARGET" , "production" )
141+
89142 app = web .Application ()
90143
91144 # routes
92145 app .router .add_routes (
93146 [
94- web .get ("/v1/health" , Handlers .get_health , name = "get_health" ),
95- web .get ("/v1/dict" , Handlers .get_dict , name = "get_dict" ),
96- web .get ("/v1/envelope" , Handlers .get_envelope , name = "get_envelope" ),
97- web .get ("/v1/list" , Handlers .get_list , name = "get_list" ),
98- web .get ("/v1/attobj" , Handlers .get_attobj , name = "get_attobj" ),
99- web .get ("/v1/string" , Handlers .get_string , name = "get_string" ),
100- web .get ("/v1/number" , Handlers .get_number , name = "get_number" ),
101- web .get ("/v1/mixed" , Handlers .get_mixed , name = "get_mixed" ),
147+ web .get (path , handler , name = handler .__name__ )
148+ for path , handler in [
149+ ("/v1/health" , Handlers .get_health ),
150+ ("/v1/dict" , Handlers .get_dict ),
151+ ("/v1/envelope" , Handlers .get_envelope ),
152+ ("/v1/list" , Handlers .get_list ),
153+ ("/v1/obj" , Handlers .get_obj ),
154+ ("/v1/string" , Handlers .get_string ),
155+ ("/v1/number" , Handlers .get_number ),
156+ ("/v1/mixed" , Handlers .get_mixed ),
157+ # custom use cases
158+ ("/v1/raise_exception" , Handlers .raise_exception ),
159+ ("/v1/raise_error" , Handlers .raise_error ),
160+ ("/v1/raise_error_with_reason" , Handlers .raise_error_with_reason ),
161+ ("/v1/raise_success" , Handlers .raise_success ),
162+ ("/v1/raise_success_with_reason" , Handlers .raise_success_with_reason ),
163+ ("/v1/raise_success_with_text" , Handlers .raise_success_with_text ),
164+ ]
102165 ]
103166 )
104167
168+ app .router .add_routes (
169+ [
170+ web .get (
171+ "/free/raise_exception" ,
172+ Handlers .raise_exception ,
173+ name = "raise_exception_without_middleware" ,
174+ )
175+ ]
176+ )
105177 # middlewares
106178 app .middlewares .append (error_middleware_factory (api_version = "/v1" ))
107179 app .middlewares .append (envelope_middleware_factory (api_version = "/v1" ))
@@ -112,14 +184,14 @@ def client(event_loop, aiohttp_client):
112184@pytest .mark .parametrize (
113185 "path,expected_data" ,
114186 [
115- ("/health" , Handlers .get ("health" )),
116- ("/dict" , Handlers .get ("dict" )),
117- ("/envelope" , Handlers .get ("envelope" )["data" ]),
118- ("/list" , Handlers .get ("list" )),
119- ("/attobj " , Handlers .get ( "attobj " )),
120- ("/string" , Handlers .get ("string" )),
121- ("/number" , Handlers .get ("number" )),
122- ("/mixed" , Handlers .get ("mixed" )),
187+ ("/health" , Handlers .returns_value ("health" )),
188+ ("/dict" , Handlers .returns_value ("dict" )),
189+ ("/envelope" , Handlers .returns_value ("envelope" )["data" ]),
190+ ("/list" , Handlers .returns_value ("list" )),
191+ ("/obj " , Handlers .returns_value ( "obj " )),
192+ ("/string" , Handlers .returns_value ("string" )),
193+ ("/number" , Handlers .returns_value ("number" )),
194+ ("/mixed" , Handlers .returns_value ("mixed" )),
123195 ],
124196)
125197async def test_envelope_middleware (path : str , expected_data : Any , client : TestClient ):
@@ -133,7 +205,7 @@ async def test_envelope_middleware(path: str, expected_data: Any, client: TestCl
133205 assert data == expected_data
134206
135207
136- async def test_404_not_found (client : TestClient ):
208+ async def test_404_not_found_when_entrypoint_not_exposed (client : TestClient ):
137209 response = await client .get ("/some-invalid-address-outside-api" )
138210 payload = await response .text ()
139211 assert response .status == status .HTTP_404_NOT_FOUND , payload
@@ -147,3 +219,62 @@ async def test_404_not_found(client: TestClient):
147219 data , error = unwrap_envelope (payload )
148220 assert error
149221 assert not data
222+
223+
224+ async def test_raised_unhandled_exception (
225+ client : TestClient , caplog : pytest .LogCaptureFixture
226+ ):
227+ with caplog .at_level (logging .ERROR ):
228+ response = await client .get ("/v1/raise_exception" )
229+
230+ # respond the client with 500
231+ assert response .status == status .HTTP_500_INTERNAL_SERVER_ERROR
232+
233+ # response model
234+ data , error = unwrap_envelope (await response .json ())
235+ assert not data
236+ assert error
237+
238+ # user friendly message with OEC reference
239+ assert "OEC" in error ["message" ]
240+ parsed_oec = parse_error_code (error ["message" ]).pop ()
241+ assert FMSG_INTERNAL_ERROR_USER_FRIENDLY .format (parsed_oec ) == error ["message" ]
242+
243+ # avoids details
244+ assert not error .get ("errors" )
245+ assert not error .get ("logs" )
246+
247+ # - log sufficient information to diagnose the issue
248+ #
249+ # ERROR servicelib.aiohttp.rest_middlewares:rest_middlewares.py:75 We apologize ... [OEC:128594540599840].
250+ # {
251+ # "exception_details": "Unexpected error",
252+ # "error_code": "OEC:128594540599840",
253+ # "context": {
254+ # "request.remote": "127.0.0.1",
255+ # "request.method": "GET",
256+ # "request.path": "/v1/raise_exception"
257+ # },
258+ # "tip": null
259+ # }
260+ # Traceback (most recent call last):
261+ # File "/osparc-simcore/packages/service-library/src/servicelib/aiohttp/rest_middlewares.py", line 94, in _middleware_handler
262+ # return await handler(request)
263+ # ^^^^^^^^^^^^^^^^^^^^^^
264+ # File "/osparc-simcore/packages/service-library/src/servicelib/aiohttp/rest_middlewares.py", line 186, in _middleware_handler
265+ # resp = await handler(request)
266+ # ^^^^^^^^^^^^^^^^^^^^^^
267+ # File "/osparc-simcore/packages/service-library/tests/aiohttp/test_rest_middlewares.py", line 109, in raise_exception
268+ # raise SomeUnexpectedError(cls.EXPECTED_RAISE_UNEXPECTED_REASON)
269+ # tests.aiohttp.test_rest_middlewares.SomeUnexpectedError: Unexpected error
270+
271+ assert response .method in caplog .text
272+ assert response .url .path in caplog .text
273+ assert "exception_details" in caplog .text
274+ assert "request.remote" in caplog .text
275+ assert "context" in caplog .text
276+ assert SomeUnexpectedError .__name__ in caplog .text
277+ assert Handlers .EXPECTED_RAISE_UNEXPECTED_REASON in caplog .text
278+
279+ # log OEC
280+ assert "OEC:" in caplog .text
0 commit comments