Skip to content

Commit 3374169

Browse files
authored
✨ Is638/client cancellation (ITISFoundation#3155)
1 parent e7969cf commit 3374169

File tree

8 files changed

+469
-19
lines changed

8 files changed

+469
-19
lines changed

packages/service-library/requirements/_fastapi.in

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,24 @@
55

66
--constraint ./_base.in
77

8+
9+
async-asgi-testclient # replacement for fastapi.testclient.TestClient [see b) below]
810
fastapi
911
fastapi_contrib[jaegertracing]
12+
13+
14+
# NOTE: What test client to use for fastapi-based apps?
15+
#
16+
# fastapi comes with a default test client: fatapi.testclient.TestClient (SEE https://fastapi.tiangolo.com/tutorial/testing/)
17+
# which is essentially an indirection to starlette.testclient (SEE https://www.starlette.io/testclient/)
18+
#
19+
# the limitation of that client is that it is fd synchronous.
20+
#
21+
# There are two options in place:
22+
# a) fastapi recommends to use httpx and create your own AsyncTestClient: https://fastapi.tiangolo.com/advanced/async-tests/
23+
# PROS: can use respx to mock responses, used to httpx API
24+
# CONS: do it yourself, does not include app member out-of-the-box
25+
# b) use generic Async ASGI TestClient library: https://github.com/vinissimus/async-asgi-testclient
26+
# PROS: generic closed solution, has 'app' member , requests-like API (i.e. equivalent to starletter TESTClient)
27+
# CONS: basically does not have the PROS from a), adds extra deps to 'requests' lib.
28+
#

packages/service-library/requirements/_fastapi.txt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,26 @@
66
#
77
anyio==3.6.1
88
# via starlette
9+
async-asgi-testclient==1.4.11
10+
# via -r requirements/_fastapi.in
11+
certifi==2022.6.15
12+
# via requests
13+
charset-normalizer==2.0.12
14+
# via requests
915
fastapi==0.76.0
1016
# via
1117
# -r requirements/_fastapi.in
1218
# fastapi-contrib
1319
fastapi-contrib==0.2.11
1420
# via -r requirements/_fastapi.in
1521
idna==3.3
16-
# via anyio
22+
# via
23+
# anyio
24+
# requests
1725
jaeger-client==4.8.0
1826
# via fastapi-contrib
27+
multidict==6.0.2
28+
# via async-asgi-testclient
1929
opentracing==2.4.0
2030
# via
2131
# fastapi-contrib
@@ -25,6 +35,8 @@ pydantic==1.9.0
2535
# -c requirements/./../../../requirements/constraints.txt
2636
# -c requirements/./_base.in
2737
# fastapi
38+
requests==2.28.0
39+
# via async-asgi-testclient
2840
six==1.16.0
2941
# via thrift
3042
sniffio==1.2.0
@@ -43,3 +55,7 @@ typing-extensions==4.2.0
4355
# via
4456
# pydantic
4557
# starlette
58+
urllib3==1.26.9
59+
# via
60+
# -c requirements/./../../../requirements/constraints.txt
61+
# requests

packages/service-library/requirements/_test.txt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ attrs==20.3.0
2828
# pytest-docker
2929
bcrypt==3.2.2
3030
# via paramiko
31-
certifi==2022.5.18.1
32-
# via requests
31+
certifi==2022.6.15
32+
# via
33+
# -c requirements/_fastapi.txt
34+
# requests
3335
cffi==1.15.0
3436
# via
3537
# bcrypt
@@ -38,6 +40,7 @@ cffi==1.15.0
3840
charset-normalizer==2.0.12
3941
# via
4042
# -c requirements/_aiohttp.txt
43+
# -c requirements/_fastapi.txt
4144
# aiohttp
4245
# requests
4346
coverage==6.3.2
@@ -101,6 +104,7 @@ mccabe==0.7.0
101104
multidict==6.0.2
102105
# via
103106
# -c requirements/_aiohttp.txt
107+
# -c requirements/_fastapi.txt
104108
# aiohttp
105109
# yarl
106110
openapi-schema-validator==0.2.3
@@ -174,8 +178,9 @@ pyyaml==5.4.1
174178
# -c requirements/_base.txt
175179
# docker-compose
176180
# openapi-spec-validator
177-
requests==2.27.1
181+
requests==2.28.0
178182
# via
183+
# -c requirements/_fastapi.txt
179184
# coveralls
180185
# docker
181186
# docker-compose
@@ -206,6 +211,7 @@ typing-extensions==4.2.0
206211
urllib3==1.26.9
207212
# via
208213
# -c requirements/../../../requirements/constraints.txt
214+
# -c requirements/_fastapi.txt
209215
# requests
210216
websocket-client==0.59.0
211217
# via
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import asyncio
2+
import inspect
3+
import logging
4+
from asyncio import CancelledError
5+
from contextlib import suppress
6+
from functools import wraps
7+
from typing import Any, Callable, Coroutine, Optional
8+
9+
from fastapi import Request, Response
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
_DEFAULT_CHECK_INTERVAL_S: float = 0.5
15+
16+
HTTP_499_CLIENT_CLOSED_REQUEST = 499
17+
# A non-standard status code introduced by nginx for the case when a client
18+
# closes the connection while nginx is processing the request.
19+
# SEE https://www.webfx.com/web-development/glossary/http-status-codes/what-is-a-499-status-code/
20+
21+
TASK_NAME_PREFIX = "cancellable_request"
22+
23+
_FastAPIHandlerCallable = Callable[..., Coroutine[Any, Any, Optional[Any]]]
24+
25+
26+
async def _cancel_task_if_client_disconnected(
27+
request: Request, task: asyncio.Task, interval: float = _DEFAULT_CHECK_INTERVAL_S
28+
) -> None:
29+
try:
30+
while True:
31+
if task.done():
32+
logger.debug("task %s is done", task)
33+
break
34+
if await request.is_disconnected():
35+
logger.warning(
36+
"client %s disconnected! Cancelling handler for %s",
37+
request.client,
38+
f"{request.url=}",
39+
)
40+
task.cancel()
41+
break
42+
await asyncio.sleep(interval)
43+
except CancelledError:
44+
logger.debug("task monitoring %s handler was cancelled", f"{request.url=}")
45+
raise
46+
finally:
47+
logger.debug("task monitoring %s handler completed", f"{request.url}")
48+
49+
50+
def cancellable_request(handler_fun: _FastAPIHandlerCallable):
51+
"""This decorator periodically checks if the client disconnected and
52+
then will cancel the request and return a HTTP_499_CLIENT_CLOSED_REQUEST code (a la nginx).
53+
54+
Usage: decorate the cancellable route and add request: Request as an argument
55+
56+
@cancellable_request
57+
async def route(
58+
_request: Request,
59+
...
60+
)
61+
"""
62+
# CHECK: Early check that will raise upon import
63+
# IMPROVEMENT: inject this parameter to handler_fun here before it returned in the wrapper and consumed by fastapi.router?
64+
found_required_arg = any(
65+
parameter.name == "_request" and parameter.annotation == Request
66+
for parameter in inspect.signature(handler_fun).parameters.values()
67+
)
68+
if not found_required_arg:
69+
raise ValueError(
70+
f"Invalid handler {handler_fun.__name__} signature: missing required parameter _request: Request"
71+
)
72+
73+
# WRAPPER ----
74+
@wraps(handler_fun)
75+
async def wrapper(*args, **kwargs) -> Optional[Any]:
76+
request: Request = kwargs["_request"]
77+
78+
# Intercepts handler call and creates a task out of it
79+
handler_task = asyncio.create_task(
80+
handler_fun(*args, **kwargs),
81+
name=f"{TASK_NAME_PREFIX}/handler/{handler_fun.__name__}",
82+
)
83+
# An extra task to monitor when the client disconnects so it can
84+
# cancel 'handler_task'
85+
auto_cancel_task = asyncio.create_task(
86+
_cancel_task_if_client_disconnected(request, handler_task),
87+
name=f"{TASK_NAME_PREFIX}/auto_cancel/{handler_fun.__name__}",
88+
)
89+
90+
try:
91+
return await handler_task
92+
except CancelledError:
93+
logger.warning(
94+
"Request %s was cancelled since client %s disconnected !",
95+
f"{request.url}",
96+
request.client,
97+
)
98+
return Response(
99+
"Request cancelled because client disconnected",
100+
status_code=HTTP_499_CLIENT_CLOSED_REQUEST,
101+
)
102+
finally:
103+
# NOTE: This is ALSO called 'await handler_task' returns
104+
auto_cancel_task.cancel()
105+
with suppress(CancelledError):
106+
await auto_cancel_task
107+
108+
return wrapper

0 commit comments

Comments
 (0)