Skip to content

Commit 74a886a

Browse files
pcrespovmatusdrobuliak66
authored andcommitted
cherry pick web-api: Fixes handling of unexpected errors #7939
1 parent 5756774 commit 74a886a

File tree

35 files changed

+179
-170
lines changed

35 files changed

+179
-170
lines changed

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

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Enables monitoring of some quantities needed for diagnostics"""
22

3-
import asyncio
43
import logging
54
from collections.abc import Awaitable, Callable
65
from time import perf_counter
@@ -61,9 +60,8 @@ async def middleware_handler(request: web.Request, handler: Handler):
6160
# See https://prometheus.io/docs/concepts/metric_types
6261

6362
log_exception: BaseException | None = None
64-
resp: web.StreamResponse = web.HTTPInternalServerError(
65-
reason="Unexpected exception"
66-
)
63+
response: web.StreamResponse = web.HTTPInternalServerError()
64+
6765
canonical_endpoint = request.path
6866
if request.match_info.route.resource:
6967
canonical_endpoint = request.match_info.route.resource.canonical
@@ -86,29 +84,25 @@ async def middleware_handler(request: web.Request, handler: Handler):
8684
endpoint=canonical_endpoint,
8785
user_agent=user_agent,
8886
):
89-
resp = await handler(request)
87+
response = await handler(request)
9088

9189
assert isinstance( # nosec
92-
resp, web.StreamResponse
90+
response, web.StreamResponse
9391
), "Forgot envelope middleware?"
9492

9593
except web.HTTPServerError as exc:
96-
resp = exc
94+
response = exc
9795
log_exception = exc
98-
raise resp from exc
96+
raise
97+
9998
except web.HTTPException as exc:
100-
resp = exc
99+
response = exc
101100
log_exception = None
102-
raise resp from exc
103-
except asyncio.CancelledError as exc:
104-
resp = web.HTTPInternalServerError(text=f"{exc}")
105-
log_exception = exc
106-
raise resp from exc
101+
raise
102+
107103
except Exception as exc: # pylint: disable=broad-except
108-
resp = web.HTTPInternalServerError(text=f"{exc}")
109-
resp.__cause__ = exc
110104
log_exception = exc
111-
raise resp from exc
105+
raise
112106

113107
finally:
114108
response_latency_seconds = perf_counter() - start_time
@@ -118,13 +112,13 @@ async def middleware_handler(request: web.Request, handler: Handler):
118112
method=request.method,
119113
endpoint=canonical_endpoint,
120114
user_agent=user_agent,
121-
http_status=resp.status,
115+
http_status=response.status,
122116
response_latency_seconds=response_latency_seconds,
123117
)
124118

125119
if exit_middleware_cb:
126120
with log_catch(logger=log, reraise=False):
127-
await exit_middleware_cb(request, resp)
121+
await exit_middleware_cb(request, response)
128122

129123
if log_exception:
130124
log.error(
@@ -135,12 +129,12 @@ async def middleware_handler(request: web.Request, handler: Handler):
135129
request.method,
136130
request.path,
137131
response_latency_seconds,
138-
resp.status,
132+
response.status,
139133
exc_info=log_exception,
140134
stack_info=True,
141135
)
142136

143-
return resp
137+
return response
144138

145139
setattr( # noqa: B010
146140
middleware_handler, "__middleware_name__", f"{__name__}.monitor_{app_name}"

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ async def profiling_middleware(request: Request, handler):
1313
try:
1414
if _profiler.is_running or (_profiler.last_session is not None):
1515
raise HTTPInternalServerError(
16-
reason="Profiler is already running. Only a single request can be profiled at any given time.",
16+
text="Profiler is already running. Only a single request can be profiled at any given time.",
1717
headers={},
1818
)
1919
_profiler.reset()
@@ -24,7 +24,7 @@ async def profiling_middleware(request: Request, handler):
2424

2525
if response.content_type != MIMETYPE_APPLICATION_JSON:
2626
raise HTTPInternalServerError(
27-
reason=f"Profiling middleware is not compatible with {response.content_type=}",
27+
text=f"Profiling middleware is not compatible with {response.content_type=}",
2828
headers={},
2929
)
3030

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def handle_validation_as_http_error(
5151
}
5252
for e in err.errors()
5353
]
54-
reason_msg = error_msg_template.format(
54+
user_error_message = error_msg_template.format(
5555
failed=", ".join(d["loc"] for d in details)
5656
)
5757

@@ -80,15 +80,14 @@ def handle_validation_as_http_error(
8080
error_str = json_dumps(
8181
{
8282
"error": {
83-
"msg": reason_msg,
83+
"msg": user_error_message,
8484
"resource": resource_name, # optional
8585
"details": details, # optional
8686
}
8787
}
8888
)
8989

9090
raise web.HTTPUnprocessableEntity( # 422
91-
reason=reason_msg,
9291
text=error_str,
9392
content_type=MIMETYPE_APPLICATION_JSON,
9493
) from err

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

Lines changed: 78 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,24 @@
1111
from aiohttp.web_exceptions import HTTPError
1212
from aiohttp.web_request import Request
1313
from aiohttp.web_response import StreamResponse
14-
from common_library.error_codes import create_error_code
14+
from common_library.error_codes import ErrorCodeStr, create_error_code
1515
from common_library.json_serialization import json_dumps, json_loads
16+
from models_library.basic_types import IDStr
1617
from models_library.rest_error import ErrorGet, ErrorItemType, LogMessageType
18+
from servicelib.rest_constants import RESPONSE_MODEL_POLICY
19+
from servicelib.status_codes_utils import is_5xx_server_error
1720

1821
from ..logging_errors import create_troubleshotting_log_kwargs
1922
from ..mimetype_constants import MIMETYPE_APPLICATION_JSON
2023
from ..rest_responses import is_enveloped_from_map, is_enveloped_from_text
21-
from ..utils import is_production_environ
24+
from ..status_codes_utils import get_code_description
2225
from . import status
2326
from .rest_responses import (
2427
create_data_response,
2528
create_http_error,
2629
safe_status_message,
2730
wrap_as_envelope,
2831
)
29-
from .rest_utils import EnvelopeFactory
3032
from .typing_extension import Handler, Middleware
3133
from .web_exceptions_extension import get_http_error_class_or_none
3234

@@ -45,34 +47,28 @@ def is_api_request(request: web.Request, api_version: str) -> bool:
4547
return bool(request.path.startswith(base_path))
4648

4749

48-
def _handle_unexpected_exception_as_500(
49-
request: web.BaseRequest,
50-
exception: Exception,
51-
*,
52-
skip_internal_error_details: bool,
53-
) -> web.HTTPInternalServerError:
54-
"""Process unexpected exceptions and return them as HTTP errors with proper formatting.
50+
def _create_error_context(
51+
request: web.BaseRequest, exception: Exception
52+
) -> tuple[ErrorCodeStr, dict[str, Any]]:
53+
"""Create error code and context for logging purposes.
5554
56-
IMPORTANT: this function cannot throw exceptions, as it is called
55+
Returns:
56+
Tuple of (error_code, error_context)
5757
"""
5858
error_code = create_error_code(exception)
5959
error_context: dict[str, Any] = {
6060
"request.remote": f"{request.remote}",
6161
"request.method": f"{request.method}",
6262
"request.path": f"{request.path}",
6363
}
64+
return error_code, error_context
6465

65-
user_error_msg = _FMSG_INTERNAL_ERROR_USER_FRIENDLY
66-
67-
http_error = create_http_error(
68-
exception,
69-
user_error_msg,
70-
web.HTTPInternalServerError,
71-
skip_internal_error_details=skip_internal_error_details,
72-
error_code=error_code,
73-
)
7466

75-
error_context["http_error"] = http_error
67+
def _log_5xx_server_error(
68+
request: web.BaseRequest, exception: Exception, user_error_msg: str
69+
) -> ErrorCodeStr:
70+
"""Log 5XX server errors with error code and context."""
71+
error_code, error_context = _create_error_context(request, exception)
7672

7773
_logger.exception(
7874
**create_troubleshotting_log_kwargs(
@@ -82,33 +78,78 @@ def _handle_unexpected_exception_as_500(
8278
error_code=error_code,
8379
)
8480
)
81+
return error_code
82+
83+
84+
def _handle_unexpected_exception_as_500(
85+
request: web.BaseRequest, exception: Exception
86+
) -> web.HTTPInternalServerError:
87+
"""Process unexpected exceptions and return them as HTTP errors with proper formatting.
88+
89+
IMPORTANT: this function cannot throw exceptions, as it is called
90+
"""
91+
error_code, error_context = _create_error_context(request, exception)
92+
user_error_msg = _FMSG_INTERNAL_ERROR_USER_FRIENDLY
93+
94+
error_context["http_error"] = http_error = create_http_error(
95+
exception,
96+
user_error_msg,
97+
web.HTTPInternalServerError,
98+
error_code=error_code,
99+
)
100+
101+
_log_5xx_server_error(request, exception, user_error_msg)
102+
85103
return http_error
86104

87105

88106
def _handle_http_error(
89107
request: web.BaseRequest, exception: web.HTTPError
90108
) -> web.HTTPError:
91-
"""Handle standard HTTP errors by ensuring they're properly formatted."""
109+
"""Handle standard HTTP errors by ensuring they're properly formatted.
110+
111+
NOTE: this needs further refactoring to avoid code duplication
112+
"""
92113
assert request # nosec
114+
assert not exception.empty_body, "HTTPError should not have an empty body" # nosec
115+
93116
exception.content_type = MIMETYPE_APPLICATION_JSON
94117
if exception.reason:
95118
exception.set_status(
96119
exception.status, safe_status_message(message=exception.reason)
97120
)
98121

99122
if not exception.text or not is_enveloped_from_text(exception.text):
100-
error_message = exception.text or exception.reason or "Unexpected error"
123+
# NOTE: aiohttp.HTTPException creates `text = f"{self.status}: {self.reason}"`
124+
user_error_msg = exception.text or "Unexpected error"
125+
126+
error_code: IDStr | None = None
127+
if is_5xx_server_error(exception.status):
128+
error_code = IDStr(
129+
_log_5xx_server_error(request, exception, user_error_msg)
130+
)
131+
101132
error_model = ErrorGet(
102133
errors=[
103-
ErrorItemType.from_error(exception),
134+
ErrorItemType(
135+
code=exception.__class__.__name__,
136+
message=user_error_msg,
137+
resource=None,
138+
field=None,
139+
),
104140
],
105141
status=exception.status,
106142
logs=[
107-
LogMessageType(message=error_message, level="ERROR"),
143+
LogMessageType(message=user_error_msg, level="ERROR"),
108144
],
109-
message=error_message,
145+
message=user_error_msg,
146+
support_id=error_code,
147+
)
148+
exception.text = json_dumps(
149+
wrap_as_envelope(
150+
error=error_model.model_dump(mode="json", **RESPONSE_MODEL_POLICY)
151+
)
110152
)
111-
exception.text = EnvelopeFactory(error=error_model).as_text()
112153

113154
return exception
114155

@@ -136,10 +177,8 @@ def _handle_http_successful(
136177

137178
def _handle_exception_as_http_error(
138179
request: web.Request,
139-
exception: Exception,
180+
exception: NotImplementedError | TimeoutError,
140181
status_code: int,
141-
*,
142-
skip_internal_error_details: bool,
143182
) -> HTTPError:
144183
"""
145184
Generic handler for exceptions that map to specific HTTP status codes.
@@ -154,16 +193,15 @@ def _handle_exception_as_http_error(
154193
)
155194
raise ValueError(msg)
156195

157-
return create_http_error(
158-
exception,
159-
f"{exception}",
160-
http_error_cls,
161-
skip_internal_error_details=skip_internal_error_details,
162-
)
196+
user_error_msg = get_code_description(status_code)
197+
198+
if is_5xx_server_error(status_code):
199+
_log_5xx_server_error(request, exception, user_error_msg)
200+
201+
return create_http_error(exception, user_error_msg, http_error_cls)
163202

164203

165204
def error_middleware_factory(api_version: str) -> Middleware:
166-
_is_prod: bool = is_production_environ()
167205

168206
@web.middleware
169207
async def _middleware_handler(request: web.Request, handler: Handler):
@@ -188,27 +226,19 @@ async def _middleware_handler(request: web.Request, handler: Handler):
188226

189227
except NotImplementedError as exc:
190228
result = _handle_exception_as_http_error(
191-
request,
192-
exc,
193-
status.HTTP_501_NOT_IMPLEMENTED,
194-
skip_internal_error_details=_is_prod,
229+
request, exc, status.HTTP_501_NOT_IMPLEMENTED
195230
)
196231

197232
except TimeoutError as exc:
198233
result = _handle_exception_as_http_error(
199-
request,
200-
exc,
201-
status.HTTP_504_GATEWAY_TIMEOUT,
202-
skip_internal_error_details=_is_prod,
234+
request, exc, status.HTTP_504_GATEWAY_TIMEOUT
203235
)
204236

205237
except Exception as exc: # pylint: disable=broad-except
206238
#
207239
# Last resort for unexpected exceptions (including those raise by the exception handlers!)
208240
#
209-
result = _handle_unexpected_exception_as_500(
210-
request, exc, skip_internal_error_details=_is_prod
211-
)
241+
result = _handle_unexpected_exception_as_500(request, exc)
212242

213243
return result
214244

@@ -229,7 +259,6 @@ def envelope_middleware_factory(
229259
api_version: str,
230260
) -> Callable[..., Awaitable[StreamResponse]]:
231261
# FIXME: This data conversion is very error-prone. Use decorators instead!
232-
_is_prod: bool = is_production_environ()
233262

234263
@web.middleware
235264
async def _middleware_handler(

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ def create_http_error(
8080
] = web.HTTPInternalServerError, # type: ignore[assignment]
8181
*,
8282
status_reason: str | None = None,
83-
skip_internal_error_details: bool = False,
8483
error_code: ErrorCodeStr | None = None,
8584
) -> T_HTTPError:
8685
"""
@@ -99,7 +98,7 @@ def create_http_error(
9998
# changing the workflows in this function
10099

101100
is_internal_error = bool(http_error_cls == web.HTTPInternalServerError)
102-
if is_internal_error and skip_internal_error_details:
101+
if is_internal_error:
103102
error_model = ErrorGet.model_validate(
104103
{
105104
"status": http_error_cls.status_code,

packages/service-library/src/servicelib/logging_errors.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ def _collect_causes(exc: BaseException) -> str:
3939
debug_data = json_dumps(
4040
{
4141
"exception_type": f"{type(error)}",
42-
"exception_details": f"{error}",
42+
"exception_string": f"{error}",
43+
"exception_causes": _collect_causes(error),
4344
"error_code": error_code,
4445
"context": error_context,
45-
"causes": _collect_causes(error),
4646
"tip": tip,
4747
},
4848
default=representation_encoder,

0 commit comments

Comments
 (0)