Skip to content

Commit d7bb29e

Browse files
authored
🎨 Handles wallet forbidden error and enhances handling of unexpected errors (#6444)
1 parent 672f2e8 commit d7bb29e

File tree

9 files changed

+245
-62
lines changed

9 files changed

+245
-62
lines changed

packages/service-library/src/servicelib/aiohttp/rest_middlewares.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
from aiohttp import web
1313
from aiohttp.web_request import Request
1414
from aiohttp.web_response import StreamResponse
15+
from models_library.errors_classes import OsparcErrorMixin
1516
from models_library.utils.json_serialization import json_dumps
17+
from servicelib.error_codes import create_error_code
1618

19+
from ..logging_utils import create_troubleshotting_log_message, get_log_record_extra
1720
from ..mimetype_constants import MIMETYPE_APPLICATION_JSON
1821
from ..utils import is_production_environ
1922
from .rest_models import ErrorItemType, ErrorType, LogMessageType
@@ -28,6 +31,7 @@
2831
from .typing_extension import Handler, Middleware
2932

3033
DEFAULT_API_VERSION = "v0"
34+
FMSG_INTERNAL_ERROR_USER_FRIENDLY = "We apologize for the inconvenience. Our team has recorded the issue and is working to resolve it as quickly as possible. Thank you for your patience [{}]"
3135

3236

3337
_logger = logging.getLogger(__name__)
@@ -40,29 +44,41 @@ def is_api_request(request: web.Request, api_version: str) -> bool:
4044

4145
def error_middleware_factory(
4246
api_version: str,
43-
log_exceptions: bool = True,
4447
) -> Middleware:
4548
_is_prod: bool = is_production_environ()
4649

4750
def _process_and_raise_unexpected_error(request: web.BaseRequest, err: Exception):
51+
52+
error_code = create_error_code(err)
53+
error_context: dict[str, Any] = {
54+
"request.remote": f"{request.remote}",
55+
"request.method": f"{request.method}",
56+
"request.path": f"{request.path}",
57+
}
58+
if isinstance(err, OsparcErrorMixin):
59+
error_context.update(err.error_context())
60+
61+
frontend_msg = FMSG_INTERNAL_ERROR_USER_FRIENDLY.format(error_code)
62+
log_msg = create_troubleshotting_log_message(
63+
message_to_user=frontend_msg,
64+
error=err,
65+
error_code=error_code,
66+
error_context=error_context,
67+
)
68+
4869
http_error = create_http_error(
4970
err,
50-
"Unexpected Server error",
71+
frontend_msg,
5172
web.HTTPInternalServerError,
5273
skip_internal_error_details=_is_prod,
5374
)
54-
55-
if log_exceptions:
56-
_logger.error(
57-
'Unexpected server error "%s" from access: %s "%s %s". Responding with status %s',
58-
type(err),
59-
request.remote,
60-
request.method,
61-
request.path,
62-
http_error.status,
63-
exc_info=err,
64-
stack_info=True,
65-
)
75+
_logger.exception(
76+
log_msg,
77+
extra=get_log_record_extra(
78+
error_code=error_code,
79+
user_id=error_context.get("user_id"),
80+
),
81+
)
6682
raise http_error
6783

6884
@web.middleware

packages/service-library/src/servicelib/aiohttp/rest_responses.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def create_http_error(
110110
error = ErrorType(
111111
errors=items,
112112
status=http_error_cls.status_code,
113-
message=items[0].message if items else default_message,
113+
message=default_message,
114114
)
115115

116116
assert not http_error_cls.empty_body # nosec

packages/service-library/tests/aiohttp/long_running_tasks/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ async def _string_list_task(
3333
await asyncio.sleep(sleep_time)
3434
task_progress.update(message="generated item", percent=index / num_strings)
3535
if fail:
36-
raise RuntimeError("We were asked to fail!!")
36+
msg = "We were asked to fail!!"
37+
raise RuntimeError(msg)
3738

3839
# NOTE: this code is used just for the sake of not returning the default 200
3940
return web.json_response(

packages/service-library/tests/aiohttp/test_rest_middlewares.py

Lines changed: 161 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import asyncio
77
import json
8+
import logging
9+
from collections.abc import Callable
810
from dataclasses import dataclass
911
from typing import Any
1012

@@ -14,10 +16,12 @@
1416
from models_library.utils.json_serialization import json_dumps
1517
from servicelib.aiohttp import status
1618
from servicelib.aiohttp.rest_middlewares import (
19+
FMSG_INTERNAL_ERROR_USER_FRIENDLY,
1720
envelope_middleware_factory,
1821
error_middleware_factory,
1922
)
2023
from 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+
2937
class 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
)
125197
async 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

Comments
 (0)