]> git.immae.eu Git - github/fretlink/tap-google-sheets.git/blame - tap_google_sheets/client.py
client.py rate limit, sync.py changes
[github/fretlink/tap-google-sheets.git] / tap_google_sheets / client.py
CommitLineData
89643ba6
JH
1from datetime import datetime, timedelta
2import backoff
3import requests
4from collections import OrderedDict
5
6import singer
7from singer import metrics
8from singer import utils
9
10BASE_URL = 'https://www.googleapis.com'
11GOOGLE_TOKEN_URI = 'https://oauth2.googleapis.com/token'
12LOGGER = singer.get_logger()
13
14
15class Server5xxError(Exception):
16 pass
17
18
19class Server429Error(Exception):
20 pass
21
22
23class GoogleError(Exception):
24 pass
25
26
27class GoogleBadRequestError(GoogleError):
28 pass
29
30
31class GoogleUnauthorizedError(GoogleError):
32 pass
33
34
35class GooglePaymentRequiredError(GoogleError):
36 pass
37
38
39class GoogleNotFoundError(GoogleError):
40 pass
41
42
43class GoogleMethodNotAllowedError(GoogleError):
44 pass
45
46
47class GoogleConflictError(GoogleError):
48 pass
49
50
51class GoogleGoneError(GoogleError):
52 pass
53
54
55class GooglePreconditionFailedError(GoogleError):
56 pass
57
58
59class GoogleRequestEntityTooLargeError(GoogleError):
60 pass
61
62
63class GoogleRequestedRangeNotSatisfiableError(GoogleError):
64 pass
65
66
67class GoogleExpectationFailedError(GoogleError):
68 pass
69
70
71class GoogleForbiddenError(GoogleError):
72 pass
73
74
75class GoogleUnprocessableEntityError(GoogleError):
76 pass
77
78
79class GooglePreconditionRequiredError(GoogleError):
80 pass
81
82
83class GoogleInternalServiceError(GoogleError):
84 pass
85
86
87# Error Codes: https://developers.google.com/webmaster-tools/search-console-api-original/v3/errors
88ERROR_CODE_EXCEPTION_MAPPING = {
89 400: GoogleBadRequestError,
90 401: GoogleUnauthorizedError,
91 402: GooglePaymentRequiredError,
92 403: GoogleForbiddenError,
93 404: GoogleNotFoundError,
94 405: GoogleMethodNotAllowedError,
95 409: GoogleConflictError,
96 410: GoogleGoneError,
97 412: GooglePreconditionFailedError,
98 413: GoogleRequestEntityTooLargeError,
99 416: GoogleRequestedRangeNotSatisfiableError,
100 417: GoogleExpectationFailedError,
101 422: GoogleUnprocessableEntityError,
102 428: GooglePreconditionRequiredError,
103 500: GoogleInternalServiceError}
104
105
106def get_exception_for_error_code(error_code):
107 return ERROR_CODE_EXCEPTION_MAPPING.get(error_code, GoogleError)
108
109def raise_for_error(response):
110 try:
111 response.raise_for_status()
112 except (requests.HTTPError, requests.ConnectionError) as error:
113 try:
114 content_length = len(response.content)
115 if content_length == 0:
116 # There is nothing we can do here since Google has neither sent
117 # us a 2xx response nor a response content.
118 return
119 response = response.json()
120 if ('error' in response) or ('errorCode' in response):
121 message = '%s: %s' % (response.get('error', str(error)),
122 response.get('message', 'Unknown Error'))
123 error_code = response.get('error', {}).get('code')
124 ex = get_exception_for_error_code(error_code)
125 raise ex(message)
126 else:
127 raise GoogleError(error)
128 except (ValueError, TypeError):
129 raise GoogleError(error)
130
131class GoogleClient: # pylint: disable=too-many-instance-attributes
132 def __init__(self,
133 client_id,
134 client_secret,
135 refresh_token,
136 user_agent=None):
137 self.__client_id = client_id
138 self.__client_secret = client_secret
139 self.__refresh_token = refresh_token
140 self.__user_agent = user_agent
141 self.__access_token = None
142 self.__expires = None
143 self.__session = requests.Session()
144 self.base_url = None
145
146
147 def __enter__(self):
148 self.get_access_token()
149 return self
150
151 def __exit__(self, exception_type, exception_value, traceback):
152 self.__session.close()
153
da690bda 154
89643ba6
JH
155 @backoff.on_exception(backoff.expo,
156 Server5xxError,
157 max_tries=5,
158 factor=2)
159 def get_access_token(self):
160 # The refresh_token never expires and may be used many times to generate each access_token
161 # Since the refresh_token does not expire, it is not included in get access_token response
162 if self.__access_token is not None and self.__expires > datetime.utcnow():
163 return
164
165 headers = {}
166 if self.__user_agent:
167 headers['User-Agent'] = self.__user_agent
168
169 response = self.__session.post(
170 url=GOOGLE_TOKEN_URI,
171 headers=headers,
172 data={
173 'grant_type': 'refresh_token',
174 'client_id': self.__client_id,
175 'client_secret': self.__client_secret,
176 'refresh_token': self.__refresh_token,
177 })
178
179 if response.status_code >= 500:
180 raise Server5xxError()
181
182 if response.status_code != 200:
183 raise_for_error(response)
184
185 data = response.json()
186 self.__access_token = data['access_token']
187 self.__expires = datetime.utcnow() + timedelta(seconds=data['expires_in'])
188 LOGGER.info('Authorized, token expires = {}'.format(self.__expires))
189
190
da690bda
JH
191 # Rate Limit: https://developers.google.com/sheets/api/limits
192 # 100 request per 100 seconds per User
89643ba6
JH
193 @backoff.on_exception(backoff.expo,
194 (Server5xxError, ConnectionError, Server429Error),
195 max_tries=7,
196 factor=3)
da690bda 197 @utils.ratelimit(100, 100)
89643ba6
JH
198 def request(self, method, path=None, url=None, api=None, **kwargs):
199
200 self.get_access_token()
201
202 self.base_url = 'https://sheets.googleapis.com/v4'
203 if api == 'files':
204 self.base_url = 'https://www.googleapis.com/drive/v3'
205
206 if not url and path:
207 url = '{}/{}'.format(self.base_url, path)
208
209 # endpoint = stream_name (from sync.py API call)
210 if 'endpoint' in kwargs:
211 endpoint = kwargs['endpoint']
212 del kwargs['endpoint']
213 else:
214 endpoint = None
da690bda 215 LOGGER.info('{} URL = {}'.format(endpoint, url))
89643ba6
JH
216
217 if 'headers' not in kwargs:
218 kwargs['headers'] = {}
219 kwargs['headers']['Authorization'] = 'Bearer {}'.format(self.__access_token)
220
221 if self.__user_agent:
222 kwargs['headers']['User-Agent'] = self.__user_agent
223
224 if method == 'POST':
225 kwargs['headers']['Content-Type'] = 'application/json'
226
227 with metrics.http_request_timer(endpoint) as timer:
228 response = self.__session.request(method, url, **kwargs)
229 timer.tags[metrics.Tag.http_status_code] = response.status_code
230
231 if response.status_code >= 500:
232 raise Server5xxError()
233
234 #Use retry functionality in backoff to wait and retry if
235 #response code equals 429 because rate limit has been exceeded
236 if response.status_code == 429:
237 raise Server429Error()
238
239 if response.status_code != 200:
240 raise_for_error(response)
241
242 # Ensure keys and rows are ordered as received from API
243 return response.json(object_pairs_hook=OrderedDict)
244
245 def get(self, path, api, **kwargs):
246 return self.request(method='GET', path=path, api=api, **kwargs)
247
248 def post(self, path, api, **kwargs):
249 return self.request(method='POST', path=path, api=api, **kwargs)