Skip to content

Commit 8116821

Browse files
authored
🐛 web-api: Fixes handling of unexpected errors (#7939)
1 parent c1d1065 commit 8116821

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,23 +11,25 @@
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
1616
from common_library.user_messages import user_message
17+
from models_library.basic_types import IDStr
1718
from models_library.rest_error import ErrorGet, ErrorItemType, LogMessageType
19+
from servicelib.rest_constants import RESPONSE_MODEL_POLICY
20+
from servicelib.status_codes_utils import is_5xx_server_error
1821

1922
from ..logging_errors import create_troubleshotting_log_kwargs
2023
from ..mimetype_constants import MIMETYPE_APPLICATION_JSON
2124
from ..rest_responses import is_enveloped_from_map, is_enveloped_from_text
22-
from ..utils import is_production_environ
25+
from ..status_codes_utils import get_code_description
2326
from . import status
2427
from .rest_responses import (
2528
create_data_response,
2629
create_http_error,
2730
safe_status_message,
2831
wrap_as_envelope,
2932
)
30-
from .rest_utils import EnvelopeFactory
3133
from .typing_extension import Handler, Middleware
3234
from .web_exceptions_extension import get_http_error_class_or_none
3335

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

4850

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

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

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

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

88106

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

100123
if not exception.text or not is_enveloped_from_text(exception.text):
101-
error_message = exception.text or exception.reason or "Unexpected error"
124+
# NOTE: aiohttp.HTTPException creates `text = f"{self.status}: {self.reason}"`
125+
user_error_msg = exception.text or "Unexpected error"
126+
127+
error_code: IDStr | None = None
128+
if is_5xx_server_error(exception.status):
129+
error_code = IDStr(
130+
_log_5xx_server_error(request, exception, user_error_msg)
131+
)
132+
102133
error_model = ErrorGet(
103134
errors=[
104-
ErrorItemType.from_error(exception),
135+
ErrorItemType(
136+
code=exception.__class__.__name__,
137+
message=user_error_msg,
138+
resource=None,
139+
field=None,
140+
),
105141
],
106142
status=exception.status,
107143
logs=[
108-
LogMessageType(message=error_message, level="ERROR"),
144+
LogMessageType(message=user_error_msg, level="ERROR"),
109145
],
110-
message=error_message,
146+
message=user_error_msg,
147+
support_id=error_code,
148+
)
149+
exception.text = json_dumps(
150+
wrap_as_envelope(
151+
error=error_model.model_dump(mode="json", **RESPONSE_MODEL_POLICY)
152+
)
111153
)
112-
exception.text = EnvelopeFactory(error=error_model).as_text()
113154

114155
return exception
115156

@@ -137,10 +178,8 @@ def _handle_http_successful(
137178

138179
def _handle_exception_as_http_error(
139180
request: web.Request,
140-
exception: Exception,
181+
exception: NotImplementedError | TimeoutError,
141182
status_code: int,
142-
*,
143-
skip_internal_error_details: bool,
144183
) -> HTTPError:
145184
"""
146185
Generic handler for exceptions that map to specific HTTP status codes.
@@ -155,16 +194,15 @@ def _handle_exception_as_http_error(
155194
)
156195
raise ValueError(msg)
157196

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

165204

166205
def error_middleware_factory(api_version: str) -> Middleware:
167-
_is_prod: bool = is_production_environ()
168206

169207
@web.middleware
170208
async def _middleware_handler(request: web.Request, handler: Handler):
@@ -189,27 +227,19 @@ async def _middleware_handler(request: web.Request, handler: Handler):
189227

190228
except NotImplementedError as exc:
191229
result = _handle_exception_as_http_error(
192-
request,
193-
exc,
194-
status.HTTP_501_NOT_IMPLEMENTED,
195-
skip_internal_error_details=_is_prod,
230+
request, exc, status.HTTP_501_NOT_IMPLEMENTED
196231
)
197232

198233
except TimeoutError as exc:
199234
result = _handle_exception_as_http_error(
200-
request,
201-
exc,
202-
status.HTTP_504_GATEWAY_TIMEOUT,
203-
skip_internal_error_details=_is_prod,
235+
request, exc, status.HTTP_504_GATEWAY_TIMEOUT
204236
)
205237

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

214244
return result
215245

@@ -230,7 +260,6 @@ def envelope_middleware_factory(
230260
api_version: str,
231261
) -> Callable[..., Awaitable[StreamResponse]]:
232262
# FIXME: This data conversion is very error-prone. Use decorators instead!
233-
_is_prod: bool = is_production_environ()
234263

235264
@web.middleware
236265
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)