Skip to content

Commit 0180753

Browse files
authored
♻️ aiohttp deprecation: Using web.json_response to return 2XX responses instead of raising HttpException (#6563)
1 parent edc5359 commit 0180753

34 files changed

+212
-125
lines changed

packages/pytest-simcore/src/pytest_simcore/helpers/assert_checks.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77
from aiohttp import ClientResponse
88
from servicelib.aiohttp import status
99
from servicelib.aiohttp.rest_responses import unwrap_envelope
10-
from servicelib.status_utils import get_code_display_name, is_error
10+
from servicelib.status_codes_utils import get_code_display_name, is_error
1111

1212

1313
async def assert_status(
1414
response: ClientResponse,
1515
expected_status_code: int,
1616
expected_msg: str | None = None,
1717
expected_error_code: str | None = None,
18+
*,
1819
include_meta: bool | None = False,
1920
include_links: bool | None = False,
2021
) -> tuple[dict, ...]:

packages/pytest-simcore/src/pytest_simcore/helpers/webserver_parametrizations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from unittest import mock
33

44
from servicelib.aiohttp import status
5-
from servicelib.status_utils import get_code_display_name
5+
from servicelib.status_codes_utils import get_code_display_name
66
from simcore_postgres_database.models.users import UserRole
77

88

packages/service-library/src/servicelib/aiohttp/long_running_tasks/_routes.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
from aiohttp import web
55
from models_library.utils.json_serialization import json_dumps
66
from pydantic import BaseModel
7+
from servicelib.aiohttp import status
78

89
from ...long_running_tasks._errors import TaskNotCompletedError, TaskNotFoundError
910
from ...long_running_tasks._models import TaskGet, TaskId, TaskStatus
1011
from ...long_running_tasks._task import TrackedTask
11-
from ...mimetype_constants import MIMETYPE_APPLICATION_JSON
1212
from ..requests_validation import parse_request_path_parameters_as
1313
from ._dependencies import get_task_context, get_tasks_manager
1414

@@ -89,7 +89,7 @@ async def cancel_and_delete_task(request: web.Request) -> web.Response:
8989
tasks_manager = get_tasks_manager(request.app)
9090
task_context = get_task_context(request)
9191
await tasks_manager.remove_task(path_params.task_id, with_task_context=task_context)
92-
raise web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON)
92+
return web.json_response(status=status.HTTP_204_NO_CONTENT)
9393

9494

9595
__all__: tuple[str, ...] = (

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

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from servicelib.aiohttp.status import HTTP_200_OK
1414

1515
from ..mimetype_constants import MIMETYPE_APPLICATION_JSON
16-
from ..status_utils import get_code_description
16+
from ..status_codes_utils import get_code_description
1717
from .rest_models import ErrorItemType, ErrorType
1818

1919
_ENVELOPE_KEYS = ("data", "error")
@@ -151,16 +151,3 @@ def _pred(obj) -> bool:
151151
assert len(http_statuses) == len(found), "No duplicates" # nosec
152152

153153
return http_statuses
154-
155-
156-
_STATUS_CODE_TO_HTTP_ERRORS: dict[int, type[HTTPError]] = _collect_http_exceptions(
157-
HTTPError
158-
)
159-
160-
161-
def get_http_error(status_code: int) -> type[HTTPError] | None:
162-
"""Returns aiohttp error class corresponding to a 4XX or 5XX status code
163-
164-
NOTICE that any non-error code (i.e. 2XX, 3XX and 4XX) will return None
165-
"""
166-
return _STATUS_CODE_TO_HTTP_ERRORS.get(status_code)
Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,85 @@
1-
from aiohttp.web_exceptions import HTTPClientError
1+
""" Extends `aiohttp.web_exceptions` classes to match `status` codes
2+
and adds helper functions.
3+
"""
4+
5+
import inspect
6+
from typing import Any, TypeVar
7+
8+
from aiohttp import web_exceptions
9+
from aiohttp.web_exceptions import (
10+
HTTPClientError,
11+
HTTPError,
12+
HTTPException,
13+
HTTPServerError,
14+
)
215

316
from . import status
417

18+
assert issubclass(HTTPError, HTTPException) # nsoec
19+
20+
# NOTE: these are the status codes that DO NOT have an aiohttp.HTTPException associated
21+
STATUS_CODES_WITHOUT_AIOHTTP_EXCEPTION_CLASS = (
22+
status.HTTP_100_CONTINUE,
23+
status.HTTP_101_SWITCHING_PROTOCOLS,
24+
status.HTTP_102_PROCESSING,
25+
status.HTTP_103_EARLY_HINTS,
26+
status.HTTP_207_MULTI_STATUS,
27+
status.HTTP_208_ALREADY_REPORTED,
28+
status.HTTP_226_IM_USED,
29+
status.HTTP_306_RESERVED,
30+
status.HTTP_418_IM_A_TEAPOT,
31+
status.HTTP_425_TOO_EARLY,
32+
)
33+
534

635
class HTTPLockedError(HTTPClientError):
736
# pylint: disable=too-many-ancestors
837
status_code = status.HTTP_423_LOCKED
38+
39+
40+
class HTTPLoopDetectedError(HTTPServerError):
41+
# pylint: disable=too-many-ancestors
42+
status_code = status.HTTP_508_LOOP_DETECTED
43+
44+
45+
E = TypeVar("E", bound="HTTPException")
46+
47+
48+
def get_all_aiohttp_http_exceptions(
49+
base_http_exception_cls: type[E],
50+
) -> dict[int, type[E]]:
51+
# Inverse map from code to HTTPException classes
52+
53+
def _pred(obj) -> bool:
54+
return (
55+
inspect.isclass(obj)
56+
and issubclass(obj, base_http_exception_cls)
57+
and getattr(obj, "status_code", 0) > 0
58+
)
59+
60+
found: list[tuple[str, Any]] = inspect.getmembers(web_exceptions, _pred)
61+
assert found # nosec
62+
63+
status_to_http_exception_map = {cls.status_code: cls for _, cls in found}
64+
assert len(status_to_http_exception_map) == len(found), "No duplicates" # nosec
65+
66+
for cls in (
67+
HTTPLockedError,
68+
HTTPLoopDetectedError,
69+
):
70+
status_to_http_exception_map[cls.status_code] = cls
71+
72+
return status_to_http_exception_map
73+
74+
75+
_STATUS_CODE_TO_HTTP_ERRORS: dict[
76+
int, type[HTTPError]
77+
] = get_all_aiohttp_http_exceptions(HTTPError)
78+
79+
80+
def get_http_error_class_or_none(status_code: int) -> type[HTTPError] | None:
81+
"""Returns aiohttp error class corresponding to a 4XX or 5XX status code
82+
83+
NOTE: any non-error code (i.e. 2XX, 3XX and 4XX) will return None
84+
"""
85+
return _STATUS_CODE_TO_HTTP_ERRORS.get(status_code)

packages/service-library/src/servicelib/status_utils.py renamed to packages/service-library/src/servicelib/status_codes_utils.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
33
- on aiohttp services
44
from servicelib.aiohttp import status
5-
from servicelib.status_utils import is_success
5+
from servicelib.status_codes_utils import is_success
66
77
assert is_success(status.HTTP_200_OK)
88
99
1010
- on fastapi services
1111
1212
from fastapi import status
13-
from servicelib.status_utils import is_success
13+
from servicelib.status_codes_utils import is_success
1414
1515
assert is_success(status.HTTP_200_OK)
1616
@@ -37,6 +37,7 @@ def get_code_display_name(status_code: int) -> str:
3737
return f"HTTP_{status_code}_{code.name}"
3838
except ValueError:
3939
if status_code == 306: # noqa: PLR2004
40+
# NOTE: HttpStatus does not include 306
4041
return "HTTP_306_RESERVED"
4142
return _INVALID_STATUS_CODE_MSG
4243

@@ -65,35 +66,35 @@ def get_code_description(status_code: int) -> str:
6566
)
6667

6768

68-
def is_informational(status_code: int) -> bool:
69+
def is_1xx_informational(status_code: int) -> bool:
6970
"""
7071
Returns `True` for 1xx status codes, `False` otherwise.
7172
"""
7273
return 100 <= status_code <= 199 # noqa: PLR2004
7374

7475

75-
def is_success(status_code: int) -> bool:
76+
def is_2xx_success(status_code: int) -> bool:
7677
"""
7778
Returns `True` for 2xx status codes, `False` otherwise.
7879
"""
7980
return 200 <= status_code <= 299 # noqa: PLR2004
8081

8182

82-
def is_redirect(status_code: int) -> bool:
83+
def is_3xx_redirect(status_code: int) -> bool:
8384
"""
8485
Returns `True` for 3xx status codes, `False` otherwise.
8586
"""
8687
return 300 <= status_code <= 399 # noqa: PLR2004
8788

8889

89-
def is_client_error(status_code: int) -> bool:
90+
def is_4xx_client_error(status_code: int) -> bool:
9091
"""
9192
Returns `True` for 4xx status codes, `False` otherwise.
9293
"""
9394
return 400 <= status_code <= 499 # noqa: PLR2004
9495

9596

96-
def is_server_error(status_code: int) -> bool:
97+
def is_5xx_server_error(status_code: int) -> bool:
9798
"""
9899
Returns `True` for 5xx status codes, `False` otherwise.
99100
"""

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

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,13 @@
1616
HTTPOk,
1717
)
1818
from servicelib.aiohttp import status
19-
from servicelib.aiohttp.rest_responses import (
19+
from servicelib.aiohttp.rest_responses import create_http_error, exception_to_response
20+
from servicelib.aiohttp.web_exceptions_extension import (
2021
_STATUS_CODE_TO_HTTP_ERRORS,
21-
create_http_error,
22-
exception_to_response,
23-
get_http_error,
22+
get_http_error_class_or_none,
2423
)
2524
from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
2625

27-
#
28-
2926
# SEE https://httpstatuses.com/
3027
# - below 1xx -> invalid
3128
BELOW_1XX = (-5, 0, 5, 99)
@@ -36,17 +33,17 @@
3633

3734

3835
@pytest.mark.parametrize(
39-
"http_exc", (HTTPBadRequest, HTTPGone, HTTPInternalServerError)
36+
"http_exc", [HTTPBadRequest, HTTPGone, HTTPInternalServerError]
4037
)
4138
def test_get_http_exception_class_from_code(http_exc: HTTPException):
42-
assert get_http_error(http_exc.status_code) == http_exc
39+
assert get_http_error_class_or_none(http_exc.status_code) == http_exc
4340

4441

4542
@pytest.mark.parametrize(
4643
"status_code", itertools.chain(BELOW_1XX, NONE_ERRORS, ABOVE_599)
4744
)
4845
def test_get_none_for_invalid_or_not_errors_code(status_code):
49-
assert get_http_error(status_code) is None
46+
assert get_http_error_class_or_none(status_code) is None
5047

5148

5249
@pytest.mark.parametrize(

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

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
from http import HTTPStatus
22

3+
import pytest
34
from servicelib.aiohttp import status
4-
from servicelib.status_utils import (
5+
from servicelib.aiohttp.web_exceptions_extension import (
6+
STATUS_CODES_WITHOUT_AIOHTTP_EXCEPTION_CLASS,
7+
HTTPException,
8+
get_all_aiohttp_http_exceptions,
9+
)
10+
from servicelib.status_codes_utils import (
511
_INVALID_STATUS_CODE_MSG,
612
get_code_description,
713
get_code_display_name,
814
get_http_status_codes,
9-
is_client_error,
15+
is_1xx_informational,
16+
is_2xx_success,
17+
is_3xx_redirect,
18+
is_4xx_client_error,
19+
is_5xx_server_error,
1020
is_error,
11-
is_informational,
12-
is_redirect,
13-
is_server_error,
14-
is_success,
1521
)
1622

1723

@@ -31,12 +37,12 @@ def test_description():
3137

3238
def test_status_codes_checks():
3339

34-
assert is_informational(status.HTTP_102_PROCESSING)
35-
assert is_success(status.HTTP_202_ACCEPTED)
36-
assert is_redirect(status.HTTP_301_MOVED_PERMANENTLY)
40+
assert is_1xx_informational(status.HTTP_102_PROCESSING)
41+
assert is_2xx_success(status.HTTP_202_ACCEPTED)
42+
assert is_3xx_redirect(status.HTTP_301_MOVED_PERMANENTLY)
3743

38-
assert is_client_error(status.HTTP_401_UNAUTHORIZED)
39-
assert is_server_error(status.HTTP_503_SERVICE_UNAVAILABLE)
44+
assert is_4xx_client_error(status.HTTP_401_UNAUTHORIZED)
45+
assert is_5xx_server_error(status.HTTP_503_SERVICE_UNAVAILABLE)
4046

4147
assert is_error(status.HTTP_401_UNAUTHORIZED)
4248
assert is_error(status.HTTP_503_SERVICE_UNAVAILABLE)
@@ -45,7 +51,7 @@ def test_status_codes_checks():
4551
def test_predicates_with_status():
4652

4753
# in formational
48-
assert get_http_status_codes(status, is_informational) == [
54+
assert get_http_status_codes(status, is_1xx_informational) == [
4955
status.HTTP_100_CONTINUE,
5056
status.HTTP_101_SWITCHING_PROTOCOLS,
5157
status.HTTP_102_PROCESSING,
@@ -61,3 +67,17 @@ def test_predicates_with_status():
6167
for c in get_http_status_codes(status)
6268
if c != status.HTTP_306_RESERVED
6369
]
70+
71+
72+
AIOHTTP_EXCEPTION_CLASSES_MAP: dict[
73+
int, type[HTTPException]
74+
] = get_all_aiohttp_http_exceptions(HTTPException)
75+
76+
77+
@pytest.mark.parametrize("status_code", get_http_status_codes(status))
78+
def test_how_status_codes_map_to_aiohttp_exception_class(status_code):
79+
aiohttp_exception_cls = AIOHTTP_EXCEPTION_CLASSES_MAP.get(status_code)
80+
if status_code in STATUS_CODES_WITHOUT_AIOHTTP_EXCEPTION_CLASS:
81+
assert aiohttp_exception_cls is None
82+
else:
83+
assert aiohttp_exception_cls is not None

services/storage/src/simcore_service_storage/handlers_files.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import asyncio
22
import logging
33
import urllib.parse
4-
from typing import NoReturn, cast
4+
from typing import cast
55

66
from aiohttp import web
77
from aiohttp.web import RouteTableDef
@@ -25,7 +25,6 @@
2525
parse_request_path_parameters_as,
2626
parse_request_query_parameters_as,
2727
)
28-
from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
2928

3029
from ._meta import API_VTAG
3130
from .dsm import get_dsm_provider
@@ -249,7 +248,7 @@ async def upload_file(request: web.Request) -> web.Response:
249248
f"/{API_VTAG}/locations/{{location_id}}/files/{{file_id}}:abort",
250249
name="abort_upload_file",
251250
)
252-
async def abort_upload_file(request: web.Request) -> NoReturn:
251+
async def abort_upload_file(request: web.Request) -> web.Response:
253252
query_params: StorageQueryParamsBase = parse_request_query_parameters_as(
254253
StorageQueryParamsBase, request
255254
)
@@ -261,7 +260,7 @@ async def abort_upload_file(request: web.Request) -> NoReturn:
261260

262261
dsm = get_dsm_provider(request.app).get(path_params.location_id)
263262
await dsm.abort_file_upload(query_params.user_id, path_params.file_id)
264-
raise web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON)
263+
return web.json_response(status=status.HTTP_204_NO_CONTENT)
265264

266265

267266
@routes.post(
@@ -374,7 +373,7 @@ async def is_completed_upload_file(request: web.Request) -> web.Response:
374373
@routes.delete(
375374
f"/{API_VTAG}/locations/{{location_id}}/files/{{file_id}}", name="delete_file"
376375
)
377-
async def delete_file(request: web.Request) -> NoReturn:
376+
async def delete_file(request: web.Request) -> web.Response:
378377
query_params: StorageQueryParamsBase = parse_request_query_parameters_as(
379378
StorageQueryParamsBase, request
380379
)
@@ -386,7 +385,7 @@ async def delete_file(request: web.Request) -> NoReturn:
386385

387386
dsm = get_dsm_provider(request.app).get(path_params.location_id)
388387
await dsm.delete_file(query_params.user_id, path_params.file_id)
389-
raise web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON)
388+
return web.json_response(status=status.HTTP_204_NO_CONTENT)
390389

391390

392391
@routes.post(f"/{API_VTAG}/files/{{file_id}}:soft-copy", name="copy_as_soft_link")

0 commit comments

Comments
 (0)