forked from Tautulli/Tautulli
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathrequest.py
327 lines (257 loc) · 11.3 KB
/
request.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# -*- coding: utf-8 -*-
# This file is part of Tautulli.
#
# Tautulli is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Tautulli is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Tautulli. If not, see <http://www.gnu.org/licenses/>.
from __future__ import unicode_literals
from future.builtins import str
from bs4 import BeautifulSoup
from xml.dom import minidom
import collections
import requests
from requests.packages import urllib3
import plexpy
if plexpy.PYTHON2:
import lock
import logger
else:
from plexpy import lock
from plexpy import logger
# Dictionary with last request times, for rate limiting.
last_requests = collections.defaultdict(int)
fake_lock = lock.FakeLock()
def request_response(url, method="get", auto_raise=True,
whitelist_status_code=None, lock=fake_lock, **kwargs):
"""
Convenient wrapper for `requests.get', which will capture the exceptions
and log them. On success, the Response object is returned. In case of a
exception, None is returned.
Additionally, there is support for rate limiting. To use this feature,
supply a tuple of (lock, request_limit). The lock is used to make sure no
other request with the same lock is executed. The request limit is the
minimal time between two requests (and so 1/request_limit is the number of
requests per seconds).
"""
# Convert whitelist_status_code to a list if needed
if whitelist_status_code and type(whitelist_status_code) != list:
whitelist_status_code = [whitelist_status_code]
# Disable verification of SSL certificates if requested. Note: this could
# pose a security issue!
kwargs["verify"] = bool(plexpy.CONFIG.VERIFY_SSL_CERT)
if not kwargs['verify']:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Map method to the request.XXX method. This is a simple hack, but it
# allows requests to apply more magic per method. See lib/requests/api.py.
request_method = getattr(requests, method.lower())
try:
# Request URL and wait for response
with lock:
logger.debug(
"Requesting URL via %s method: %s", method.upper(), url)
response = request_method(url, **kwargs)
# If status code != OK, then raise exception, except if the status code
# is white listed.
if whitelist_status_code and auto_raise:
if response.status_code not in whitelist_status_code:
try:
response.raise_for_status()
except:
logger.debug(
"Response status code %d is not white "
"listed, raised exception", response.status_code)
raise
elif auto_raise:
response.raise_for_status()
return response
except requests.exceptions.SSLError as e:
if kwargs["verify"]:
logger.error(
"Unable to connect to remote host because of a SSL error. "
"It is likely that your system cannot verify the validity"
"of the certificate. The remote certificate is either "
"self-signed, or the remote server uses SNI. See the wiki for "
"more information on this topic.")
else:
logger.error(
"SSL error raised during connection, with certificate "
"verification turned off: %s", e)
except requests.ConnectionError:
logger.error(
"Unable to connect to remote host. Check if the remote "
"host is up and running.")
except requests.Timeout:
logger.error(
"Request timed out. The remote host did not respond timely.")
except requests.HTTPError as e:
if e.response is not None:
if e.response.status_code >= 500:
cause = "remote server error"
elif e.response.status_code >= 400:
cause = "local client error"
else:
# I don't think we will end up here, but for completeness
cause = "unknown"
logger.error(
"Request raise HTTP error with status code %d (%s).",
e.response.status_code, cause)
# Debug response
if plexpy.VERBOSE:
server_message(e.response)
else:
logger.error("Request raised HTTP error.")
except requests.RequestException as e:
logger.error("Request raised exception: %s", e)
def request_response2(url, method="get", auto_raise=True,
whitelist_status_code=None, lock=fake_lock, **kwargs):
"""
Convenient wrapper for `requests.get', which will capture the exceptions
and log them. On success, the Response object is returned. In case of a
exception, None is returned.
Additionally, there is support for rate limiting. To use this feature,
supply a tuple of (lock, request_limit). The lock is used to make sure no
other request with the same lock is executed. The request limit is the
minimal time between two requests (and so 1/request_limit is the number of
requests per seconds).
"""
# Convert whitelist_status_code to a list if needed
if whitelist_status_code and type(whitelist_status_code) != list:
whitelist_status_code = [whitelist_status_code]
# Disable verification of SSL certificates if requested. Note: this could
# pose a security issue!
kwargs['verify'] = bool(plexpy.CONFIG.VERIFY_SSL_CERT)
if not kwargs['verify']:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Map method to the request.XXX method. This is a simple hack, but it
# allows requests to apply more magic per method. See lib/requests/api.py.
request_method = getattr(requests, method.lower())
response = None
err_msg = http_err = req_msg = None
try:
with lock:
response = request_method(url, **kwargs)
# If status code != OK, then raise exception, except if the status code
# is white listed.
if whitelist_status_code and auto_raise:
if response.status_code not in whitelist_status_code:
try:
response.raise_for_status()
except:
raise
elif auto_raise:
response.raise_for_status()
except requests.exceptions.SSLError as e:
if kwargs["verify"]:
err_msg = "Unable to connect to remote host because of a SSL error."
else:
err_msg = "Unable to connect to remote host because of a SSL error, " \
"with certificate verification turned off: {}".format(e)
except requests.ConnectionError:
err_msg = "Unable to connect to remote host. Check if the remote host is up and running."
except requests.Timeout:
err_msg = "Request to the remote host timed out."
except requests.HTTPError as e:
if e.response is not None:
if e.response.status_code >= 500:
http_err = "[{e.response.status_code}] {e.response.reason} (remote server error).".format(e=e)
elif e.response.status_code >= 400:
http_err = "[{e.response.status_code}] {e.response.reason} (local client error).".format(e=e)
else:
http_err = "Unknown HTTP error."
err_msg = "Request raised a HTTP error: {}".format(http_err)
if plexpy.VERBOSE:
req_msg = server_message(e.response, return_msg=True)
else:
err_msg = "Request raised a HTTP error: Unknown response."
except requests.RequestException as e:
err_msg = "Request raised an exception: {}".format(e)
return response, err_msg, req_msg
def request_soup(url, **kwargs):
"""
Wrapper for `request_response', which will return a BeatifulSoup object if
no exceptions are raised.
"""
parser = kwargs.pop("parser", "html5lib")
response = request_response(url, **kwargs)
if response is not None:
return BeautifulSoup(response.content, parser)
def request_minidom(url, **kwargs):
"""
Wrapper for `request_response', which will return a Minidom object if no
exceptions are raised.
"""
response = request_response(url, **kwargs)
if response is not None:
return minidom.parseString(response.content)
def request_json(url, **kwargs):
"""
Wrapper for `request_response', which will decode the response as JSON
object and return the result, if no exceptions are raised.
As an option, a validator callback can be given, which should return True
if the result is valid.
"""
validator = kwargs.pop("validator", None)
response = request_response(url, **kwargs)
if response is not None:
try:
result = response.json()
if validator and not validator(result):
logger.error("JSON validation result failed")
else:
return result
except ValueError:
logger.error("Response returned invalid JSON data")
# Debug response
if plexpy.VERBOSE:
server_message(response)
def request_content(url, **kwargs):
"""
Wrapper for `request_response', which will return the raw content.
"""
response = request_response(url, **kwargs)
if response is not None:
return response.content
def server_message(response, return_msg=False):
"""
Extract server message from response and log in to logger with DEBUG level.
Some servers return extra information in the result. Try to parse it for
debugging purpose. Messages are limited to 150 characters, since it may
return the whole page in case of normal web page URLs
"""
message = None
# First attempt is to 'read' the response as HTML
if "text/html" in response.headers.get("content-type", ""):
try:
soup = BeautifulSoup(response.content, "html5lib")
except Exception:
soup = None
if soup:
# Find body and cleanup common tags to grab content, which probably
# contains the message.
message = soup.find("body")
elements = ("header", "script", "footer", "nav", "input", "textarea")
for element in elements:
for tag in soup.find_all(element):
tag.replaceWith("")
message = message.text if message else soup.text
message = message.strip()
# Second attempt is to just take the response
if message is None:
message = response.content.strip()
if message:
message = str(message, 'utf-8', 'replace')
# Truncate message if it is too long.
if len(message) > 150:
message = message[:150] + "..."
if return_msg:
return message
logger.debug("Server responded with message: %s", message)