Skip to content

Commit 9705882

Browse files
authored
♻️🐛Refactors webserver's errors middleware to handle LineTooLong exceptions (#7878)
1 parent a5e90f2 commit 9705882

File tree

3 files changed

+283
-101
lines changed

3 files changed

+283
-101
lines changed

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

Lines changed: 151 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import Any
99

1010
from aiohttp import web
11+
from aiohttp.web_exceptions import HTTPError
1112
from aiohttp.web_request import Request
1213
from aiohttp.web_response import StreamResponse
1314
from common_library.error_codes import create_error_code
@@ -18,6 +19,7 @@
1819
from ..mimetype_constants import MIMETYPE_APPLICATION_JSON
1920
from ..rest_responses import is_enveloped_from_map, is_enveloped_from_text
2021
from ..utils import is_production_environ
22+
from . import status
2123
from .rest_responses import (
2224
create_data_response,
2325
create_http_error,
@@ -26,6 +28,7 @@
2628
)
2729
from .rest_utils import EnvelopeFactory
2830
from .typing_extension import Handler, Middleware
31+
from .web_exceptions_extension import get_http_error_class_or_none
2932

3033
DEFAULT_API_VERSION = "v0"
3134
_FMSG_INTERNAL_ERROR_USER_FRIENDLY = (
@@ -42,110 +45,172 @@ def is_api_request(request: web.Request, api_version: str) -> bool:
4245
return bool(request.path.startswith(base_path))
4346

4447

45-
def error_middleware_factory( # noqa: C901
46-
api_version: str,
47-
) -> Middleware:
48-
_is_prod: bool = is_production_environ()
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.
55+
56+
IMPORTANT: this function cannot throw exceptions, as it is called
57+
"""
58+
error_code = create_error_code(exception)
59+
error_context: dict[str, Any] = {
60+
"request.remote": f"{request.remote}",
61+
"request.method": f"{request.method}",
62+
"request.path": f"{request.path}",
63+
}
64+
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+
)
74+
75+
error_context["http_error"] = http_error
4976

50-
def _process_and_raise_unexpected_error(request: web.BaseRequest, err: Exception):
51-
error_code = create_error_code(err)
52-
error_context: dict[str, Any] = {
53-
"request.remote": f"{request.remote}",
54-
"request.method": f"{request.method}",
55-
"request.path": f"{request.path}",
56-
}
57-
58-
user_error_msg = _FMSG_INTERNAL_ERROR_USER_FRIENDLY
59-
http_error = create_http_error(
60-
err,
77+
_logger.exception(
78+
**create_troubleshotting_log_kwargs(
6179
user_error_msg,
62-
web.HTTPInternalServerError,
63-
skip_internal_error_details=_is_prod,
80+
error=exception,
81+
error_context=error_context,
6482
error_code=error_code,
6583
)
66-
_logger.exception(
67-
**create_troubleshotting_log_kwargs(
68-
user_error_msg,
69-
error=err,
70-
error_context=error_context,
71-
error_code=error_code,
72-
)
84+
)
85+
return http_error
86+
87+
88+
def _handle_http_error(
89+
request: web.BaseRequest, exception: web.HTTPError
90+
) -> web.HTTPError:
91+
"""Handle standard HTTP errors by ensuring they're properly formatted."""
92+
assert request # nosec
93+
exception.content_type = MIMETYPE_APPLICATION_JSON
94+
if exception.reason:
95+
exception.set_status(
96+
exception.status, safe_status_message(message=exception.reason)
97+
)
98+
99+
if not exception.text or not is_enveloped_from_text(exception.text):
100+
error_message = exception.text or exception.reason or "Unexpected error"
101+
error_model = ErrorGet(
102+
errors=[
103+
ErrorItemType.from_error(exception),
104+
],
105+
status=exception.status,
106+
logs=[
107+
LogMessageType(message=error_message, level="ERROR"),
108+
],
109+
message=error_message,
110+
)
111+
exception.text = EnvelopeFactory(error=error_model).as_text()
112+
113+
return exception
114+
115+
116+
def _handle_http_successful(
117+
request: web.Request, exception: web.HTTPSuccessful
118+
) -> web.HTTPSuccessful:
119+
"""Handle successful HTTP responses, ensuring they're properly enveloped."""
120+
assert request # nosec
121+
122+
exception.content_type = MIMETYPE_APPLICATION_JSON
123+
if exception.reason:
124+
exception.set_status(
125+
exception.status, safe_status_message(message=exception.reason)
126+
)
127+
128+
if exception.text:
129+
payload = json_loads(exception.text)
130+
if not is_enveloped_from_map(payload):
131+
payload = wrap_as_envelope(data=payload)
132+
exception.text = json_dumps(payload)
133+
134+
return exception
135+
136+
137+
def _handle_exception_as_http_error(
138+
request: web.Request,
139+
exception: Exception,
140+
status_code: int,
141+
*,
142+
skip_internal_error_details: bool,
143+
) -> HTTPError:
144+
"""
145+
Generic handler for exceptions that map to specific HTTP status codes.
146+
Converts the status code to the appropriate HTTP error class and creates a response.
147+
"""
148+
assert request # nosec
149+
150+
http_error_cls = get_http_error_class_or_none(status_code)
151+
if http_error_cls is None:
152+
msg = (
153+
f"No HTTP error class found for status code {status_code}, falling back to 500",
73154
)
74-
raise http_error
155+
raise ValueError(msg)
156+
157+
return create_http_error(
158+
exception,
159+
f"{exception}",
160+
http_error_cls,
161+
skip_internal_error_details=skip_internal_error_details,
162+
)
163+
164+
165+
def error_middleware_factory(api_version: str) -> Middleware:
166+
_is_prod: bool = is_production_environ()
75167

76168
@web.middleware
77-
async def _middleware_handler(request: web.Request, handler: Handler): # noqa: C901
169+
async def _middleware_handler(request: web.Request, handler: Handler):
78170
"""
79171
Ensure all error raised are properly enveloped and json responses
80172
"""
81173
if not is_api_request(request, api_version):
82174
return await handler(request)
83175

84-
# FIXME: review when to send info to client and when not!
85176
try:
86-
return await handler(request)
177+
try:
178+
result = await handler(request)
87179

88-
except web.HTTPError as err:
89-
90-
err.content_type = MIMETYPE_APPLICATION_JSON
91-
if err.reason:
92-
err.set_status(err.status, safe_status_message(message=err.reason))
93-
94-
if not err.text or not is_enveloped_from_text(err.text):
95-
error_message = err.text or err.reason or "Unexpected error"
96-
error_model = ErrorGet(
97-
errors=[
98-
ErrorItemType.from_error(err),
99-
],
100-
status=err.status,
101-
logs=[
102-
LogMessageType(message=error_message, level="ERROR"),
103-
],
104-
message=error_message,
180+
except web.HTTPError as exc: # 4XX and 5XX raised as exceptions
181+
result = _handle_http_error(request, exc)
182+
183+
except web.HTTPSuccessful as exc: # 2XX rased as exceptions
184+
result = _handle_http_successful(request, exc)
185+
186+
except web.HTTPRedirection as exc: # 3XX raised as exceptions
187+
result = exc
188+
189+
except NotImplementedError as exc:
190+
result = _handle_exception_as_http_error(
191+
request,
192+
exc,
193+
status.HTTP_501_NOT_IMPLEMENTED,
194+
skip_internal_error_details=_is_prod,
105195
)
106-
err.text = EnvelopeFactory(error=error_model).as_text()
107-
108-
raise
109-
110-
except web.HTTPSuccessful as err:
111-
err.content_type = MIMETYPE_APPLICATION_JSON
112-
if err.reason:
113-
err.set_status(err.status, safe_status_message(message=err.reason))
114-
115-
if err.text:
116-
try:
117-
payload = json_loads(err.text)
118-
if not is_enveloped_from_map(payload):
119-
payload = wrap_as_envelope(data=payload)
120-
err.text = json_dumps(payload)
121-
except Exception as other_error: # pylint: disable=broad-except
122-
_process_and_raise_unexpected_error(request, other_error)
123-
raise
124-
125-
except web.HTTPRedirection as err:
126-
_logger.debug("Redirected to %s", err)
127-
raise
128-
129-
except NotImplementedError as err:
130-
http_error = create_http_error(
131-
err,
132-
f"{err}",
133-
web.HTTPNotImplemented,
134-
skip_internal_error_details=_is_prod,
135-
)
136-
raise http_error from err
137-
138-
except TimeoutError as err:
139-
http_error = create_http_error(
140-
err,
141-
f"{err}",
142-
web.HTTPGatewayTimeout,
143-
skip_internal_error_details=_is_prod,
196+
197+
except TimeoutError as exc:
198+
result = _handle_exception_as_http_error(
199+
request,
200+
exc,
201+
status.HTTP_504_GATEWAY_TIMEOUT,
202+
skip_internal_error_details=_is_prod,
203+
)
204+
205+
except Exception as exc: # pylint: disable=broad-except
206+
#
207+
# Last resort for unexpected exceptions (including those raise by the exception handlers!)
208+
#
209+
result = _handle_unexpected_exception_as_500(
210+
request, exc, skip_internal_error_details=_is_prod
144211
)
145-
raise http_error from err
146212

147-
except Exception as err: # pylint: disable=broad-except
148-
_process_and_raise_unexpected_error(request, err)
213+
return result
149214

150215
# adds identifier (mostly for debugging)
151216
setattr( # noqa: B010

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

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Final, TypedDict
1+
from typing import Any, Final, TypedDict, TypeVar
22

33
from aiohttp import web
44
from aiohttp.web_exceptions import HTTPError
@@ -10,7 +10,7 @@
1010
from ..mimetype_constants import MIMETYPE_APPLICATION_JSON
1111
from ..rest_constants import RESPONSE_MODEL_POLICY
1212
from ..rest_responses import is_enveloped
13-
from ..status_codes_utils import get_code_description, is_error
13+
from ..status_codes_utils import get_code_description, get_code_display_name, is_error
1414

1515

1616
class EnvelopeDict(TypedDict):
@@ -69,41 +69,50 @@ def safe_status_message(
6969
return flat_message[: max_length - 3] + "..."
7070

7171

72+
T_HTTPError = TypeVar("T_HTTPError", bound=HTTPError)
73+
74+
7275
def create_http_error(
7376
errors: list[Exception] | Exception,
7477
error_message: str | None = None,
75-
http_error_cls: type[HTTPError] = web.HTTPInternalServerError,
78+
http_error_cls: type[
79+
T_HTTPError
80+
] = web.HTTPInternalServerError, # type: ignore[assignment]
7681
*,
7782
status_reason: str | None = None,
7883
skip_internal_error_details: bool = False,
7984
error_code: ErrorCodeStr | None = None,
80-
) -> HTTPError:
85+
) -> T_HTTPError:
8186
"""
8287
- Response body conforms OAS schema model
8388
- Can skip internal details when 500 status e.g. to avoid transmitting server
8489
exceptions to the client in production
8590
"""
86-
if not isinstance(errors, list):
87-
errors = [errors]
88-
89-
is_internal_error = bool(http_error_cls == web.HTTPInternalServerError)
9091

91-
status_reason = status_reason or get_code_description(http_error_cls.status_code)
92+
status_reason = status_reason or get_code_display_name(http_error_cls.status_code)
9293
error_message = error_message or get_code_description(http_error_cls.status_code)
9394

9495
assert len(status_reason) < MAX_STATUS_MESSAGE_LENGTH # nosec
9596

97+
# WARNING: do not refactor too much this function withouth considering how
98+
# front-end handle errors. i.e. please sync with front-end developers before
99+
# changing the workflows in this function
100+
101+
is_internal_error = bool(http_error_cls == web.HTTPInternalServerError)
96102
if is_internal_error and skip_internal_error_details:
97-
error = ErrorGet.model_validate(
103+
error_model = ErrorGet.model_validate(
98104
{
99105
"status": http_error_cls.status_code,
100106
"message": error_message,
101107
"support_id": error_code,
102108
}
103109
)
104110
else:
111+
if not isinstance(errors, list):
112+
errors = [errors]
113+
105114
items = [ErrorItemType.from_error(err) for err in errors]
106-
error = ErrorGet.model_validate(
115+
error_model = ErrorGet.model_validate(
107116
{
108117
"errors": items, # NOTE: deprecated!
109118
"status": http_error_cls.status_code,
@@ -113,15 +122,14 @@ def create_http_error(
113122
)
114123

115124
assert not http_error_cls.empty_body # nosec
125+
116126
payload = wrap_as_envelope(
117-
error=error.model_dump(mode="json", **RESPONSE_MODEL_POLICY)
127+
error=error_model.model_dump(mode="json", **RESPONSE_MODEL_POLICY)
118128
)
119129

120130
return http_error_cls(
121131
reason=safe_status_message(status_reason),
122-
text=json_dumps(
123-
payload,
124-
),
132+
text=json_dumps(payload),
125133
content_type=MIMETYPE_APPLICATION_JSON,
126134
)
127135

0 commit comments

Comments
 (0)