Skip to content

Commit 1049b03

Browse files
committed
Implement Rate Limiting, Auth, and Info Errors.
1 parent fb21ae9 commit 1049b03

File tree

13 files changed

+183
-64
lines changed

13 files changed

+183
-64
lines changed
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
from .http_error import HTTPError
22
from .not_found import NotFound
33
from .bad_request import BadRequest
4-
from .internal_server_error import InternalServerError
4+
from .internal_server_error import InternalServerError
5+
from .forbidden import Forbidden
6+
from .rate_limited import RateLimited
7+
from .unauthorized import Unauthorized
Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
class BadRequest(Exception):
1+
from typing import TYPE_CHECKING
2+
3+
from . import HTTPError
4+
5+
if TYPE_CHECKING:
6+
from .. import Response
7+
8+
class BadRequest(HTTPError):
29
"""
310
An exception for a 400 HTTP error.
411
"""
512

6-
def __init__(self):
13+
def __init__(self, resp: 'Response'):
714
message = "Bad request, make sure your input is valid!"
8-
super().__init__(message)
15+
super().__init__(resp, message)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from typing import TYPE_CHECKING
2+
3+
from . import HTTPError
4+
5+
if TYPE_CHECKING:
6+
from .. import Response
7+
8+
class Forbidden(HTTPError):
9+
"""
10+
An exception raised for 403 HTTP error.
11+
"""
12+
13+
def __init__(self, resp: 'Response') -> None:
14+
message = "You do not have permission to access this resource!"
15+
super().__init__(resp, message)
Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1+
from typing import TYPE_CHECKING
2+
3+
if TYPE_CHECKING:
4+
from .. import Response
5+
16
class HTTPError(Exception):
27
"""
38
This class represents an error or problem with a request.
49
"""
5-
status: int
6-
url: str
7-
data: any
8-
910

10-
def __init__(self, status: int, url: str, data: any) -> None:
11-
self.status = status
12-
self.url = url
13-
self.data = data
14-
error_message = f"The request to {url} has failed with code {status}. The following data has been returned: \n {data}"
11+
def __init__(self, resp: 'Response', msg: str = "No Info.") -> None:
12+
error_message = f"Info: {msg} \nURL: {resp.url} \nStatus: {resp.status}\n Data: \n{resp.data}"
1513
super().__init__(error_message)
Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
class InternalServerError(Exception):
1+
from typing import TYPE_CHECKING
2+
3+
from . import HTTPError
4+
5+
if TYPE_CHECKING:
6+
from .. import Response
7+
8+
class InternalServerError(HTTPError):
29
"""
310
An exception for a 500 HTTP error.
411
"""
512

6-
def __init__(self):
13+
def __init__(self, resp: 'Response'):
714
message = "Something went wrong within Rec Room's servers. Make sure your input is valid!"
8-
super().__init__(message)
15+
super().__init__(resp, message)
Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
class NotFound(Exception):
1+
from typing import TYPE_CHECKING
2+
3+
from . import HTTPError
4+
5+
if TYPE_CHECKING:
6+
from .. import Response
7+
8+
class NotFound(HTTPError):
29
"""
310
An exception for a 404 HTTP error.
411
"""
512

6-
def __init__(self):
13+
def __init__(self, resp: 'Response'):
714
message = "The data you were looking for can't be found! It either doesn't exist or is private."
8-
super().__init__(message)
15+
super().__init__(resp, message)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from datetime import timedelta
2+
from typing import TYPE_CHECKING
3+
4+
from . import HTTPError
5+
6+
if TYPE_CHECKING:
7+
from .. import Response
8+
9+
class RateLimited(HTTPError):
10+
"""
11+
This exception is raised when the a rate limit is encountered. Raised for a 401 with a retry-after header.
12+
"""
13+
14+
def __init__(self, resp: 'Response') -> None:
15+
time_out = resp.headers.get("retry_after")
16+
t = timedelta(time_out)
17+
message = f"You're currently being rate limited. Time out expires in {t}."
18+
super().__init__(resp, message)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from typing import TYPE_CHECKING
2+
3+
from . import HTTPError
4+
5+
if TYPE_CHECKING:
6+
from .. import Response
7+
8+
class Unauthorized(Exception):
9+
"""
10+
An exception raised for 401 HTTP error.
11+
"""
12+
13+
def __init__(self, resp: 'Response') -> None:
14+
message = "You aren't authorized to access this rousource. Please verify you entered the correct api key."
15+
super().__init__(resp, message)

src/recnetpy/rest/http_client.py

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,65 @@
11
from typing import TYPE_CHECKING, Dict
2+
from math import floor
23

3-
from asyncio import Lock
4+
from asyncio import Lock, get_running_loop, AbstractEventLoop, sleep
45
from aiohttp import ClientSession, TCPConnector
56

6-
from .async_threads import AsyncThreadPool
7-
from .exceptions import HTTPError, BadRequest, InternalServerError
7+
from .exceptions import *
88

99
if TYPE_CHECKING:
1010
from .request import Request
1111
from .response import Response
1212

13+
RATE_LIMIT = 30
14+
15+
def verify_status(resp: Response):
16+
match resp.status:
17+
case 400:
18+
raise BadRequest(resp)
19+
case 401:
20+
raise Unauthorized(resp)
21+
case 403:
22+
if resp.headers.get("retry-after"):
23+
raise RateLimited(resp)
24+
raise Forbidden(resp)
25+
case 404:
26+
raise NotFound(resp)
27+
case 500:
28+
raise InternalServerError(resp)
29+
case _:
30+
raise HTTPError(resp)
31+
32+
1333
class HTTPClient:
1434
"""
1535
This class is responsible for managing the
1636
client session, and adding requests to the
1737
thread pool.
1838
"""
19-
locks: Dict[str, Lock]
2039
session: ClientSession
21-
thread_pool: AsyncThreadPool
40+
api_key: str
41+
rate_limit: int
42+
remaining_limit: int
43+
next_tick: float
44+
tick_offset: float
45+
__loop: AbstractEventLoop
46+
__sleep: Lock
47+
2248

23-
def __init__(self) -> None:
24-
self.locks = {}
25-
connector = TCPConnector(limit=200)
49+
def __init__(self, api_key: str) -> None:
50+
connector = TCPConnector(limit=100)
2651
self.session = ClientSession(connector=connector)
27-
self.thread_pool = AsyncThreadPool(200) #Allows ONLY 200 connections to be processed at any given time.
52+
self.__sleep = Lock()
53+
self.__loop = get_running_loop()
54+
self.api_key = api_key
55+
self.rate_limit = RATE_LIMIT
56+
self.tick_offset = self.__loop.time() % 1
57+
self.reset_limit()
2858

59+
def reset_limit(self):
60+
self.next_tick = floor(self.__loop.time() + 1) + self.tick_offset
61+
self.remaining_limit = self.rate_limit
62+
2963
async def push(self, request: 'Request') -> 'Response':
3064
"""
3165
Creates a lock unique to the request, and
@@ -35,29 +69,23 @@ async def push(self, request: 'Request') -> 'Response':
3569
@param request: The request object to be executed.
3670
@return: Returns a response object.
3771
"""
38-
lock = self.locks.get(request.bucket)
39-
if lock is None:
40-
lock = Lock()
41-
self.locks[request.bucket] = lock
42-
async with lock:
43-
await self.thread_pool.submit(request)
44-
result = await request.get_result()
45-
if result.status == 404:
46-
result.data = None
47-
return result
48-
if result.success: return result
49-
match result.status:
50-
case 400:
51-
raise BadRequest
52-
case 500:
53-
raise InternalServerError
54-
case _:
55-
raise HTTPError(result.status, request.url, result.data)
72+
async with self.__sleep:
73+
t = self.__loop.time()
74+
if t >= self.next_tick: self.reset_limit()
75+
if self.remaining_limit <= 0:
76+
await sleep(self.next_tick - t)
77+
self.reset_limit()
78+
request.send()
79+
self.remaining_limit -= 1
80+
resp = await request.get_result()
81+
verify_status(resp)
82+
return resp
83+
84+
5685

5786
async def stop(self) -> None:
5887
"""
5988
Stops the thread pool, and closes the
6089
underlying client connection.
6190
"""
62-
await self.thread_pool.stop()
6391
await self.session.close()

src/recnetpy/rest/request.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
from typing import TypeVar, Dict, Optional, Union, List
1+
from typing import Dict, Optional, Union, List, Generic, TypeVar
22
from aiohttp import ClientSession, ClientResponse
3-
from .async_threads import ThreadTask
3+
from asyncio import Future
4+
45
from .response import Response
56

67
async def parse_response(resp: ClientResponse) -> Union[str, Dict, List]:
@@ -14,7 +15,9 @@ async def parse_response(resp: ClientResponse) -> Union[str, Dict, List]:
1415
return await resp.json()
1516
return await resp.text()
1617

17-
class Request(ThreadTask[Response]):
18+
RT = TypeVar('RT')
19+
20+
class Request(Generic[RT]):
1821
"""
1922
This class encapsulates a request to be executed inside of a
2023
thread pool.
@@ -26,6 +29,8 @@ class Request(ThreadTask[Response]):
2629
params: Optional[Dict]
2730
body: Optional[Dict]
2831
headers: Optional[Dict]
32+
result: Optional[Response]
33+
__future: Optional[Future]
2934

3035
def __init__(self, client: ClientSession, method: str, url: str, params: Optional[Dict] = None, body: Optional[Dict] = None, headers: Optional[Dict] = None) -> None:
3136
super().__init__()
@@ -35,16 +40,18 @@ def __init__(self, client: ClientSession, method: str, url: str, params: Optiona
3540
self.params = params
3641
self.body = body
3742
self.headers = headers
43+
self.response = None
44+
self.__future = None
3845

39-
async def run(self) -> Response:
46+
def send(self) -> Response:
4047
"""
4148
This function is to be executed within a thread. It makes a
4249
request, and parses the response into a custom response object.
4350
4451
@return: A response object containing the fetched data.
4552
"""
4653
self.attempts = 0
47-
return await self.make_request()
54+
self.__future = self.make_request()
4855

4956
async def make_request(self) -> Response:
5057
"""
@@ -58,17 +65,22 @@ async def make_request(self) -> Response:
5865
try:
5966
async with self.client.request(self.method, self.url, data = self.body, params = self.params, headers = self.headers) as response:
6067
data = await parse_response(response)
61-
return Response(response.status, response.ok, data)
68+
return Response(self.url, response.status, response.ok, response.headers, data)
6269
except Exception as e:
6370
self.attempts += 1
6471
if self.attempts <= 3: return await self.make_request()
6572
raise e
66-
67-
@property
68-
def bucket(self) -> str:
73+
74+
async def get_result(self):
6975
"""
70-
Represents the request and its componets as astring.
76+
It returns the result of the execution. Blocks if
77+
the tasks underlying lock isn't freed, or returns
78+
immediately. May cause unexpected behaviour if not
79+
submitted to a pool.
7180
72-
@return: The request as a string.
81+
@return: Returns the value returned from the run method.
7382
"""
74-
return f"{self.url}:{self.params}:{self.body}"
83+
if self.__future:
84+
self.result = await self.__future
85+
self.__future = None
86+
return self.result

0 commit comments

Comments
 (0)