3
3
import logging
4
4
import time
5
5
from copy import deepcopy
6
- from datetime import datetime
6
+ from datetime import datetime , timedelta
7
7
from os import environ
8
8
from random import choice , shuffle
9
- from typing import Any , Dict , List , Optional , Set
9
+ from typing import Any , Dict , List , Optional , Set , Tuple
10
10
11
11
import jwt
12
12
import requests
15
15
16
16
17
17
class TokenManager :
18
- """A class to store a token's attributes and state."""
18
+ """A class to store a token's attributes and state.
19
+ This parent class should not be used directly, use a subclass instead.
20
+ """
19
21
20
22
DEFAULT_RATE_LIMIT = 5000
21
23
# The DEFAULT_RATE_LIMIT_BUFFER buffer serves two purposes:
@@ -25,7 +27,7 @@ class TokenManager:
25
27
26
28
def __init__ (
27
29
self ,
28
- token : str ,
30
+ token : Optional [ str ] ,
29
31
rate_limit_buffer : Optional [int ] = None ,
30
32
logger : Optional [Any ] = None ,
31
33
):
@@ -50,6 +52,9 @@ def update_rate_limit(self, response_headers: Any) -> None:
50
52
51
53
def is_valid_token (self ) -> bool :
52
54
"""Try making a request with the current token. If the request succeeds return True, else False."""
55
+ if not self .token :
56
+ return False
57
+
53
58
try :
54
59
response = requests .get (
55
60
url = "https://api.github.com/rate_limit" ,
@@ -61,7 +66,7 @@ def is_valid_token(self) -> bool:
61
66
return True
62
67
except requests .exceptions .HTTPError :
63
68
msg = (
64
- f"A token was dismissed . "
69
+ f"A token could not be validated . "
65
70
f"{ response .status_code } Client Error: "
66
71
f"{ str (response .content )} (Reason: { response .reason } )"
67
72
)
@@ -70,7 +75,7 @@ def is_valid_token(self) -> bool:
70
75
return False
71
76
72
77
def has_calls_remaining (self ) -> bool :
73
- """Check if token is valid .
78
+ """Check if a token has capacity to make more calls .
74
79
75
80
Returns:
76
81
True if the token is valid and has enough api calls remaining.
@@ -85,6 +90,14 @@ def has_calls_remaining(self) -> bool:
85
90
return True
86
91
87
92
93
+ class PersonalTokenManager (TokenManager ):
94
+ """A class to store token rate limiting information."""
95
+
96
+ def __init__ (self , token : str , rate_limit_buffer : Optional [int ] = None , ** kwargs ):
97
+ """Init PersonalTokenRateLimit info."""
98
+ super ().__init__ (token , rate_limit_buffer = rate_limit_buffer , ** kwargs )
99
+
100
+
88
101
def generate_jwt_token (
89
102
github_app_id : str ,
90
103
github_private_key : str ,
@@ -110,7 +123,8 @@ def generate_app_access_token(
110
123
github_app_id : str ,
111
124
github_private_key : str ,
112
125
github_installation_id : Optional [str ] = None ,
113
- ) -> str :
126
+ ) -> Tuple [str , datetime ]:
127
+ produced_at = datetime .now ()
114
128
jwt_token = generate_jwt_token (github_app_id , github_private_key )
115
129
116
130
headers = {"Authorization" : f"Bearer { jwt_token } " }
@@ -135,14 +149,71 @@ def generate_app_access_token(
135
149
if resp .status_code != 201 :
136
150
resp .raise_for_status ()
137
151
138
- return resp .json ()["token" ]
152
+ expires_at = produced_at + timedelta (hours = 1 )
153
+ return resp .json ()["token" ], expires_at
154
+
155
+
156
+ class AppTokenManager (TokenManager ):
157
+ """A class to store an app token's attributes and state, and handle token refreshing"""
158
+
159
+ DEFAULT_RATE_LIMIT = 15000
160
+ DEFAULT_EXPIRY_BUFFER_MINS = 10
161
+
162
+ def __init__ (self , env_key : str , rate_limit_buffer : Optional [int ] = None , ** kwargs ):
163
+ if rate_limit_buffer is None :
164
+ rate_limit_buffer = self .DEFAULT_RATE_LIMIT_BUFFER
165
+ super ().__init__ (None , rate_limit_buffer = rate_limit_buffer , ** kwargs )
166
+
167
+ parts = env_key .split (";;" )
168
+ self .github_app_id = parts [0 ]
169
+ self .github_private_key = (parts [1 :2 ] or ["" ])[0 ].replace ("\\ n" , "\n " )
170
+ self .github_installation_id : Optional [str ] = (parts [2 :3 ] or ["" ])[0 ]
171
+
172
+ self .token_expires_at : Optional [datetime ] = None
173
+ self .claim_token ()
174
+
175
+ def claim_token (self ):
176
+ """Updates the TokenManager's token and token_expires_at attributes.
177
+
178
+ The outcome will be _either_ that self.token is updated to a newly claimed valid token and
179
+ self.token_expires_at is updated to the anticipated expiry time (erring on the side of an early estimate)
180
+ _or_ self.token and self.token_expires_at are both set to None.
181
+ """
182
+ self .token = None
183
+ self .token_expires_at = None
184
+
185
+ # Make sure we have the details we need
186
+ if not self .github_app_id or not self .github_private_key :
187
+ raise ValueError (
188
+ "GITHUB_APP_PRIVATE_KEY could not be parsed. The expected format is "
189
+ '":app_id:;;-----BEGIN RSA PRIVATE KEY-----\\ n_YOUR_P_KEY_\\ n-----END RSA PRIVATE KEY-----"'
190
+ )
191
+
192
+ self .token , self .token_expires_at = generate_app_access_token (
193
+ self .github_app_id , self .github_private_key , self .github_installation_id
194
+ )
195
+
196
+ # Check if the token isn't valid. If not, overwrite it with None
197
+ if not self .is_valid_token ():
198
+ if self .logger :
199
+ self .logger .warning (
200
+ "An app token was generated but could not be validated."
201
+ )
202
+ self .token = None
203
+ self .token_expires_at = None
139
204
140
205
141
206
class GitHubTokenAuthenticator (APIAuthenticatorBase ):
142
207
"""Base class for offloading API auth."""
143
208
209
+ @staticmethod
210
+ def get_env ():
211
+ return dict (environ )
212
+
144
213
def prepare_tokens (self ) -> List [TokenManager ]:
145
- # Save GitHub tokens
214
+ """Prep GitHub tokens"""
215
+
216
+ env_dict = self .get_env ()
146
217
rate_limit_buffer = self ._config .get ("rate_limit_buffer" , None )
147
218
148
219
personal_tokens : Set [str ] = set ()
@@ -156,52 +227,42 @@ def prepare_tokens(self) -> List[TokenManager]:
156
227
# Accept multiple tokens using environment variables GITHUB_TOKEN*
157
228
env_tokens = {
158
229
value
159
- for key , value in environ .items ()
230
+ for key , value in env_dict .items ()
160
231
if key .startswith ("GITHUB_TOKEN" )
161
232
}
162
233
if len (env_tokens ) > 0 :
163
234
self .logger .info (
164
235
f"Found { len (env_tokens )} 'GITHUB_TOKEN' environment variables for authentication."
165
236
)
166
- personal_tokens = env_tokens
237
+ personal_tokens = personal_tokens . union ( env_tokens )
167
238
168
239
token_managers : List [TokenManager ] = []
169
240
for token in personal_tokens :
170
- token_manager = TokenManager (
241
+ token_manager = PersonalTokenManager (
171
242
token , rate_limit_buffer = rate_limit_buffer , logger = self .logger
172
243
)
173
244
if token_manager .is_valid_token ():
174
245
token_managers .append (token_manager )
246
+ else :
247
+ logging .warn ("A token was dismissed." )
175
248
176
249
# Parse App level private key and generate a token
177
- if "GITHUB_APP_PRIVATE_KEY" in environ .keys ():
250
+ if "GITHUB_APP_PRIVATE_KEY" in env_dict .keys ():
178
251
# To simplify settings, we use a single env-key formatted as follows:
179
252
# "{app_id};;{-----BEGIN RSA PRIVATE KEY-----\n_YOUR_PRIVATE_KEY_\n-----END RSA PRIVATE KEY-----}"
180
- parts = environ ["GITHUB_APP_PRIVATE_KEY" ].split (";;" )
181
- github_app_id = parts [0 ]
182
- github_private_key = (parts [1 :2 ] or ["" ])[0 ].replace ("\\ n" , "\n " )
183
- github_installation_id = (parts [2 :3 ] or ["" ])[0 ]
184
-
185
- if not (github_private_key ):
186
- self .logger .warning (
187
- "GITHUB_APP_PRIVATE_KEY could not be parsed. The expected format is "
188
- '":app_id:;;-----BEGIN RSA PRIVATE KEY-----\n _YOUR_P_KEY_\n -----END RSA PRIVATE KEY-----"'
253
+ env_key = env_dict ["GITHUB_APP_PRIVATE_KEY" ]
254
+ try :
255
+ app_token_manager = AppTokenManager (
256
+ env_key , rate_limit_buffer = rate_limit_buffer , logger = self .logger
189
257
)
190
-
191
- else :
192
- app_token = generate_app_access_token (
193
- github_app_id , github_private_key , github_installation_id or None
194
- )
195
- token_manager = TokenManager (
196
- app_token , rate_limit_buffer = rate_limit_buffer , logger = self .logger
258
+ if app_token_manager .is_valid_token ():
259
+ token_managers .append (app_token_manager )
260
+ except ValueError as e :
261
+ self .logger .warn (
262
+ f"An error was thrown while preparing an app token: { e } "
197
263
)
198
- if token_manager .is_valid_token ():
199
- token_managers .append (token_manager )
200
264
201
265
self .logger .info (f"Tap will run with { len (token_managers )} auth tokens" )
202
-
203
- # Create a dict of TokenManager
204
- # TODO - separate app_token and add logic to refresh the token using generate_app_access_token.
205
266
return token_managers
206
267
207
268
def __init__ (self , stream : RESTStream ) -> None :
0 commit comments