Skip to content

Commit 074b53e

Browse files
committed
✨ Enhance response handling: streamline data response creation and add safe status message truncation
1 parent 8b02e28 commit 074b53e

File tree

3 files changed

+85
-43
lines changed

3 files changed

+85
-43
lines changed

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,7 @@ async def _middleware_handler(
186186
return resp
187187

188188
if not isinstance(resp, StreamResponse):
189-
resp = create_data_response(
190-
data=resp,
191-
skip_internal_error_details=_is_prod,
192-
)
189+
resp = create_data_response(data=resp)
193190

194191
assert isinstance(resp, web.StreamResponse) # nosec
195192
return resp

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

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

33
from aiohttp import web
44
from aiohttp.web_exceptions import HTTPError
@@ -10,21 +10,65 @@
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
13+
from ..status_codes_utils import get_code_description, is_error
14+
15+
16+
class EnvelopeDict(TypedDict):
17+
data: Any
18+
error: Any
1419

1520

1621
def wrap_as_envelope(
17-
data: Any = None,
18-
error: Any = None,
19-
) -> dict[str, Any]:
22+
data: Any | None = None,
23+
error: Any | None = None,
24+
) -> EnvelopeDict:
2025
return {"data": data, "error": error}
2126

2227

2328
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."""
30+
31+
assert ( # nosec
32+
is_error(status) is False
33+
), f"Expected a non-error status code, got {status=}"
34+
2435
enveloped_payload = wrap_as_envelope(data) if not is_enveloped(data) else data
2536
return web.json_response(enveloped_payload, dumps=json_dumps, status=status)
2637

2738

39+
MAX_STATUS_MESSAGE_LENGTH: Final[int] = 50
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] + "..."
70+
71+
2872
def create_http_error(
2973
errors: list[Exception] | Exception,
3074
error_message: str | None = None,
@@ -45,7 +89,9 @@ def create_http_error(
4589
is_internal_error = bool(http_error_cls == web.HTTPInternalServerError)
4690

4791
status_reason = status_reason or get_code_description(http_error_cls.status_code)
48-
error_message = error_message or status_reason
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
4995

5096
if is_internal_error and skip_internal_error_details:
5197
error = ErrorGet.model_validate(
@@ -90,29 +136,3 @@ def exception_to_response(exc: HTTPError) -> web.Response:
90136
reason=exc.reason,
91137
text=exc.text,
92138
)
93-
94-
95-
def safe_status_message(message: str | None, max_length: int = 50) -> str | None:
96-
"""
97-
Truncates a status-message (i.e. `reason` in HTTP errors) to a maximum length, replacing newlines with spaces.
98-
99-
If the message is longer than max_length, it will be truncated and "..." will be appended.
100-
101-
This prevents issues such as:
102-
- `aiohttp.http_exceptions.LineTooLong`: 400, message: Got more than 8190 bytes when reading Status line is too long.
103-
- Multiline not allowed in HTTP reason attribute (aiohttp now raises ValueError).
104-
105-
See:
106-
- When to use http status and/or text messages https://github.com/ITISFoundation/osparc-simcore/pull/7760
107-
- [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)
108-
- [RFC 9110, Section 15.5: Reason Phrase](https://datatracker.ietf.org/doc/html/rfc9110#section-15.5) (reason phrase definition)
109-
"""
110-
if not message:
111-
return None
112-
113-
flat_message = message.replace("\n", " ")
114-
if len(flat_message) <= max_length:
115-
return flat_message
116-
117-
# Truncate and add ellipsis
118-
return flat_message[: max_length - 3] + "..."

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

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
HTTPOk,
1818
)
1919
from common_library.error_codes import ErrorCodeStr, create_error_code
20-
from servicelib.aiohttp import status
2120
from servicelib.aiohttp.rest_responses import create_http_error, exception_to_response
2221
from servicelib.aiohttp.web_exceptions_extension import (
2322
_STATUS_CODE_TO_HTTP_ERRORS,
@@ -61,18 +60,43 @@ def test_collected_http_errors_map(status_code: int, http_error_cls: type[HTTPEr
6160

6261
@pytest.mark.parametrize("skip_details", [True, False])
6362
@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-
63+
@pytest.mark.parametrize(
64+
"http_error_cls",
65+
[
66+
web.HTTPBadRequest, # 400
67+
web.HTTPUnauthorized, # 401
68+
web.HTTPForbidden, # 403
69+
web.HTTPNotFound, # 404
70+
web.HTTPGone, # 410
71+
web.HTTPInternalServerError, # 500
72+
web.HTTPBadGateway, # 502
73+
web.HTTPServiceUnavailable, # 503
74+
],
75+
ids=[
76+
"400",
77+
"401",
78+
"403",
79+
"404",
80+
"410",
81+
"500",
82+
"502",
83+
"503",
84+
],
85+
)
86+
def tests_exception_to_response(
87+
skip_details: bool, error_code: ErrorCodeStr | None, http_error_cls: type[HTTPError]
88+
):
6689
expected_status_reason = "SHORT REASON"
6790
expected_error_message = "Something whent wrong !"
6891
expected_exceptions: list[Exception] = [RuntimeError("foo")]
6992

7093
http_error = create_http_error(
7194
errors=expected_exceptions,
7295
error_message=expected_error_message,
73-
status_reason="SHORT REASON",
74-
http_error_cls=web.HTTPInternalServerError,
75-
skip_internal_error_details=skip_details,
96+
status_reason=expected_status_reason,
97+
http_error_cls=http_error_cls,
98+
skip_internal_error_details=skip_details
99+
and (http_error_cls == web.HTTPInternalServerError),
76100
error_code=error_code,
77101
)
78102

@@ -89,7 +113,7 @@ def tests_exception_to_response(skip_details: bool, error_code: ErrorCodeStr | N
89113

90114
# checks response components
91115
assert response.content_type == MIMETYPE_APPLICATION_JSON
92-
assert response.status == status.HTTP_500_INTERNAL_SERVER_ERROR
116+
assert response.status == http_error_cls.status_code
93117
assert response.text
94118
assert response.body
95119

@@ -99,6 +123,7 @@ def tests_exception_to_response(skip_details: bool, error_code: ErrorCodeStr | N
99123
assert response_json["error"]["message"] == expected_error_message
100124
assert response_json["error"]["supportId"] == error_code
101125
assert response_json["error"]["status"] == response.status
126+
assert response.reason == expected_status_reason
102127

103128

104129
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)