44
55import asyncio
66import time
7+ from typing import Callable
78
89import pytest
910from aiohttp import web
10- from aiohttp .web_exceptions import HTTPTooManyRequests
11+ from aiohttp .test_utils import TestClient
12+ from aiohttp .web_exceptions import HTTPOk , HTTPTooManyRequests
13+ from pydantic import ValidationError , conint , parse_obj_as
1114from simcore_service_webserver .utils_rate_limiting import global_rate_limit_route
1215
16+ TOTAL_TEST_TIME = 1 # secs
1317MAX_NUM_REQUESTS = 3
1418MEASURE_INTERVAL = 0.5
1519MAX_REQUEST_RATE = MAX_NUM_REQUESTS / MEASURE_INTERVAL
@@ -22,35 +26,43 @@ async def get_ok_handler(_request: web.Request):
2226 return web .json_response ({"value" : 1 })
2327
2428
25- @pytest .mark .parametrize (
26- "requests_per_second" ,
27- [0.5 * MAX_REQUEST_RATE , MAX_REQUEST_RATE , 2 * MAX_REQUEST_RATE ],
28- )
29- async def test_global_rate_limit_route (requests_per_second , aiohttp_client ):
30- #
29+ @pytest .fixture
30+ def client (event_loop , aiohttp_client : Callable ) -> TestClient :
3131 app = web .Application ()
3232 app .router .add_get ("/" , get_ok_handler )
3333
34- client = await aiohttp_client (app )
35- # ---
34+ return event_loop .run_until_complete (aiohttp_client (app ))
3635
36+
37+ def test_rate_limit_route_decorator ():
3738 # decorated function keeps setup
3839 assert get_ok_handler .rate_limit_setup == (MAX_NUM_REQUESTS , MEASURE_INTERVAL )
3940
41+
42+ @pytest .mark .parametrize (
43+ "requests_per_second" ,
44+ [0.5 * MAX_REQUEST_RATE , MAX_REQUEST_RATE , 2 * MAX_REQUEST_RATE ],
45+ )
46+ async def test_global_rate_limit_route (requests_per_second : float , client : TestClient ):
47+ # WARNING: this test has some timings and might fail when using breakpoints
48+
4049 # Creates desired stream of requests for 1 second
41- TOTAL_TEST_TIME = 1 # secs
4250 num_requests = int (requests_per_second * TOTAL_TEST_TIME )
4351 time_between_requests = 1.0 / requests_per_second
4452
45- futures = []
53+ tasks = []
4654 t0 = time .time ()
47- while len (futures ) < num_requests :
55+ while len (tasks ) < num_requests :
4856 t1 = time .time ()
49- futures .append (asyncio .create_task (client .get ("/" )))
50- time .sleep (time_between_requests - (time .time () - t1 ))
57+ tasks .append (asyncio .create_task (client .get ("/" )))
58+ elapsed_on_creation = time .time () - t1 # ANE is really precise here ;-)
59+
60+ # NOTE: I am not sure why using asyncio.sleep here would make some tests fail the check "after"
61+ # await asyncio.sleep(time_between_requests - create_gap)
62+ time .sleep (time_between_requests - elapsed_on_creation )
5163
5264 elapsed = time .time () - t0
53- count = len (futures )
65+ count = len (tasks )
5466 print (
5567 count ,
5668 "requests in" ,
@@ -63,20 +75,42 @@ async def test_global_rate_limit_route(requests_per_second, aiohttp_client):
6375 assert count == num_requests
6476 assert elapsed == pytest .approx (TOTAL_TEST_TIME , abs = 0.1 )
6577
66- for i , fut in enumerate (futures ):
67- while not fut .done ():
68- await asyncio .sleep (0.1 )
69- assert not fut .cancelled ()
70- assert not fut .exception ()
71- print ("%2d" % i , fut .result ().status )
72-
73- expected_status = 200
78+ msg = []
79+ for i , task in enumerate (tasks ):
80+ while not task .done ():
81+ await asyncio .sleep (0.01 )
82+ assert not task .cancelled ()
83+ assert not task .exception ()
84+ msg .append (
85+ (
86+ "request # %2d" % i ,
87+ f"status={ task .result ().status } " ,
88+ f"retry-after={ task .result ().headers .get ('Retry-After' )} " ,
89+ )
90+ )
91+ print (* msg [- 1 ])
92+
93+ expected_status = HTTPOk .status_code
7494
7595 # first requests are OK
76- assert all (f .result ().status == expected_status for f in futures [:MAX_NUM_REQUESTS ])
96+ assert all (
97+ t .result ().status == expected_status for t in tasks [:MAX_NUM_REQUESTS ]
98+ ), f" Failed with { msg [:MAX_NUM_REQUESTS ]} "
7799
78100 if requests_per_second >= MAX_REQUEST_RATE :
79101 expected_status = HTTPTooManyRequests .status_code
80102
81103 # after ...
82- assert all (f .result ().status == expected_status for f in futures [MAX_NUM_REQUESTS :])
104+ assert all (
105+ t .result ().status == expected_status for t in tasks [MAX_NUM_REQUESTS :]
106+ ), f" Failed with { msg [MAX_NUM_REQUESTS :]} "
107+
108+ # checks Retry-After header
109+ failed = []
110+ for t in tasks :
111+ if retry_after := t .result ().headers .get ("Retry-After" ):
112+ try :
113+ parse_obj_as (conint (ge = 1 ), retry_after )
114+ except ValidationError as err :
115+ failed .append ((retry_after , f"{ err } " ))
116+ assert not failed
0 commit comments