Passed
Push — main ( 6627c3...a229ba )
by
unknown
01:35
created

pincer.core.http.HttpCallable.__call__()   A

Complexity

Conditions 1

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 4
dl 0
loc 5
rs 10
c 0
b 0
f 0
cc 1
nop 6
1
# Copyright Pincer 2021-Present
0 ignored issues
show
introduced by
Missing module docstring
Loading history...
2
# Full MIT License can be found in `LICENSE` at the project root.
3
4
import asyncio
5
import logging
6
from asyncio import sleep
7
from json import dumps
8
from typing import Dict, Any, Optional, Protocol
9
10
from aiohttp import ClientSession, ClientResponse
11
from aiohttp.client import _RequestContextManager
12
from aiohttp.typedefs import StrOrURL
13
14
from . import __package__
15
from .._config import GatewayConfig
16
from ..exceptions import (
17
    NotFoundError, BadRequestError, NotModifiedError, UnauthorizedError,
18
    ForbiddenError, MethodNotAllowedError, RateLimitError, ServerError,
19
    HTTPError
20
)
21
22
_log = logging.getLogger(__package__)
23
24
25
class HttpCallable(Protocol):
26
    """aiohttp HTTP method"""
27
    __name__: str
28
29
    def __call__(
30
            self, url: StrOrURL, *,
31
            allow_redirects: bool = True, json: Dict = None, **kwargs: Any
32
    ) -> _RequestContextManager:
33
        ...
34
35
36
class HTTPClient:
37
    """Interacts with Discord API through HTTP protocol"""
38
39
    def __init__(self, token: str, *, version: int = None, ttl: int = 5):
40
        """
41
        Instantiate a new HttpApi object.
42
43
        :param token:
44
            Discord API token
45
46
        Keyword Arguments:
47
48
        :param version:
49
            The discord API version.
50
            See `<https://discord.com/developers/docs/reference#api-versioning>`_.
51
52
        :param ttl:
53
            Max amount of attempts after error code 5xx
54
        """
55
        version = version or GatewayConfig.version
56
        self.url: str = f"https://discord.com/api/v{version}"
57
        self.max_ttl: int = ttl
58
59
        headers: Dict[str, str] = {
60
            "Authorization": f"Bot {token}",
61
            "Content-Type": "application/json"
62
        }
63
        self.__session: ClientSession = ClientSession(headers=headers)
64
65
        self.__http_exceptions: Dict[int, HTTPError] = {
66
            304: NotModifiedError(),
67
            400: BadRequestError(),
68
            401: UnauthorizedError(),
69
            403: ForbiddenError(),
70
            404: NotFoundError(),
71
            405: MethodNotAllowedError(),
72
            429: RateLimitError()
73
        }
74
75
    # for with block
76
    async def __aenter__(self):
77
        return self
78
79
    async def __aexit__(self, exc_type, exc, tb):
80
        await self.close()
81
82
    async def close(self):
83
        """Closes :attr:`~.HTTPClient.__session`"""
84
        await self.__session.close()
85
86
    async def __send(
87
            self,
88
            method: HttpCallable,
89
            endpoint: str, *,
90
            data: Optional[Dict] = None,
91
            __ttl: int = None
92
    ) -> Optional[Dict]:
93
        """
94
        Send an api request to the Discord REST API.
95
96
        :meta public:
97
98
        :param method:
99
            The method for the request. (eg GET or POST)
100
101
        :param endpoint:
102
            The endpoint to which the request will be sent.
103
104
        Keyword Arguments:
105
106
        :param data:
107
            The data which will be added to the request.
108
109
        :param __ttl:
110
            Private param used for recursively setting the retry amount.
111
            (Eg set to 1 for 1 max retry)
112
        """
113
        ttl = __ttl or self.max_ttl
114
115
        if ttl == 0:
116
            logging.error(
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
117
                # TODO: print better method name
0 ignored issues
show
Coding Style introduced by
TODO and FIXME comments should generally be avoided.
Loading history...
118
                f"{method.__name__.upper()} {endpoint} has reached the "
119
                f"maximum retry count of {self.max_ttl}."
120
            )
121
122
            raise ServerError(f"Maximum amount of retries for `{endpoint}`.")
123
124
        # TODO: print better method name
0 ignored issues
show
Coding Style introduced by
TODO and FIXME comments should generally be avoided.
Loading history...
125
        _log.debug(f"{method.__name__.upper()} {endpoint} | {dumps(data)}")
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
126
127
        url = f"{self.url}/{endpoint}"
128
        async with method(url, json=data) as res:
129
            return await self.__handle_response(
130
                res, method, endpoint, data, ttl
131
            )
132
133
    async def __handle_response(
134
            self,
135
            res: ClientResponse,
136
            method: HttpCallable,
137
            endpoint: str,
138
            data: Optional[Dict],
139
            __ttl: int,
140
    ) -> Optional[Dict]:
141
        """
142
        Handle responses from the discord API.
143
144
        :meta public:
145
146
        Side effects:
147
            If a 5xx error code is returned it will retry the request.
148
149
        :param res:
150
            The response from the discord API.
151
152
        :param method:
153
            The method which was used to call the endpoint.
154
155
        :param endpoint:
156
            The endpoint to which the request was sent.
157
158
        :param data:
159
            The data which was added to the request.
160
161
        :param __ttl:
162
            Private param used for recursively setting the retry amount.
163
            (Eg set to 1 for 1 max retry)
164
        """
165
        _log.debug(f"Received response for {endpoint} | {await res.text()}")
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
166
        if res.ok:
167
            if res.status == 204:
168
                _log.debug(
169
                    "Request has been sent successfully. "
170
                )
171
                return
172
173
            _log.debug(
174
                "Request has been sent successfully. "
175
                "Returning json response."
176
            )
177
178
            return await res.json()
179
180
        exception = self.__http_exceptions.get(res.status)
181
182
        if exception:
183
            if isinstance(exception, RateLimitError):
184
                timeout = (await res.json()).get("retry_after", 40)
185
186
                _log.exception(
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
187
                    f"RateLimitError: {res.reason}."
188
                    f" Retrying in {timeout} seconds"
189
                )
190
                await sleep(timeout)
191
                return await self.__send(method, endpoint, data=data)
192
193
            _log.error(
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
194
                f"An http exception occurred while trying to send "
195
                f"a request to {endpoint}. ({res.status}, {res.reason})"
196
            )
197
198
            exception.__init__(res.reason)
199
            raise exception
200
201
        # status code is guaranteed to be 5xx
202
        retry_in = 1 + (self.max_ttl - __ttl) * 2
203
204
        _log.debug(
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
205
            "Server side error occurred with status code "
206
            f"{res.status}. Retrying in {retry_in}s."
207
        )
208
209
        await asyncio.sleep(retry_in)
210
211
        # try sending it again
212
        return await self.__send(method, endpoint, __ttl=__ttl - 1, data=data)
213
214
    async def delete(self, route: str) -> Optional[Dict]:
215
        """
216
        Sends a delete request to a Discord REST endpoint.
217
218
        :param route:
219
            The Discord REST endpoint to send a delete request to.
220
221
        :return:
222
            JSON response from the discord API.
223
        """
224
        return await self.__send(self.__session.delete, route)
225
226
    async def get(self, route: str) -> Optional[Dict]:
227
        """
228
        Sends a get request to a Discord REST endpoint.
229
230
        :param route:
231
            The Discord REST endpoint to send a get request to.
232
233
        :return:
234
            JSON response from the discord API.
235
        """
236
        return await self.__send(self.__session.get, route)
237
238
    async def head(self, route: str) -> Optional[Dict]:
239
        """
240
        Sends a head request to a Discord REST endpoint.
241
242
        :param route:
243
            The Discord REST endpoint to send a head request to.
244
245
        :return:
246
            JSON response from the discord API.
247
        """
248
        return await self.__send(self.__session.head, route)
249
250
    async def options(self, route: str) -> Optional[Dict]:
251
        """
252
        Sends a options request to a Discord REST endpoint.
253
254
        :param route:
255
            The Discord REST endpoint to send a options request to.
256
257
        :return:
258
            JSON response from the discord API.
259
        """
260
        return await self.__send(self.__session.options, route)
261
262
    async def patch(self, route: str, data: Dict) -> Optional[Dict]:
263
        """
264
        Sends a patch request to a Discord REST endpoint.
265
266
        :param route:
267
            The Discord REST endpoint to send a patch request to.
268
269
        :param data:
270
            The update data for the patch request.
271
272
        :return:
273
            JSON response from the discord API.
274
        """
275
        return await self.__send(self.__session.patch, route, data=data)
276
277
    async def post(self, route: str, data: Dict) -> Optional[Dict]:
278
        """
279
        Sends a post request to a Discord REST endpoint.
280
281
        :param route:
282
            The Discord REST endpoint to send a post request to.
283
284
        :param data:
285
            The data for the post request.
286
287
        :return:
288
            JSON response from the discord API.
289
        """
290
        return await self.__send(self.__session.post, route, data=data)
291
292
    async def put(self, route: str, data: Dict) -> Optional[Dict]:
293
        """
294
        Sends a put request to a Discord REST endpoint.
295
296
        :param route:
297
            The Discord REST endpoint to send a put request to.
298
299
        :param data:
300
            The data for the put request.
301
302
        :return:
303
            JSON response from the discord API.
304
        """
305
        return await self.__send(self.__session.put, route, data=data)
306