Skip to content

Commit a728780

Browse files
authored
🎨♻️ Enhances web-server's error middle-ware for safe status-line and refactors aiohttp response helpers (#7770)
1 parent dc35a5e commit a728780

File tree

4 files changed

+126
-98
lines changed

4 files changed

+126
-98
lines changed

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@
1818
from ..mimetype_constants import MIMETYPE_APPLICATION_JSON
1919
from ..rest_responses import is_enveloped_from_map, is_enveloped_from_text
2020
from ..utils import is_production_environ
21-
from .rest_responses import create_data_response, create_http_error, wrap_as_envelope
21+
from .rest_responses import (
22+
create_data_response,
23+
create_http_error,
24+
safe_status_message,
25+
wrap_as_envelope,
26+
)
2227
from .rest_utils import EnvelopeFactory
2328
from .typing_extension import Handler, Middleware
2429

@@ -83,6 +88,8 @@ async def _middleware_handler(request: web.Request, handler: Handler): # noqa:
8388
except web.HTTPError as err:
8489

8590
err.content_type = MIMETYPE_APPLICATION_JSON
91+
if err.reason:
92+
err.set_status(err.status, safe_status_message(message=err.reason))
8693

8794
if not err.text or not is_enveloped_from_text(err.text):
8895
error_message = err.text or err.reason or "Unexpected error"
@@ -102,6 +109,9 @@ async def _middleware_handler(request: web.Request, handler: Handler): # noqa:
102109

103110
except web.HTTPSuccessful as err:
104111
err.content_type = MIMETYPE_APPLICATION_JSON
112+
if err.reason:
113+
err.set_status(err.status, safe_status_message(message=err.reason))
114+
105115
if err.text:
106116
try:
107117
payload = json_loads(err.text)
@@ -176,10 +186,7 @@ async def _middleware_handler(
176186
return resp
177187

178188
if not isinstance(resp, StreamResponse):
179-
resp = create_data_response(
180-
data=resp,
181-
skip_internal_error_details=_is_prod,
182-
)
189+
resp = create_data_response(data=resp)
183190

184191
assert isinstance(resp, web.StreamResponse) # nosec
185192
return resp
Lines changed: 62 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
"""Utils to check, convert and compose server responses for the RESTApi"""
1+
from typing import Any, Final, TypedDict
22

3-
import inspect
4-
from typing import Any
5-
6-
from aiohttp import web, web_exceptions
7-
from aiohttp.web_exceptions import HTTPError, HTTPException
3+
from aiohttp import web
4+
from aiohttp.web_exceptions import HTTPError
85
from common_library.error_codes import ErrorCodeStr
96
from common_library.json_serialization import json_dumps
107
from models_library.rest_error import ErrorGet, ErrorItemType
@@ -13,46 +10,71 @@
1310
from ..mimetype_constants import MIMETYPE_APPLICATION_JSON
1411
from ..rest_constants import RESPONSE_MODEL_POLICY
1512
from ..rest_responses import is_enveloped
16-
from ..status_codes_utils import get_code_description
13+
from ..status_codes_utils import get_code_description, is_error
14+
15+
16+
class EnvelopeDict(TypedDict):
17+
data: Any
18+
error: Any
1719

1820

1921
def wrap_as_envelope(
20-
data: Any = None,
21-
error: Any = None,
22-
) -> dict[str, Any]:
22+
data: Any | None = None,
23+
error: Any | None = None,
24+
) -> EnvelopeDict:
2325
return {"data": data, "error": error}
2426

2527

26-
# RESPONSES FACTORIES -------------------------------
28+
def create_data_response(data: Any, *, status: int = HTTP_200_OK) -> web.Response:
29+
"""Creates a JSON response with the given data and ensures it is wrapped in an envelope."""
2730

31+
assert ( # nosec
32+
is_error(status) is False
33+
), f"Expected a non-error status code, got {status=}"
2834

29-
def create_data_response(
30-
data: Any, *, skip_internal_error_details=False, status=HTTP_200_OK
31-
) -> web.Response:
32-
response = None
33-
try:
34-
payload = wrap_as_envelope(data) if not is_enveloped(data) else data
35+
enveloped_payload = wrap_as_envelope(data) if not is_enveloped(data) else data
36+
return web.json_response(enveloped_payload, dumps=json_dumps, status=status)
3537

36-
response = web.json_response(payload, dumps=json_dumps, status=status)
37-
except (TypeError, ValueError) as err:
38-
response = exception_to_response(
39-
create_http_error(
40-
[
41-
err,
42-
],
43-
str(err),
44-
web.HTTPInternalServerError,
45-
skip_internal_error_details=skip_internal_error_details,
46-
)
47-
)
48-
return response
38+
39+
MAX_STATUS_MESSAGE_LENGTH: Final[int] = 100
40+
41+
42+
def safe_status_message(
43+
message: str | None, max_length: int = MAX_STATUS_MESSAGE_LENGTH
44+
) -> str | None:
45+
"""
46+
Truncates a status-message (i.e. `reason` in HTTP errors) to a maximum length, replacing newlines with spaces.
47+
48+
If the message is longer than max_length, it will be truncated and "..." will be appended.
49+
50+
This prevents issues such as:
51+
- `aiohttp.http_exceptions.LineTooLong`: 400, message: Got more than 8190 bytes when reading Status line is too long.
52+
- Multiline not allowed in HTTP reason attribute (aiohttp now raises ValueError).
53+
54+
See:
55+
- When to use http status and/or text messages https://github.com/ITISFoundation/osparc-simcore/pull/7760
56+
- [RFC 9112, Section 4.1: HTTP/1.1 Message Syntax and Routing](https://datatracker.ietf.org/doc/html/rfc9112#section-4.1) (status line length limits)
57+
- [RFC 9110, Section 15.5: Reason Phrase](https://datatracker.ietf.org/doc/html/rfc9110#section-15.5) (reason phrase definition)
58+
"""
59+
assert max_length > 0 # nosec
60+
61+
if not message:
62+
return None
63+
64+
flat_message = message.replace("\n", " ")
65+
if len(flat_message) <= max_length:
66+
return flat_message
67+
68+
# Truncate and add ellipsis
69+
return flat_message[: max_length - 3] + "..."
4970

5071

5172
def create_http_error(
5273
errors: list[Exception] | Exception,
53-
reason: str | None = None,
74+
error_message: str | None = None,
5475
http_error_cls: type[HTTPError] = web.HTTPInternalServerError,
5576
*,
77+
status_reason: str | None = None,
5678
skip_internal_error_details: bool = False,
5779
error_code: ErrorCodeStr | None = None,
5880
) -> HTTPError:
@@ -64,14 +86,18 @@ def create_http_error(
6486
if not isinstance(errors, list):
6587
errors = [errors]
6688

67-
is_internal_error: bool = http_error_cls == web.HTTPInternalServerError
68-
default_message = reason or get_code_description(http_error_cls.status_code)
89+
is_internal_error = bool(http_error_cls == web.HTTPInternalServerError)
90+
91+
status_reason = status_reason or get_code_description(http_error_cls.status_code)
92+
error_message = error_message or get_code_description(http_error_cls.status_code)
93+
94+
assert len(status_reason) < MAX_STATUS_MESSAGE_LENGTH # nosec
6995

7096
if is_internal_error and skip_internal_error_details:
7197
error = ErrorGet.model_validate(
7298
{
7399
"status": http_error_cls.status_code,
74-
"message": default_message,
100+
"message": error_message,
75101
"support_id": error_code,
76102
}
77103
)
@@ -81,7 +107,7 @@ def create_http_error(
81107
{
82108
"errors": items, # NOTE: deprecated!
83109
"status": http_error_cls.status_code,
84-
"message": default_message,
110+
"message": error_message,
85111
"support_id": error_code,
86112
}
87113
)
@@ -92,7 +118,7 @@ def create_http_error(
92118
)
93119

94120
return http_error_cls(
95-
reason=safe_status_message(reason),
121+
reason=safe_status_message(status_reason),
96122
text=json_dumps(
97123
payload,
98124
),
@@ -110,47 +136,3 @@ def exception_to_response(exc: HTTPError) -> web.Response:
110136
reason=exc.reason,
111137
text=exc.text,
112138
)
113-
114-
115-
# Inverse map from code to HTTPException classes
116-
def _collect_http_exceptions(exception_cls: type[HTTPException] = HTTPException):
117-
def _pred(obj) -> bool:
118-
return (
119-
inspect.isclass(obj)
120-
and issubclass(obj, exception_cls)
121-
and getattr(obj, "status_code", 0) > 0
122-
)
123-
124-
found: list[tuple[str, Any]] = inspect.getmembers(web_exceptions, _pred)
125-
assert found # nosec
126-
127-
http_statuses = {cls.status_code: cls for _, cls in found}
128-
assert len(http_statuses) == len(found), "No duplicates" # nosec
129-
130-
return http_statuses
131-
132-
133-
def safe_status_message(message: str | None, max_length: int = 50) -> str | None:
134-
"""
135-
Truncates a status-message (i.e. `reason` in HTTP errors) to a maximum length, replacing newlines with spaces.
136-
137-
If the message is longer than max_length, it will be truncated and "..." will be appended.
138-
139-
This prevents issues such as:
140-
- `aiohttp.http_exceptions.LineTooLong`: 400, message: Got more than 8190 bytes when reading Status line is too long.
141-
- Multiline not allowed in HTTP reason attribute (aiohttp now raises ValueError).
142-
143-
See:
144-
- When to use http status and/or text messages https://github.com/ITISFoundation/osparc-simcore/pull/7760
145-
- [RFC 9112, Section 4.1: HTTP/1.1 Message Syntax and Routing](https://datatracker.ietf.org/doc/html/rfc9112#section-4.1) (status line length limits)
146-
- [RFC 9110, Section 15.5: Reason Phrase](https://datatracker.ietf.org/doc/html/rfc9110#section-15.5) (reason phrase definition)
147-
"""
148-
if not message:
149-
return None
150-
151-
flat_message = message.replace("\n", " ")
152-
if len(flat_message) <= max_length:
153-
return flat_message
154-
155-
# Truncate and add ellipsis
156-
return flat_message[: max_length - 3] + "..."

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

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@
1717
HTTPOk,
1818
)
1919
from common_library.error_codes import ErrorCodeStr, create_error_code
20-
from servicelib.aiohttp import status
21-
from servicelib.aiohttp.rest_responses import create_http_error, exception_to_response
20+
from servicelib.aiohttp.rest_responses import (
21+
MAX_STATUS_MESSAGE_LENGTH,
22+
create_http_error,
23+
exception_to_response,
24+
)
2225
from servicelib.aiohttp.web_exceptions_extension import (
2326
_STATUS_CODE_TO_HTTP_ERRORS,
2427
get_http_error_class_or_none,
@@ -61,16 +64,43 @@ def test_collected_http_errors_map(status_code: int, http_error_cls: type[HTTPEr
6164

6265
@pytest.mark.parametrize("skip_details", [True, False])
6366
@pytest.mark.parametrize("error_code", [None, create_error_code(Exception("fake"))])
64-
def tests_exception_to_response(skip_details: bool, error_code: ErrorCodeStr | None):
65-
66-
expected_reason = "Something whent wrong !"
67+
@pytest.mark.parametrize(
68+
"http_error_cls",
69+
[
70+
web.HTTPBadRequest, # 400
71+
web.HTTPUnauthorized, # 401
72+
web.HTTPForbidden, # 403
73+
web.HTTPNotFound, # 404
74+
web.HTTPGone, # 410
75+
web.HTTPInternalServerError, # 500
76+
web.HTTPBadGateway, # 502
77+
web.HTTPServiceUnavailable, # 503
78+
],
79+
ids=[
80+
"400",
81+
"401",
82+
"403",
83+
"404",
84+
"410",
85+
"500",
86+
"502",
87+
"503",
88+
],
89+
)
90+
def tests_exception_to_response(
91+
skip_details: bool, error_code: ErrorCodeStr | None, http_error_cls: type[HTTPError]
92+
):
93+
expected_status_reason = "SHORT REASON"
94+
expected_error_message = "Something whent wrong !"
6795
expected_exceptions: list[Exception] = [RuntimeError("foo")]
6896

6997
http_error = create_http_error(
7098
errors=expected_exceptions,
71-
reason=expected_reason,
72-
http_error_cls=web.HTTPInternalServerError,
73-
skip_internal_error_details=skip_details,
99+
error_message=expected_error_message,
100+
status_reason=expected_status_reason,
101+
http_error_cls=http_error_cls,
102+
skip_internal_error_details=skip_details
103+
and (http_error_cls == web.HTTPInternalServerError),
74104
error_code=error_code,
75105
)
76106

@@ -87,16 +117,17 @@ def tests_exception_to_response(skip_details: bool, error_code: ErrorCodeStr | N
87117

88118
# checks response components
89119
assert response.content_type == MIMETYPE_APPLICATION_JSON
90-
assert response.status == status.HTTP_500_INTERNAL_SERVER_ERROR
120+
assert response.status == http_error_cls.status_code
91121
assert response.text
92122
assert response.body
93123

94124
# checks response model
95125
response_json = json.loads(response.text)
96126
assert response_json["data"] is None
97-
assert response_json["error"]["message"] == expected_reason
127+
assert response_json["error"]["message"] == expected_error_message
98128
assert response_json["error"]["supportId"] == error_code
99129
assert response_json["error"]["status"] == response.status
130+
assert response.reason == expected_status_reason
100131

101132

102133
@pytest.mark.parametrize(
@@ -109,10 +140,13 @@ def tests_exception_to_response(skip_details: bool, error_code: ErrorCodeStr | N
109140
"Message\nwith\nnewlines",
110141
"Message with newlines",
111142
), # Newlines are replaced with spaces
112-
("A" * 100, "A" * 47 + "..."), # Long message gets truncated with ellipsis
113143
(
114-
"Line1\nLine2\nLine3" + "X" * 100,
115-
"Line1 Line2 Line3" + "X" * 30 + "...",
144+
"A" * (MAX_STATUS_MESSAGE_LENGTH + 1),
145+
"A" * (MAX_STATUS_MESSAGE_LENGTH - 3) + "...",
146+
), # Long message gets truncated with ellipsis
147+
(
148+
"Line1\nLine2\nLine3" + "X" * (MAX_STATUS_MESSAGE_LENGTH + 1),
149+
"Line1 Line2 Line3" + "X" * (MAX_STATUS_MESSAGE_LENGTH - 20) + "...",
116150
), # Combined case: newlines and truncation with ellipsis
117151
],
118152
ids=[

services/web/server/src/simcore_service_webserver/director_v2/_controller/_rest_exceptions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@
3030
async def _handler_director_service_error_as_503_or_4xx(
3131
request: web.Request, exception: Exception
3232
) -> web.Response:
33+
"""
34+
Handles DirectorV2ServiceError exceptions by returning:
35+
- HTTP 503 Service Unavailable if the underlying director-v2 service returns a 5XX server error,
36+
- or the original 4XX client error status and message if it is a client error.
37+
"""
3338
assert isinstance(exception, DirectorV2ServiceError) # nosec
3439
assert status_codes_utils.is_error(
3540
exception.status

0 commit comments

Comments
 (0)