Skip to content

Commit 8812a8a

Browse files
committed
add test for request handler cancel_on_disconnect @pcrespov
1 parent c376bab commit 8812a8a

File tree

1 file changed

+61
-107
lines changed

1 file changed

+61
-107
lines changed

packages/service-library/tests/fastapi/test_request_decorators.py

Lines changed: 61 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -2,121 +2,75 @@
22
# pylint: disable=unused-argument
33
# pylint: disable=unused-variable
44

5+
56
import asyncio
6-
import subprocess
7-
import sys
8-
import time
9-
from collections.abc import Callable, Iterator
10-
from contextlib import contextmanager
11-
from pathlib import Path
12-
from typing import NamedTuple
7+
from collections.abc import Awaitable, Callable
8+
from typing import Any
9+
from unittest.mock import AsyncMock
1310

1411
import pytest
15-
import requests
16-
from fastapi import FastAPI, Query, Request
12+
from fastapi import Request
1713
from servicelib.fastapi.requests_decorators import cancel_on_disconnect
1814

19-
CURRENT_FILE = Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve()
20-
CURRENT_DIR = CURRENT_FILE.parent
15+
POLLER_CLEANUP_DELAY_S = 100.0
16+
2117

18+
@pytest.fixture
19+
def long_running_poller_mock(
20+
monkeypatch: pytest.MonkeyPatch,
21+
) -> Callable[[asyncio.Event, Request, Any], Awaitable]:
2222

23-
mock_app = FastAPI(title="Disconnect example")
23+
async def _mock_disconnect_poller(
24+
close_event: asyncio.Event, request: Request, result: Any
25+
):
26+
_mock_disconnect_poller.called = True
27+
while not await request.is_disconnected():
28+
await asyncio.sleep(0.01)
29+
if close_event.is_set():
30+
# Simulate a long cleanup procedure
31+
await asyncio.sleep(POLLER_CLEANUP_DELAY_S)
32+
break
33+
return result
2434

25-
MESSAGE_ON_HANDLER_CANCELLATION = "Request was cancelled!!"
35+
monkeypatch.setattr(
36+
"servicelib.fastapi.requests_decorators._disconnect_poller",
37+
_mock_disconnect_poller,
38+
)
39+
return _mock_disconnect_poller
2640

2741

28-
@mock_app.get("/example")
29-
@cancel_on_disconnect
30-
async def example(
31-
request: Request,
32-
wait: float = Query(..., description="Time to wait, in seconds"),
42+
async def test_decorator_waits_for_poller_cleanup(
43+
long_running_poller_mock: Callable[[asyncio.Event, Request, Any], Awaitable],
3344
):
34-
try:
35-
print(f"Sleeping for {wait:.2f}")
36-
await asyncio.sleep(wait)
37-
print("Sleep not cancelled")
38-
return f"I waited for {wait:.2f}s and now this is the result"
39-
except asyncio.CancelledError:
40-
print(MESSAGE_ON_HANDLER_CANCELLATION)
41-
raise
42-
43-
44-
class ServerInfo(NamedTuple):
45-
url: str
46-
proc: subprocess.Popen
47-
48-
49-
@contextmanager
50-
def server_lifetime(port: int) -> Iterator[ServerInfo]:
51-
with subprocess.Popen(
52-
[
53-
"uvicorn",
54-
f"{CURRENT_FILE.stem}:mock_app",
55-
"--port",
56-
f"{port}",
57-
],
58-
cwd=f"{CURRENT_DIR}",
59-
stdout=subprocess.PIPE,
60-
stderr=subprocess.PIPE,
61-
) as proc:
62-
63-
url = f"http://127.0.0.1:{port}"
64-
print("\nStarted", proc.args)
65-
66-
# some time to start
67-
time.sleep(2)
68-
69-
# checks started successfully
70-
assert proc.stdout
71-
assert not proc.poll(), proc.stdout.read().decode("utf-8")
72-
print("server is up and waiting for requests...")
73-
yield ServerInfo(url, proc)
74-
print("server is closing...")
75-
proc.terminate()
76-
print("server terminated")
77-
78-
79-
def test_cancel_on_disconnect(get_unused_port: Callable[[], int]):
80-
81-
with server_lifetime(port=get_unused_port()) as server:
82-
url, proc = server
83-
print("--> testing server")
84-
response = requests.get(f"{server.url}/example?wait=0", timeout=2)
85-
print(response.url, "->", response.text)
86-
response.raise_for_status()
87-
print("<-- server responds")
88-
89-
print("--> testing server correctly cancels")
90-
with pytest.raises(requests.exceptions.ReadTimeout):
91-
response = requests.get(f"{server.url}/example?wait=2", timeout=0.5)
92-
print("<-- testing server correctly cancels done")
93-
94-
print("--> testing server again")
95-
# NOTE: the timeout here appears to be sensitive. if it is set <5 the test hangs from time to time
96-
response = requests.get(f"{server.url}/example?wait=1", timeout=5)
97-
print(response.url, "->", response.text)
98-
response.raise_for_status()
99-
print("<-- testing server again done")
100-
101-
# kill service
102-
server.proc.terminate()
103-
assert server.proc.stdout
104-
server_log = server.proc.stdout.read().decode("utf-8")
105-
print(
106-
f"{server.url=} stdout",
107-
"-" * 10,
108-
"\n",
109-
server_log,
110-
"-" * 30,
111-
)
112-
# server.url=http://127.0.0.1:44077 stdout ----------
113-
# Sleeping for 0.00
114-
# Sleep not cancelled
115-
# INFO: 127.0.0.1:35114 - "GET /example?wait=0 HTTP/1.1" 200 OK
116-
# Sleeping for 2.00
117-
# Exiting on cancellation
118-
# Sleeping for 1.00
119-
# Sleep not cancelled
120-
# INFO: 127.0.0.1:35134 - "GET /example?wait=1 HTTP/1.1" 200 OK
121-
122-
assert MESSAGE_ON_HANDLER_CANCELLATION in server_log
45+
"""
46+
Tests that the decorator's wrapper waits for the poller task to finish
47+
its cleanup, even if the handler finishes first, without needing a full server.
48+
"""
49+
long_running_poller_mock.called = False
50+
handler_was_called = False
51+
52+
@cancel_on_disconnect
53+
async def my_handler(request: Request):
54+
nonlocal handler_was_called
55+
handler_was_called = True
56+
await asyncio.sleep(0.1) # Simulate quick work
57+
return "Success"
58+
59+
# Mock a fastapi.Request object
60+
mock_request = AsyncMock(spec=Request)
61+
mock_request.is_disconnected.return_value = False
62+
63+
# ---
64+
tasks_before = asyncio.all_tasks()
65+
66+
# Call the decorated handler
67+
_ = await my_handler(mock_request)
68+
69+
tasks_after = asyncio.all_tasks()
70+
# ---
71+
72+
assert handler_was_called
73+
assert long_running_poller_mock.called == True
74+
75+
# Check that no background tasks were left orphaned
76+
assert tasks_before == tasks_after

0 commit comments

Comments
 (0)